Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
62e80c60
Commit
62e80c60
authored
Apr 05, 2026
by
erio
Browse files
revert: completely remove all Sora functionality
parent
dbb248df
Changes
136
Hide whitespace changes
Inline
Side-by-side
frontend/src/api/sora.ts
deleted
100644 → 0
View file @
dbb248df
/**
* Sora 客户端 API
* 封装所有 Sora 生成、作品库、配额等接口调用
*/
import
{
apiClient
}
from
'
./client
'
// ==================== 类型定义 ====================
export
interface
SoraGeneration
{
id
:
number
user_id
:
number
model
:
string
prompt
:
string
media_type
:
string
status
:
string
// pending | generating | completed | failed | cancelled
storage_type
:
string
// upstream | s3 | local
media_url
:
string
media_urls
:
string
[]
s3_object_keys
:
string
[]
file_size_bytes
:
number
error_message
:
string
created_at
:
string
completed_at
?:
string
}
export
interface
GenerateRequest
{
model
:
string
prompt
:
string
video_count
?:
number
media_type
?:
string
image_input
?:
string
api_key_id
?:
number
}
export
interface
GenerateResponse
{
generation_id
:
number
status
:
string
}
export
interface
GenerationListResponse
{
data
:
SoraGeneration
[]
total
:
number
page
:
number
}
export
interface
QuotaInfo
{
quota_bytes
:
number
used_bytes
:
number
available_bytes
:
number
quota_source
:
string
// user | group | system | unlimited
source
?:
string
// 兼容旧字段
}
export
interface
StorageStatus
{
s3_enabled
:
boolean
s3_healthy
:
boolean
local_enabled
:
boolean
}
/** 单个扁平模型(旧接口,保留兼容) */
export
interface
SoraModel
{
id
:
string
name
:
string
type
:
string
// video | image
orientation
?:
string
duration
?:
number
}
/** 模型家族(新接口 — 后端从 soraModelConfigs 自动聚合) */
export
interface
SoraModelFamily
{
id
:
string
// 家族 ID,如 "sora2"
name
:
string
// 显示名,如 "Sora 2"
type
:
string
// "video" | "image"
orientations
:
string
[]
// ["landscape", "portrait"] 或 ["landscape", "portrait", "square"]
durations
?:
number
[]
// [10, 15, 25](仅视频模型)
}
type
LooseRecord
=
Record
<
string
,
unknown
>
function
asRecord
(
value
:
unknown
):
LooseRecord
|
null
{
return
value
!==
null
&&
typeof
value
===
'
object
'
?
value
as
LooseRecord
:
null
}
function
asArray
<
T
=
unknown
>
(
value
:
unknown
):
T
[]
{
return
Array
.
isArray
(
value
)
?
value
as
T
[]
:
[]
}
function
asPositiveInt
(
value
:
unknown
):
number
|
null
{
const
n
=
Number
(
value
)
if
(
!
Number
.
isFinite
(
n
)
||
n
<=
0
)
return
null
return
Math
.
round
(
n
)
}
function
dedupeStrings
(
values
:
string
[]):
string
[]
{
return
Array
.
from
(
new
Set
(
values
))
}
function
extractOrientationFromModelID
(
modelID
:
string
):
string
|
null
{
const
m
=
modelID
.
match
(
/-
(
landscape|portrait|square
)(?:
-
\d
+s
)?
$/i
)
return
m
?
m
[
1
].
toLowerCase
()
:
null
}
function
extractDurationFromModelID
(
modelID
:
string
):
number
|
null
{
const
m
=
modelID
.
match
(
/-
(\d
+
)
s$/i
)
return
m
?
asPositiveInt
(
m
[
1
])
:
null
}
function
normalizeLegacyFamilies
(
candidates
:
unknown
[]):
SoraModelFamily
[]
{
const
familyMap
=
new
Map
<
string
,
SoraModelFamily
>
()
for
(
const
item
of
candidates
)
{
const
model
=
asRecord
(
item
)
if
(
!
model
||
typeof
model
.
id
!==
'
string
'
||
model
.
id
.
trim
()
===
''
)
continue
const
rawID
=
model
.
id
.
trim
()
const
type
=
model
.
type
===
'
image
'
?
'
image
'
:
'
video
'
const
name
=
typeof
model
.
name
===
'
string
'
&&
model
.
name
.
trim
()
?
model
.
name
.
trim
()
:
rawID
const
baseID
=
rawID
.
replace
(
/-
(
landscape|portrait|square
)(?:
-
\d
+s
)?
$/i
,
''
)
const
orientation
=
typeof
model
.
orientation
===
'
string
'
&&
model
.
orientation
?
model
.
orientation
.
toLowerCase
()
:
extractOrientationFromModelID
(
rawID
)
const
duration
=
asPositiveInt
(
model
.
duration
)
??
extractDurationFromModelID
(
rawID
)
const
familyKey
=
baseID
||
rawID
const
family
=
familyMap
.
get
(
familyKey
)
??
{
id
:
familyKey
,
name
,
type
,
orientations
:
[],
durations
:
[]
}
if
(
orientation
)
{
family
.
orientations
.
push
(
orientation
)
}
if
(
type
===
'
video
'
&&
duration
)
{
family
.
durations
=
family
.
durations
||
[]
family
.
durations
.
push
(
duration
)
}
familyMap
.
set
(
familyKey
,
family
)
}
return
Array
.
from
(
familyMap
.
values
())
.
map
((
family
)
=>
({
...
family
,
orientations
:
family
.
orientations
.
length
>
0
?
dedupeStrings
(
family
.
orientations
)
:
(
family
.
type
===
'
image
'
?
[
'
square
'
]
:
[
'
landscape
'
]),
durations
:
family
.
type
===
'
video
'
?
Array
.
from
(
new
Set
((
family
.
durations
||
[]).
filter
((
d
):
d
is
number
=>
Number
.
isFinite
(
d
)))).
sort
((
a
,
b
)
=>
a
-
b
)
:
[]
}))
.
filter
((
family
)
=>
family
.
id
!==
''
)
}
function
normalizeModelFamilyRecord
(
item
:
unknown
):
SoraModelFamily
|
null
{
const
model
=
asRecord
(
item
)
if
(
!
model
||
typeof
model
.
id
!==
'
string
'
||
model
.
id
.
trim
()
===
''
)
return
null
// 仅把明确的“家族结构”识别为 family;老结构(单模型)走 legacy 聚合逻辑。
if
(
!
Array
.
isArray
(
model
.
orientations
)
&&
!
Array
.
isArray
(
model
.
durations
))
return
null
const
orientations
=
asArray
<
string
>
(
model
.
orientations
).
filter
((
o
):
o
is
string
=>
typeof
o
===
'
string
'
&&
o
.
length
>
0
)
const
durations
=
asArray
<
unknown
>
(
model
.
durations
)
.
map
(
asPositiveInt
)
.
filter
((
d
):
d
is
number
=>
d
!==
null
)
return
{
id
:
model
.
id
.
trim
(),
name
:
typeof
model
.
name
===
'
string
'
&&
model
.
name
.
trim
()
?
model
.
name
.
trim
()
:
model
.
id
.
trim
(),
type
:
model
.
type
===
'
image
'
?
'
image
'
:
'
video
'
,
orientations
:
dedupeStrings
(
orientations
),
durations
:
Array
.
from
(
new
Set
(
durations
)).
sort
((
a
,
b
)
=>
a
-
b
)
}
}
function
extractCandidateArray
(
payload
:
unknown
):
unknown
[]
{
if
(
Array
.
isArray
(
payload
))
return
payload
const
record
=
asRecord
(
payload
)
if
(
!
record
)
return
[]
const
keys
:
Array
<
keyof
LooseRecord
>
=
[
'
data
'
,
'
items
'
,
'
models
'
,
'
families
'
]
for
(
const
key
of
keys
)
{
if
(
Array
.
isArray
(
record
[
key
]))
{
return
record
[
key
]
as
unknown
[]
}
}
return
[]
}
export
function
normalizeModelFamiliesResponse
(
payload
:
unknown
):
SoraModelFamily
[]
{
const
candidates
=
extractCandidateArray
(
payload
)
if
(
candidates
.
length
===
0
)
return
[]
const
normalized
=
candidates
.
map
(
normalizeModelFamilyRecord
)
.
filter
((
item
):
item
is
SoraModelFamily
=>
item
!==
null
)
if
(
normalized
.
length
>
0
)
return
normalized
return
normalizeLegacyFamilies
(
candidates
)
}
export
function
normalizeGenerationListResponse
(
payload
:
unknown
):
GenerationListResponse
{
const
record
=
asRecord
(
payload
)
if
(
!
record
)
{
return
{
data
:
[],
total
:
0
,
page
:
1
}
}
const
data
=
Array
.
isArray
(
record
.
data
)
?
(
record
.
data
as
SoraGeneration
[])
:
Array
.
isArray
(
record
.
items
)
?
(
record
.
items
as
SoraGeneration
[])
:
[]
const
total
=
Number
(
record
.
total
)
const
page
=
Number
(
record
.
page
)
return
{
data
,
total
:
Number
.
isFinite
(
total
)
?
total
:
data
.
length
,
page
:
Number
.
isFinite
(
page
)
&&
page
>
0
?
page
:
1
}
}
// ==================== API 方法 ====================
/** 异步生成 — 创建 pending 记录后立即返回 */
export
async
function
generate
(
req
:
GenerateRequest
):
Promise
<
GenerateResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
GenerateResponse
>
(
'
/sora/generate
'
,
req
)
return
data
}
/** 查询生成记录列表 */
export
async
function
listGenerations
(
params
?:
{
page
?:
number
page_size
?:
number
status
?:
string
storage_type
?:
string
media_type
?:
string
}):
Promise
<
GenerationListResponse
>
{
const
{
data
}
=
await
apiClient
.
get
<
unknown
>
(
'
/sora/generations
'
,
{
params
})
return
normalizeGenerationListResponse
(
data
)
}
/** 查询生成记录详情 */
export
async
function
getGeneration
(
id
:
number
):
Promise
<
SoraGeneration
>
{
const
{
data
}
=
await
apiClient
.
get
<
SoraGeneration
>
(
`/sora/generations/
${
id
}
`
)
return
data
}
/** 删除生成记录 */
export
async
function
deleteGeneration
(
id
:
number
):
Promise
<
{
message
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
delete
<
{
message
:
string
}
>
(
`/sora/generations/
${
id
}
`
)
return
data
}
/** 取消生成任务 */
export
async
function
cancelGeneration
(
id
:
number
):
Promise
<
{
message
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
post
<
{
message
:
string
}
>
(
`/sora/generations/
${
id
}
/cancel`
)
return
data
}
/** 手动保存到 S3 */
export
async
function
saveToStorage
(
id
:
number
):
Promise
<
{
message
:
string
;
object_key
:
string
;
object_keys
?:
string
[]
}
>
{
const
{
data
}
=
await
apiClient
.
post
<
{
message
:
string
;
object_key
:
string
;
object_keys
?:
string
[]
}
>
(
`/sora/generations/
${
id
}
/save`
)
return
data
}
/** 查询配额信息 */
export
async
function
getQuota
():
Promise
<
QuotaInfo
>
{
const
{
data
}
=
await
apiClient
.
get
<
QuotaInfo
>
(
'
/sora/quota
'
)
return
data
}
/** 获取可用模型家族列表 */
export
async
function
getModels
():
Promise
<
SoraModelFamily
[]
>
{
const
{
data
}
=
await
apiClient
.
get
<
unknown
>
(
'
/sora/models
'
)
return
normalizeModelFamiliesResponse
(
data
)
}
/** 获取存储状态 */
export
async
function
getStorageStatus
():
Promise
<
StorageStatus
>
{
const
{
data
}
=
await
apiClient
.
get
<
StorageStatus
>
(
'
/sora/storage-status
'
)
return
data
}
const
soraAPI
=
{
generate
,
listGenerations
,
getGeneration
,
deleteGeneration
,
cancelGeneration
,
saveToStorage
,
getQuota
,
getModels
,
getStorageStatus
}
export
default
soraAPI
frontend/src/components/account/AccountTestModal.vue
View file @
62e80c60
...
...
@@ -41,7 +41,7 @@
</span>
</div>
<div
v-if=
"!isSoraAccount"
class=
"space-y-1.5"
>
<div
class=
"space-y-1.5"
>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.accounts.selectTestModel
'
)
}}
</label>
...
...
@@ -54,12 +54,6 @@
:placeholder=
"loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')"
/>
</div>
<div
v-else
class=
"rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-300"
>
{{
t
(
'
admin.accounts.soraTestHint
'
)
}}
</div>
<div
v-if=
"supportsGeminiImageTest"
class=
"space-y-1.5"
>
<TextArea
...
...
@@ -152,17 +146,15 @@
<div
class=
"flex items-center gap-3"
>
<span
class=
"flex items-center gap-1"
>
<Icon
name=
"grid"
size=
"sm"
:stroke-width=
"2"
/>
{{
isSoraAccount
?
t
(
'
admin.accounts.soraTestTarget
'
)
:
t
(
'
admin.accounts.testModel
'
)
}}
{{
t
(
'
admin.accounts.testModel
'
)
}}
</span>
</div>
<span
class=
"flex items-center gap-1"
>
<Icon
name=
"chat"
size=
"sm"
:stroke-width=
"2"
/>
{{
isSoraAccount
?
t
(
'
admin.accounts.soraTestMode
'
)
:
supportsGeminiImageTest
?
t
(
'
admin.accounts.geminiImageTestMode
'
)
:
t
(
'
admin.accounts.testPrompt
'
)
supportsGeminiImageTest
?
t
(
'
admin.accounts.geminiImageTestMode
'
)
:
t
(
'
admin.accounts.testPrompt
'
)
}}
</span>
</div>
...
...
@@ -179,10 +171,10 @@
</button>
<button
@
click=
"startTest"
:disabled=
"status === 'connecting' ||
(!isSoraAccount &&
!selectedModelId
)
"
:disabled=
"status === 'connecting' || !selectedModelId"
:class=
"[
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
status === 'connecting' ||
(!isSoraAccount &&
!selectedModelId
)
status === 'connecting' || !selectedModelId
? 'cursor-not-allowed bg-primary-400 text-white'
: status === 'success'
? 'bg-green-500 text-white hover:bg-green-600'
...
...
@@ -258,11 +250,9 @@ const selectedModelId = ref('')
const
testPrompt
=
ref
(
''
)
const
loadingModels
=
ref
(
false
)
let
eventSource
:
EventSource
|
null
=
null
const
isSoraAccount
=
computed
(()
=>
props
.
account
?.
platform
===
'
sora
'
)
const
generatedImages
=
ref
<
PreviewImage
[]
>
([])
const
prioritizedGeminiModels
=
[
'
gemini-3.1-flash-image
'
,
'
gemini-2.5-flash-image
'
,
'
gemini-2.5-flash
'
,
'
gemini-2.5-pro
'
,
'
gemini-3-flash-preview
'
,
'
gemini-3-pro-preview
'
,
'
gemini-2.0-flash
'
]
const
supportsGeminiImageTest
=
computed
(()
=>
{
if
(
isSoraAccount
.
value
)
return
false
const
modelID
=
selectedModelId
.
value
.
toLowerCase
()
if
(
!
modelID
.
startsWith
(
'
gemini-
'
)
||
!
modelID
.
includes
(
'
-image
'
))
return
false
...
...
@@ -302,12 +292,6 @@ watch(selectedModelId, () => {
const
loadAvailableModels
=
async
()
=>
{
if
(
!
props
.
account
)
return
if
(
props
.
account
.
platform
===
'
sora
'
)
{
availableModels
.
value
=
[]
selectedModelId
.
value
=
''
loadingModels
.
value
=
false
return
}
loadingModels
.
value
=
true
selectedModelId
.
value
=
''
// Reset selection before loading
...
...
@@ -373,7 +357,7 @@ const scrollToBottom = async () => {
}
const
startTest
=
async
()
=>
{
if
(
!
props
.
account
||
(
!
isSoraAccount
.
value
&&
!
selectedModelId
.
value
)
)
return
if
(
!
props
.
account
||
!
selectedModelId
.
value
)
return
resetState
()
status
.
value
=
'
connecting
'
...
...
@@ -394,14 +378,10 @@ const startTest = async () => {
Authorization
:
`Bearer
${
localStorage
.
getItem
(
'
auth_token
'
)}
`
,
'
Content-Type
'
:
'
application/json
'
},
body
:
JSON
.
stringify
(
isSoraAccount
.
value
?
{}
:
{
body
:
JSON
.
stringify
({
model_id
:
selectedModelId
.
value
,
prompt
:
supportsGeminiImageTest
.
value
?
testPrompt
.
value
.
trim
()
:
''
}
)
})
})
if
(
!
response
.
ok
)
{
...
...
@@ -461,9 +441,7 @@ const handleEvent = (event: {
addLine
(
t
(
'
admin.accounts.usingModel
'
,
{
model
:
event
.
model
}),
'
text-cyan-400
'
)
}
addLine
(
isSoraAccount
.
value
?
t
(
'
admin.accounts.soraTestingFlow
'
)
:
supportsGeminiImageTest
.
value
supportsGeminiImageTest
.
value
?
t
(
'
admin.accounts.sendingGeminiImageRequest
'
)
:
t
(
'
admin.accounts.sendingTestMessage
'
),
'
text-gray-400
'
...
...
frontend/src/components/account/CreateAccountModal.vue
View file @
62e80c60
...
...
@@ -109,28 +109,6 @@
</svg>
OpenAI
</button>
<button
type=
"button"
@
click=
"form.platform = 'sora'"
:class=
"[
'flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
form.platform === 'sora'
? 'bg-white text-rose-600 shadow-sm dark:bg-dark-600 dark:text-rose-400'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
]"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Sora
</button>
<button
type=
"button"
@
click=
"form.platform = 'gemini'"
...
...
@@ -172,63 +150,6 @@
</div>
</div>
<!-- Account Type Selection (Sora) -->
<div
v-if=
"form.platform === 'sora'"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.accountType
'
)
}}
</label>
<div
class=
"mt-2 grid grid-cols-2 gap-3"
data-tour=
"account-form-type"
>
<button
type=
"button"
@
click=
"soraAccountType = 'oauth'; accountCategory = 'oauth-based'; addMethod = 'oauth'"
:class=
"[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
soraAccountType === 'oauth'
? 'border-rose-500 bg-rose-50 dark:bg-rose-900/20'
: 'border-gray-200 hover:border-rose-300 dark:border-dark-600 dark:hover:border-rose-700'
]"
>
<div
:class=
"[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
soraAccountType === 'oauth'
? 'bg-rose-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon
name=
"key"
size=
"sm"
/>
</div>
<div>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
OAuth
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.types.chatgptOauth
'
)
}}
</span>
</div>
</button>
<button
type=
"button"
@
click=
"soraAccountType = 'apikey'; accountCategory = 'apikey'"
:class=
"[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
soraAccountType === 'apikey'
? 'border-rose-500 bg-rose-50 dark:bg-rose-900/20'
: 'border-gray-200 hover:border-rose-300 dark:border-dark-600 dark:hover:border-rose-700'
]"
>
<div
:class=
"[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
soraAccountType === 'apikey'
? 'bg-rose-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon
name=
"link"
size=
"sm"
/>
</div>
<div>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
admin.accounts.types.soraApiKey
'
)
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.types.soraApiKeyHint
'
)
}}
</span>
</div>
</button>
</div>
</div>
<!-- Account Type Selection (Anthropic) -->
<div
v-if=
"form.platform === 'anthropic'"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.accountType
'
)
}}
</label>
...
...
@@ -935,14 +856,14 @@
type=
"text"
class=
"input"
:placeholder=
"
form.platform === 'openai'
|| form.platform === 'sora'
form.platform === 'openai'
? 'https://api.openai.com'
: form.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
"
/>
<p
class=
"input-hint"
>
{{
form
.
platform
===
'
sora
'
?
t
(
'
admin.accounts.soraUpstreamBaseUrlHint
'
)
:
baseUrlHint
}}
</p>
<p
class=
"input-hint"
>
{{
baseUrlHint
}}
</p>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.apiKeyRequired
'
)
}}
</label>
...
...
@@ -2543,13 +2464,13 @@
:
loading
=
"
currentOAuthLoading
"
:
error
=
"
currentOAuthError
"
:
show
-
help
=
"
form.platform === 'anthropic'
"
:
show
-
proxy
-
warning
=
"
form.platform !== 'openai' &&
form.platform !== 'sora' &&
!!form.proxy_id
"
:
show
-
proxy
-
warning
=
"
form.platform !== 'openai' && !!form.proxy_id
"
:
allow
-
multiple
=
"
form.platform === 'anthropic'
"
:
show
-
cookie
-
option
=
"
form.platform === 'anthropic'
"
:
show
-
refresh
-
token
-
option
=
"
form.platform === 'openai' || form.platform ===
'sora' || form.platform ===
'antigravity'
"
:
show
-
refresh
-
token
-
option
=
"
form.platform === 'openai' || form.platform === 'antigravity'
"
:
show
-
mobile
-
refresh
-
token
-
option
=
"
form.platform === 'openai'
"
:
show
-
session
-
token
-
option
=
"
f
orm.platform === 'sora'
"
:
show
-
access
-
token
-
option
=
"
f
orm.platform === 'sora'
"
:
show
-
session
-
token
-
option
=
"
f
alse
"
:
show
-
access
-
token
-
option
=
"
f
alse
"
:
platform
=
"
form.platform
"
:
show
-
project
-
id
=
"
geminiOAuthType === 'code_assist'
"
@
generate
-
url
=
"
handleGenerateUrl
"
...
...
@@ -2943,7 +2864,7 @@ const { t } = useI18n()
const
authStore
=
useAuthStore
()
const
oauthStepTitle
=
computed
(()
=>
{
if
(
form
.
platform
===
'
openai
'
||
form
.
platform
===
'
sora
'
)
return
t
(
'
admin.accounts.oauth.openai.title
'
)
if
(
form
.
platform
===
'
openai
'
)
return
t
(
'
admin.accounts.oauth.openai.title
'
)
if
(
form
.
platform
===
'
gemini
'
)
return
t
(
'
admin.accounts.oauth.gemini.title
'
)
if
(
form
.
platform
===
'
antigravity
'
)
return
t
(
'
admin.accounts.oauth.antigravity.title
'
)
return
t
(
'
admin.accounts.oauth.title
'
)
...
...
@@ -2951,13 +2872,13 @@ const oauthStepTitle = computed(() => {
// Platform-specific hints for API Key type
const
baseUrlHint
=
computed
(()
=>
{
if
(
form
.
platform
===
'
openai
'
||
form
.
platform
===
'
sora
'
)
return
t
(
'
admin.accounts.openai.baseUrlHint
'
)
if
(
form
.
platform
===
'
openai
'
)
return
t
(
'
admin.accounts.openai.baseUrlHint
'
)
if
(
form
.
platform
===
'
gemini
'
)
return
t
(
'
admin.accounts.gemini.baseUrlHint
'
)
return
t
(
'
admin.accounts.baseUrlHint
'
)
}
)
const
apiKeyHint
=
computed
(()
=>
{
if
(
form
.
platform
===
'
openai
'
||
form
.
platform
===
'
sora
'
)
return
t
(
'
admin.accounts.openai.apiKeyHint
'
)
if
(
form
.
platform
===
'
openai
'
)
return
t
(
'
admin.accounts.openai.apiKeyHint
'
)
if
(
form
.
platform
===
'
gemini
'
)
return
t
(
'
admin.accounts.gemini.apiKeyHint
'
)
return
t
(
'
admin.accounts.apiKeyHint
'
)
}
)
...
...
@@ -2979,35 +2900,33 @@ const appStore = useAppStore()
// OAuth composables
const
oauth
=
useAccountOAuth
()
// For Anthropic OAuth
const
openaiOAuth
=
useOpenAIOAuth
({
platform
:
'
openai
'
}
)
// For OpenAI OAuth
const
soraOAuth
=
useOpenAIOAuth
({
platform
:
'
sora
'
}
)
// For Sora OAuth
const
geminiOAuth
=
useGeminiOAuth
()
// For Gemini OAuth
const
antigravityOAuth
=
useAntigravityOAuth
()
// For Antigravity OAuth
const
activeOpenAIOAuth
=
computed
(()
=>
(
form
.
platform
===
'
sora
'
?
soraOAuth
:
openaiOAuth
))
// Computed: current OAuth state for template binding
const
currentAuthUrl
=
computed
(()
=>
{
if
(
form
.
platform
===
'
openai
'
||
form
.
platform
===
'
sora
'
)
return
activeO
pen
AI
OAuth
.
value
.
authUrl
.
value
if
(
form
.
platform
===
'
openai
'
)
return
o
pen
ai
OAuth
.
authUrl
.
value
if
(
form
.
platform
===
'
gemini
'
)
return
geminiOAuth
.
authUrl
.
value
if
(
form
.
platform
===
'
antigravity
'
)
return
antigravityOAuth
.
authUrl
.
value
return
oauth
.
authUrl
.
value
}
)
const
currentSessionId
=
computed
(()
=>
{
if
(
form
.
platform
===
'
openai
'
||
form
.
platform
===
'
sora
'
)
return
activeO
pen
AI
OAuth
.
value
.
sessionId
.
value
if
(
form
.
platform
===
'
openai
'
)
return
o
pen
ai
OAuth
.
sessionId
.
value
if
(
form
.
platform
===
'
gemini
'
)
return
geminiOAuth
.
sessionId
.
value
if
(
form
.
platform
===
'
antigravity
'
)
return
antigravityOAuth
.
sessionId
.
value
return
oauth
.
sessionId
.
value
}
)
const
currentOAuthLoading
=
computed
(()
=>
{
if
(
form
.
platform
===
'
openai
'
||
form
.
platform
===
'
sora
'
)
return
activeO
pen
AI
OAuth
.
value
.
loading
.
value
if
(
form
.
platform
===
'
openai
'
)
return
o
pen
ai
OAuth
.
loading
.
value
if
(
form
.
platform
===
'
gemini
'
)
return
geminiOAuth
.
loading
.
value
if
(
form
.
platform
===
'
antigravity
'
)
return
antigravityOAuth
.
loading
.
value
return
oauth
.
loading
.
value
}
)
const
currentOAuthError
=
computed
(()
=>
{
if
(
form
.
platform
===
'
openai
'
||
form
.
platform
===
'
sora
'
)
return
activeO
pen
AI
OAuth
.
value
.
error
.
value
if
(
form
.
platform
===
'
openai
'
)
return
o
pen
ai
OAuth
.
error
.
value
if
(
form
.
platform
===
'
gemini
'
)
return
geminiOAuth
.
error
.
value
if
(
form
.
platform
===
'
antigravity
'
)
return
antigravityOAuth
.
error
.
value
return
oauth
.
error
.
value
...
...
@@ -3065,7 +2984,6 @@ const anthropicPassthroughEnabled = ref(false)
const
mixedScheduling
=
ref
(
false
)
// For antigravity accounts: enable mixed scheduling
const
allowOverages
=
ref
(
false
)
// For antigravity accounts: enable AI Credits overages
const
antigravityAccountType
=
ref
<
'
oauth
'
|
'
upstream
'
>
(
'
oauth
'
)
// For antigravity: oauth or upstream
const
soraAccountType
=
ref
<
'
oauth
'
|
'
apikey
'
>
(
'
oauth
'
)
// For sora: oauth or apikey (upstream)
const
upstreamBaseUrl
=
ref
(
''
)
// For upstream type: base URL
const
upstreamApiKey
=
ref
(
''
)
// For upstream type: API key
const
antigravityModelRestrictionMode
=
ref
<
'
whitelist
'
|
'
mapping
'
>
(
'
whitelist
'
)
...
...
@@ -3277,8 +3195,8 @@ const expiresAtInput = computed({
const
canExchangeCode
=
computed
(()
=>
{
const
authCode
=
oauthFlowRef
.
value
?.
authCode
||
''
if
(
form
.
platform
===
'
openai
'
||
form
.
platform
===
'
sora
'
)
{
return
authCode
.
trim
()
&&
activeO
pen
AI
OAuth
.
value
.
sessionId
.
value
&&
!
activeO
pen
AI
OAuth
.
value
.
loading
.
value
if
(
form
.
platform
===
'
openai
'
)
{
return
authCode
.
trim
()
&&
o
pen
ai
OAuth
.
sessionId
.
value
&&
!
o
pen
ai
OAuth
.
loading
.
value
}
if
(
form
.
platform
===
'
gemini
'
)
{
return
authCode
.
trim
()
&&
geminiOAuth
.
sessionId
.
value
&&
!
geminiOAuth
.
loading
.
value
...
...
@@ -3320,18 +3238,13 @@ watch(
// Sync form.type based on accountCategory, addMethod, and platform-specific type
watch
(
[
accountCategory
,
addMethod
,
antigravityAccountType
,
soraAccountType
],
([
category
,
method
,
agType
,
soraType
])
=>
{
[
accountCategory
,
addMethod
,
antigravityAccountType
],
([
category
,
method
,
agType
])
=>
{
// Antigravity upstream 类型(实际创建为 apikey)
if
(
form
.
platform
===
'
antigravity
'
&&
agType
===
'
upstream
'
)
{
form
.
type
=
'
apikey
'
return
}
// Sora apikey 类型(上游透传)
if
(
form
.
platform
===
'
sora
'
&&
soraType
===
'
apikey
'
)
{
form
.
type
=
'
apikey
'
return
}
// Bedrock 类型
if
(
form
.
platform
===
'
anthropic
'
&&
category
===
'
bedrock
'
)
{
form
.
type
=
'
bedrock
'
as
AccountType
...
...
@@ -3352,7 +3265,7 @@ watch(
(
newPlatform
)
=>
{
// Reset base URL based on platform
apiKeyBaseUrl
.
value
=
(
newPlatform
===
'
openai
'
||
newPlatform
===
'
sora
'
)
(
newPlatform
===
'
openai
'
)
?
'
https://api.openai.com
'
:
newPlatform
===
'
gemini
'
?
'
https://generativelanguage.googleapis.com
'
...
...
@@ -3387,13 +3300,6 @@ watch(
if
(
newPlatform
!==
'
anthropic
'
&&
newPlatform
!==
'
antigravity
'
)
{
interceptWarmupRequests
.
value
=
false
}
if
(
newPlatform
===
'
sora
'
)
{
// 默认 OAuth,但允许用户选择 API Key
accountCategory
.
value
=
'
oauth-based
'
addMethod
.
value
=
'
oauth
'
form
.
type
=
'
oauth
'
soraAccountType
.
value
=
'
oauth
'
}
if
(
newPlatform
!==
'
openai
'
)
{
openaiPassthroughEnabled
.
value
=
false
openaiOAuthResponsesWebSocketV2Mode
.
value
=
OPENAI_WS_MODE_OFF
...
...
@@ -3406,7 +3312,7 @@ watch(
// Reset OAuth states
oauth
.
resetState
()
openaiOAuth
.
resetState
()
soraOAuth
.
resetState
()
geminiOAuth
.
resetState
()
antigravityOAuth
.
resetState
()
}
...
...
@@ -3816,7 +3722,6 @@ const resetForm = () => {
geminiTierAIStudio
.
value
=
'
aistudio_free
'
oauth
.
resetState
()
openaiOAuth
.
resetState
()
soraOAuth
.
resetState
()
geminiOAuth
.
resetState
()
antigravityOAuth
.
resetState
()
oauthFlowRef
.
value
?.
reset
()
...
...
@@ -3877,29 +3782,6 @@ const buildAnthropicExtra = (base?: Record<string, unknown>): Record<string, unk
return
Object
.
keys
(
extra
).
length
>
0
?
extra
:
undefined
}
const
buildSoraExtra
=
(
base
?:
Record
<
string
,
unknown
>
,
linkedOpenAIAccountId
?:
string
|
number
):
Record
<
string
,
unknown
>
|
undefined
=>
{
const
extra
:
Record
<
string
,
unknown
>
=
{
...(
base
||
{
}
)
}
if
(
linkedOpenAIAccountId
!==
undefined
&&
linkedOpenAIAccountId
!==
null
)
{
const
id
=
String
(
linkedOpenAIAccountId
).
trim
()
if
(
id
)
{
extra
.
linked_openai_account_id
=
id
}
}
delete
extra
.
openai_passthrough
delete
extra
.
openai_oauth_passthrough
delete
extra
.
codex_cli_only
delete
extra
.
openai_oauth_responses_websockets_v2_mode
delete
extra
.
openai_apikey_responses_websockets_v2_mode
delete
extra
.
openai_oauth_responses_websockets_v2_enabled
delete
extra
.
openai_apikey_responses_websockets_v2_enabled
delete
extra
.
responses_websockets_v2_enabled
delete
extra
.
openai_ws_enabled
return
Object
.
keys
(
extra
).
length
>
0
?
extra
:
undefined
}
// Helper function to create account with mixed channel warning handling
const
doCreateAccount
=
async
(
payload
:
CreateAccountRequest
)
=>
{
const
canContinue
=
await
ensureAntigravityMixedChannelConfirmed
(
async
()
=>
{
...
...
@@ -4064,19 +3946,6 @@ const handleSubmit = async () => {
return
}
// Sora apikey 账号 base_url 必填 + scheme 校验
if
(
form
.
platform
===
'
sora
'
)
{
const
soraBaseUrl
=
apiKeyBaseUrl
.
value
.
trim
()
if
(
!
soraBaseUrl
)
{
appStore
.
showError
(
t
(
'
admin.accounts.soraBaseUrlRequired
'
))
return
}
if
(
!
soraBaseUrl
.
startsWith
(
'
http://
'
)
&&
!
soraBaseUrl
.
startsWith
(
'
https://
'
))
{
appStore
.
showError
(
t
(
'
admin.accounts.soraBaseUrlInvalidScheme
'
))
return
}
}
// Determine default base URL based on platform
const
defaultBaseUrl
=
form
.
platform
===
'
openai
'
...
...
@@ -4134,15 +4003,14 @@ const goBackToBasicInfo = () => {
step
.
value
=
1
oauth
.
resetState
()
openaiOAuth
.
resetState
()
soraOAuth
.
resetState
()
geminiOAuth
.
resetState
()
antigravityOAuth
.
resetState
()
oauthFlowRef
.
value
?.
reset
()
}
const
handleGenerateUrl
=
async
()
=>
{
if
(
form
.
platform
===
'
openai
'
||
form
.
platform
===
'
sora
'
)
{
await
activeO
pen
AI
OAuth
.
value
.
generateAuthUrl
(
form
.
proxy_id
)
if
(
form
.
platform
===
'
openai
'
)
{
await
o
pen
ai
OAuth
.
generateAuthUrl
(
form
.
proxy_id
)
}
else
if
(
form
.
platform
===
'
gemini
'
)
{
await
geminiOAuth
.
generateAuthUrl
(
form
.
proxy_id
,
...
...
@@ -4158,95 +4026,15 @@ const handleGenerateUrl = async () => {
}
const
handleValidateRefreshToken
=
(
rt
:
string
)
=>
{
if
(
form
.
platform
===
'
openai
'
||
form
.
platform
===
'
sora
'
)
{
if
(
form
.
platform
===
'
openai
'
)
{
handleOpenAIValidateRT
(
rt
)
}
else
if
(
form
.
platform
===
'
antigravity
'
)
{
handleAntigravityValidateRT
(
rt
)
}
}
const
handleValidateSessionToken
=
(
sessionToken
:
string
)
=>
{
if
(
form
.
platform
===
'
sora
'
)
{
handleSoraValidateST
(
sessionToken
)
}
}
// Sora 手动 AT 批量导入
const
handleImportAccessToken
=
async
(
accessTokenInput
:
string
)
=>
{
const
oauthClient
=
activeOpenAIOAuth
.
value
if
(
!
accessTokenInput
.
trim
())
return
const
accessTokens
=
accessTokenInput
.
split
(
'
\n
'
)
.
map
((
at
)
=>
at
.
trim
())
.
filter
((
at
)
=>
at
)
if
(
accessTokens
.
length
===
0
)
{
oauthClient
.
error
.
value
=
'
Please enter at least one Access Token
'
return
}
oauthClient
.
loading
.
value
=
true
oauthClient
.
error
.
value
=
''
let
successCount
=
0
let
failedCount
=
0
const
errors
:
string
[]
=
[]
try
{
for
(
let
i
=
0
;
i
<
accessTokens
.
length
;
i
++
)
{
try
{
const
credentials
:
Record
<
string
,
unknown
>
=
{
access_token
:
accessTokens
[
i
],
}
const
soraExtra
=
buildSoraExtra
()
const
accountName
=
accessTokens
.
length
>
1
?
`${form.name
}
#${i + 1
}
`
:
form
.
name
await
adminAPI
.
accounts
.
create
({
name
:
accountName
,
notes
:
form
.
notes
,
platform
:
'
sora
'
,
type
:
'
oauth
'
,
credentials
,
extra
:
soraExtra
,
proxy_id
:
form
.
proxy_id
,
concurrency
:
form
.
concurrency
,
load_factor
:
form
.
load_factor
??
undefined
,
priority
:
form
.
priority
,
rate_multiplier
:
form
.
rate_multiplier
,
group_ids
:
form
.
group_ids
,
expires_at
:
form
.
expires_at
,
auto_pause_on_expired
:
autoPauseOnExpired
.
value
}
)
successCount
++
}
catch
(
error
:
any
)
{
failedCount
++
const
errMsg
=
error
.
response
?.
data
?.
detail
||
error
.
message
||
'
Unknown error
'
errors
.
push
(
`#${i + 1
}
: ${errMsg
}
`
)
}
}
if
(
successCount
>
0
&&
failedCount
===
0
)
{
appStore
.
showSuccess
(
accessTokens
.
length
>
1
?
t
(
'
admin.accounts.oauth.batchSuccess
'
,
{
count
:
successCount
}
)
:
t
(
'
admin.accounts.accountCreated
'
)
)
emit
(
'
created
'
)
handleClose
()
}
else
if
(
successCount
>
0
&&
failedCount
>
0
)
{
appStore
.
showWarning
(
t
(
'
admin.accounts.oauth.batchPartialSuccess
'
,
{
success
:
successCount
,
failed
:
failedCount
}
)
)
oauthClient
.
error
.
value
=
errors
.
join
(
'
\n
'
)
emit
(
'
created
'
)
}
else
{
oauthClient
.
error
.
value
=
errors
.
join
(
'
\n
'
)
appStore
.
showError
(
t
(
'
admin.accounts.oauth.batchFailed
'
))
}
}
finally
{
oauthClient
.
loading
.
value
=
false
}
const
handleValidateSessionToken
=
(
_sessionToken
:
string
)
=>
{
// Session token validation removed
}
const
formatDateTimeLocal
=
formatDateTimeLocalInput
...
...
@@ -4312,7 +4100,7 @@ const createAccountAndFinish = async (
// OpenAI OAuth 授权码兑换
const
handleOpenAIExchange
=
async
(
authCode
:
string
)
=>
{
const
oauthClient
=
activeO
pen
AI
OAuth
.
value
const
oauthClient
=
o
pen
ai
OAuth
if
(
!
authCode
.
trim
()
||
!
oauthClient
.
sessionId
.
value
)
return
oauthClient
.
loading
.
value
=
true
...
...
@@ -4338,7 +4126,6 @@ const handleOpenAIExchange = async (authCode: string) => {
const
oauthExtra
=
oauthClient
.
buildExtraInfo
(
tokenInfo
)
as
Record
<
string
,
unknown
>
|
undefined
const
extra
=
buildOpenAIExtra
(
oauthExtra
)
const
shouldCreateOpenAI
=
form
.
platform
===
'
openai
'
const
shouldCreateSora
=
form
.
platform
===
'
sora
'
// Add model mapping for OpenAI OAuth accounts(透传模式下不应用)
if
(
shouldCreateOpenAI
&&
!
isOpenAIModelRestrictionDisabled
.
value
)
{
...
...
@@ -4353,10 +4140,8 @@ const handleOpenAIExchange = async (authCode: string) => {
return
}
let
openaiAccountId
:
string
|
number
|
undefined
if
(
shouldCreateOpenAI
)
{
const
openaiAccount
=
await
adminAPI
.
accounts
.
create
({
await
adminAPI
.
accounts
.
create
({
name
:
form
.
name
,
notes
:
form
.
notes
,
platform
:
'
openai
'
,
...
...
@@ -4372,36 +4157,6 @@ const handleOpenAIExchange = async (authCode: string) => {
expires_at
:
form
.
expires_at
,
auto_pause_on_expired
:
autoPauseOnExpired
.
value
}
)
openaiAccountId
=
openaiAccount
.
id
appStore
.
showSuccess
(
t
(
'
admin.accounts.accountCreated
'
))
}
if
(
shouldCreateSora
)
{
const
soraCredentials
=
{
access_token
:
credentials
.
access_token
,
refresh_token
:
credentials
.
refresh_token
,
client_id
:
credentials
.
client_id
,
expires_at
:
credentials
.
expires_at
}
const
soraName
=
shouldCreateOpenAI
?
`${form.name
}
(Sora)`
:
form
.
name
const
soraExtra
=
buildSoraExtra
(
shouldCreateOpenAI
?
extra
:
oauthExtra
,
openaiAccountId
)
await
adminAPI
.
accounts
.
create
({
name
:
soraName
,
notes
:
form
.
notes
,
platform
:
'
sora
'
,
type
:
'
oauth
'
,
credentials
:
soraCredentials
,
extra
:
soraExtra
,
proxy_id
:
form
.
proxy_id
,
concurrency
:
form
.
concurrency
,
load_factor
:
form
.
load_factor
??
undefined
,
priority
:
form
.
priority
,
rate_multiplier
:
form
.
rate_multiplier
,
group_ids
:
form
.
group_ids
,
expires_at
:
form
.
expires_at
,
auto_pause_on_expired
:
autoPauseOnExpired
.
value
}
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.accountCreated
'
))
}
...
...
@@ -4416,12 +4171,12 @@ const handleOpenAIExchange = async (authCode: string) => {
}
// OpenAI 手动 RT 批量验证和创建
// OpenAI Mobile RT
使用的
client_id
(与后端 openai.SoraClientID 一致)
// OpenAI Mobile RT client_id
const
OPENAI_MOBILE_RT_CLIENT_ID
=
'
app_LlGpXReQgckcGGUo2JrYvtJK
'
// OpenAI
/Sora
RT 批量验证和创建(共享逻辑)
// OpenAI RT 批量验证和创建(共享逻辑)
const
handleOpenAIBatchRT
=
async
(
refreshTokenInput
:
string
,
clientId
?:
string
)
=>
{
const
oauthClient
=
activeO
pen
AI
OAuth
.
value
const
oauthClient
=
o
pen
ai
OAuth
if
(
!
refreshTokenInput
.
trim
())
return
const
refreshTokens
=
refreshTokenInput
...
...
@@ -4441,7 +4196,6 @@ const handleOpenAIBatchRT = async (refreshTokenInput: string, clientId?: string)
let
failedCount
=
0
const
errors
:
string
[]
=
[]
const
shouldCreateOpenAI
=
form
.
platform
===
'
openai
'
const
shouldCreateSora
=
form
.
platform
===
'
sora
'
try
{
for
(
let
i
=
0
;
i
<
refreshTokens
.
length
;
i
++
)
{
...
...
@@ -4477,10 +4231,8 @@ const handleOpenAIBatchRT = async (refreshTokenInput: string, clientId?: string)
const
baseName
=
form
.
name
||
tokenInfo
.
email
||
'
OpenAI OAuth Account
'
const
accountName
=
refreshTokens
.
length
>
1
?
`${baseName
}
#${i + 1
}
`
:
baseName
let
openaiAccountId
:
string
|
number
|
undefined
if
(
shouldCreateOpenAI
)
{
const
openaiAccount
=
await
adminAPI
.
accounts
.
create
({
await
adminAPI
.
accounts
.
create
({
name
:
accountName
,
notes
:
form
.
notes
,
platform
:
'
openai
'
,
...
...
@@ -4496,34 +4248,6 @@ const handleOpenAIBatchRT = async (refreshTokenInput: string, clientId?: string)
expires_at
:
form
.
expires_at
,
auto_pause_on_expired
:
autoPauseOnExpired
.
value
}
)
openaiAccountId
=
openaiAccount
.
id
}
if
(
shouldCreateSora
)
{
const
soraCredentials
=
{
access_token
:
credentials
.
access_token
,
refresh_token
:
credentials
.
refresh_token
,
client_id
:
credentials
.
client_id
,
expires_at
:
credentials
.
expires_at
}
const
soraName
=
shouldCreateOpenAI
?
`${accountName
}
(Sora)`
:
accountName
const
soraExtra
=
buildSoraExtra
(
shouldCreateOpenAI
?
extra
:
oauthExtra
,
openaiAccountId
)
await
adminAPI
.
accounts
.
create
({
name
:
soraName
,
notes
:
form
.
notes
,
platform
:
'
sora
'
,
type
:
'
oauth
'
,
credentials
:
soraCredentials
,
extra
:
soraExtra
,
proxy_id
:
form
.
proxy_id
,
concurrency
:
form
.
concurrency
,
load_factor
:
form
.
load_factor
??
undefined
,
priority
:
form
.
priority
,
rate_multiplier
:
form
.
rate_multiplier
,
group_ids
:
form
.
group_ids
,
expires_at
:
form
.
expires_at
,
auto_pause_on_expired
:
autoPauseOnExpired
.
value
}
)
}
successCount
++
...
...
@@ -4561,95 +4285,9 @@ const handleOpenAIBatchRT = async (refreshTokenInput: string, clientId?: string)
// 手动输入 RT(Codex CLI client_id,默认)
const
handleOpenAIValidateRT
=
(
rt
:
string
)
=>
handleOpenAIBatchRT
(
rt
)
// 手动输入 Mobile RT
(SoraClientID)
// 手动输入 Mobile RT
const
handleOpenAIValidateMobileRT
=
(
rt
:
string
)
=>
handleOpenAIBatchRT
(
rt
,
OPENAI_MOBILE_RT_CLIENT_ID
)
// Sora 手动 ST 批量验证和创建
const
handleSoraValidateST
=
async
(
sessionTokenInput
:
string
)
=>
{
const
oauthClient
=
activeOpenAIOAuth
.
value
if
(
!
sessionTokenInput
.
trim
())
return
const
sessionTokens
=
sessionTokenInput
.
split
(
'
\n
'
)
.
map
((
st
)
=>
st
.
trim
())
.
filter
((
st
)
=>
st
)
if
(
sessionTokens
.
length
===
0
)
{
oauthClient
.
error
.
value
=
t
(
'
admin.accounts.oauth.openai.pleaseEnterSessionToken
'
)
return
}
oauthClient
.
loading
.
value
=
true
oauthClient
.
error
.
value
=
''
let
successCount
=
0
let
failedCount
=
0
const
errors
:
string
[]
=
[]
try
{
for
(
let
i
=
0
;
i
<
sessionTokens
.
length
;
i
++
)
{
try
{
const
tokenInfo
=
await
oauthClient
.
validateSessionToken
(
sessionTokens
[
i
],
form
.
proxy_id
)
if
(
!
tokenInfo
)
{
failedCount
++
errors
.
push
(
`#${i + 1
}
: ${oauthClient.error.value || 'Validation failed'
}
`
)
oauthClient
.
error
.
value
=
''
continue
}
const
credentials
=
oauthClient
.
buildCredentials
(
tokenInfo
)
credentials
.
session_token
=
sessionTokens
[
i
]
const
oauthExtra
=
oauthClient
.
buildExtraInfo
(
tokenInfo
)
as
Record
<
string
,
unknown
>
|
undefined
const
soraExtra
=
buildSoraExtra
(
oauthExtra
)
const
accountName
=
sessionTokens
.
length
>
1
?
`${form.name
}
#${i + 1
}
`
:
form
.
name
await
adminAPI
.
accounts
.
create
({
name
:
accountName
,
notes
:
form
.
notes
,
platform
:
'
sora
'
,
type
:
'
oauth
'
,
credentials
,
extra
:
soraExtra
,
proxy_id
:
form
.
proxy_id
,
concurrency
:
form
.
concurrency
,
load_factor
:
form
.
load_factor
??
undefined
,
priority
:
form
.
priority
,
rate_multiplier
:
form
.
rate_multiplier
,
group_ids
:
form
.
group_ids
,
expires_at
:
form
.
expires_at
,
auto_pause_on_expired
:
autoPauseOnExpired
.
value
}
)
successCount
++
}
catch
(
error
:
any
)
{
failedCount
++
const
errMsg
=
error
.
response
?.
data
?.
detail
||
error
.
message
||
'
Unknown error
'
errors
.
push
(
`#${i + 1
}
: ${errMsg
}
`
)
}
}
if
(
successCount
>
0
&&
failedCount
===
0
)
{
appStore
.
showSuccess
(
sessionTokens
.
length
>
1
?
t
(
'
admin.accounts.oauth.batchSuccess
'
,
{
count
:
successCount
}
)
:
t
(
'
admin.accounts.accountCreated
'
)
)
emit
(
'
created
'
)
handleClose
()
}
else
if
(
successCount
>
0
&&
failedCount
>
0
)
{
appStore
.
showWarning
(
t
(
'
admin.accounts.oauth.batchPartialSuccess
'
,
{
success
:
successCount
,
failed
:
failedCount
}
)
)
oauthClient
.
error
.
value
=
errors
.
join
(
'
\n
'
)
emit
(
'
created
'
)
}
else
{
oauthClient
.
error
.
value
=
errors
.
join
(
'
\n
'
)
appStore
.
showError
(
t
(
'
admin.accounts.oauth.batchFailed
'
))
}
}
finally
{
oauthClient
.
loading
.
value
=
false
}
}
// Antigravity 手动 RT 批量验证和创建
const
handleAntigravityValidateRT
=
async
(
refreshTokenInput
:
string
)
=>
{
if
(
!
refreshTokenInput
.
trim
())
return
...
...
@@ -4918,7 +4556,6 @@ const handleExchangeCode = async () => {
switch
(
form
.
platform
)
{
case
'
openai
'
:
case
'
sora
'
:
return
handleOpenAIExchange
(
authCode
)
case
'
gemini
'
:
return
handleGeminiExchange
(
authCode
)
...
...
frontend/src/components/account/EditAccountModal.vue
View file @
62e80c60
...
...
@@ -35,7 +35,7 @@
type=
"text"
class=
"input"
:placeholder=
"
account.platform === 'openai'
|| account.platform === 'sora'
account.platform === 'openai'
? 'https://api.openai.com'
: account.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
...
...
@@ -53,7 +53,7 @@
type=
"password"
class=
"input font-mono"
:placeholder=
"
account.platform === 'openai'
|| account.platform === 'sora'
account.platform === 'openai'
? 'sk-proj-...'
: account.platform === 'gemini'
? 'AIza...'
...
...
@@ -1969,7 +1969,7 @@ const tempUnschedPresets = computed(() => [
// Computed: default base URL based on platform
const
defaultBaseUrl
=
computed
(()
=>
{
if
(
props
.
account
?.
platform
===
'
openai
'
||
props
.
account
?.
platform
===
'
sora
'
)
return
'
https://api.openai.com
'
if
(
props
.
account
?.
platform
===
'
openai
'
)
return
'
https://api.openai.com
'
if
(
props
.
account
?.
platform
===
'
gemini
'
)
return
'
https://generativelanguage.googleapis.com
'
return
'
https://api.anthropic.com
'
}
)
...
...
@@ -2157,7 +2157,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
if
(
newAccount
.
type
===
'
apikey
'
&&
newAccount
.
credentials
)
{
const
credentials
=
newAccount
.
credentials
as
Record
<
string
,
unknown
>
const
platformDefaultUrl
=
newAccount
.
platform
===
'
openai
'
||
newAccount
.
platform
===
'
sora
'
newAccount
.
platform
===
'
openai
'
?
'
https://api.openai.com
'
:
newAccount
.
platform
===
'
gemini
'
?
'
https://generativelanguage.googleapis.com
'
...
...
@@ -2253,7 +2253,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editBaseUrl
.
value
=
(
credentials
.
base_url
as
string
)
||
''
}
else
{
const
platformDefaultUrl
=
newAccount
.
platform
===
'
openai
'
||
newAccount
.
platform
===
'
sora
'
newAccount
.
platform
===
'
openai
'
?
'
https://api.openai.com
'
:
newAccount
.
platform
===
'
gemini
'
?
'
https://generativelanguage.googleapis.com
'
...
...
frontend/src/components/account/OAuthAuthorizationFlow.vue
View file @
62e80c60
...
...
@@ -168,217 +168,6 @@
<
/div
>
<
/div
>
<!--
Session
Token
Input
(
Sora
)
-->
<
div
v
-
if
=
"
inputMethod === 'session_token'
"
class
=
"
space-y-4
"
>
<
div
class
=
"
rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80
"
>
<
p
class
=
"
mb-3 text-sm text-blue-700 dark:text-blue-300
"
>
{{
t
(
getOAuthKey
(
'
sessionTokenDesc
'
))
}}
<
/p
>
<
div
class
=
"
mb-4
"
>
<
label
class
=
"
mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300
"
>
<
Icon
name
=
"
key
"
size
=
"
sm
"
class
=
"
text-blue-500
"
/>
{{
t
(
getOAuthKey
(
'
sessionTokenRawLabel
'
))
}}
<
span
v
-
if
=
"
parsedSessionTokenCount > 1
"
class
=
"
rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white
"
>
{{
t
(
'
admin.accounts.oauth.keysCount
'
,
{
count
:
parsedSessionTokenCount
}
)
}}
<
/span
>
<
/label
>
<
textarea
v
-
model
=
"
sessionTokenInput
"
rows
=
"
3
"
class
=
"
input w-full resize-y font-mono text-sm
"
:
placeholder
=
"
t(getOAuthKey('sessionTokenRawPlaceholder'))
"
><
/textarea
>
<
p
class
=
"
mt-1 text-xs text-blue-600 dark:text-blue-400
"
>
{{
t
(
getOAuthKey
(
'
sessionTokenRawHint
'
))
}}
<
/p
>
<
div
class
=
"
mt-2 flex flex-wrap items-center gap-2
"
>
<
button
type
=
"
button
"
class
=
"
btn btn-secondary px-2 py-1 text-xs
"
@
click
=
"
handleOpenSoraSessionUrl
"
>
{{
t
(
getOAuthKey
(
'
openSessionUrl
'
))
}}
<
/button
>
<
button
type
=
"
button
"
class
=
"
btn btn-secondary px-2 py-1 text-xs
"
@
click
=
"
handleCopySoraSessionUrl
"
>
{{
t
(
getOAuthKey
(
'
copySessionUrl
'
))
}}
<
/button
>
<
/div
>
<
p
class
=
"
mt-1 break-all text-xs text-blue-600 dark:text-blue-400
"
>
{{
soraSessionUrl
}}
<
/p
>
<
p
class
=
"
mt-1 text-xs text-amber-600 dark:text-amber-400
"
>
{{
t
(
getOAuthKey
(
'
sessionUrlHint
'
))
}}
<
/p
>
<
p
v
-
if
=
"
parsedSessionTokenCount > 1
"
class
=
"
mt-1 text-xs text-blue-600 dark:text-blue-400
"
>
{{
t
(
'
admin.accounts.oauth.batchCreateAccounts
'
,
{
count
:
parsedSessionTokenCount
}
)
}}
<
/p
>
<
/div
>
<
div
v
-
if
=
"
sessionTokenInput.trim()
"
class
=
"
mb-4 space-y-3
"
>
<
div
>
<
label
class
=
"
mb-2 flex items-center gap-2 text-xs font-semibold text-gray-700 dark:text-gray-300
"
>
{{
t
(
getOAuthKey
(
'
parsedSessionTokensLabel
'
))
}}
<
span
v
-
if
=
"
parsedSessionTokenCount > 0
"
class
=
"
rounded-full bg-emerald-500 px-2 py-0.5 text-[10px] text-white
"
>
{{
parsedSessionTokenCount
}}
<
/span
>
<
/label
>
<
textarea
:
value
=
"
parsedSessionTokensText
"
rows
=
"
2
"
readonly
class
=
"
input w-full resize-y bg-gray-50 font-mono text-xs dark:bg-gray-700
"
><
/textarea
>
<
p
v
-
if
=
"
parsedSessionTokenCount === 0
"
class
=
"
mt-1 text-xs text-amber-600 dark:text-amber-400
"
>
{{
t
(
getOAuthKey
(
'
parsedSessionTokensEmpty
'
))
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 flex items-center gap-2 text-xs font-semibold text-gray-700 dark:text-gray-300
"
>
{{
t
(
getOAuthKey
(
'
parsedAccessTokensLabel
'
))
}}
<
span
v
-
if
=
"
parsedAccessTokenFromSessionInputCount > 0
"
class
=
"
rounded-full bg-emerald-500 px-2 py-0.5 text-[10px] text-white
"
>
{{
parsedAccessTokenFromSessionInputCount
}}
<
/span
>
<
/label
>
<
textarea
:
value
=
"
parsedAccessTokensText
"
rows
=
"
2
"
readonly
class
=
"
input w-full resize-y bg-gray-50 font-mono text-xs dark:bg-gray-700
"
><
/textarea
>
<
/div
>
<
/div
>
<
div
v
-
if
=
"
error
"
class
=
"
mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30
"
>
<
p
class
=
"
whitespace-pre-line text-sm text-red-600 dark:text-red-400
"
>
{{
error
}}
<
/p
>
<
/div
>
<
button
type
=
"
button
"
class
=
"
btn btn-primary w-full
"
:
disabled
=
"
loading || parsedSessionTokenCount === 0
"
@
click
=
"
handleValidateSessionToken
"
>
<
svg
v
-
if
=
"
loading
"
class
=
"
-ml-1 mr-2 h-4 w-4 animate-spin
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
>
<
circle
class
=
"
opacity-25
"
cx
=
"
12
"
cy
=
"
12
"
r
=
"
10
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
4
"
><
/circle
>
<
path
class
=
"
opacity-75
"
fill
=
"
currentColor
"
d
=
"
M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z
"
><
/path
>
<
/svg
>
<
Icon
v
-
else
name
=
"
sparkles
"
size
=
"
sm
"
class
=
"
mr-2
"
/>
{{
loading
?
t
(
getOAuthKey
(
'
validating
'
))
:
t
(
getOAuthKey
(
'
validateAndCreate
'
))
}}
<
/button
>
<
/div
>
<
/div
>
<!--
Access
Token
Input
(
Sora
)
-->
<
div
v
-
if
=
"
inputMethod === 'access_token'
"
class
=
"
space-y-4
"
>
<
div
class
=
"
rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80
"
>
<
p
class
=
"
mb-3 text-sm text-blue-700 dark:text-blue-300
"
>
{{
t
(
'
admin.accounts.oauth.openai.accessTokenDesc
'
,
'
直接粘贴 Access Token 创建账号,无需 OAuth 授权流程。支持批量导入(每行一个)。
'
)
}}
<
/p
>
<
div
class
=
"
mb-4
"
>
<
label
class
=
"
mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300
"
>
<
Icon
name
=
"
key
"
size
=
"
sm
"
class
=
"
text-blue-500
"
/>
Access
Token
<
span
v
-
if
=
"
parsedAccessTokenCount > 1
"
class
=
"
rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white
"
>
{{
t
(
'
admin.accounts.oauth.keysCount
'
,
{
count
:
parsedAccessTokenCount
}
)
}}
<
/span
>
<
/label
>
<
textarea
v
-
model
=
"
accessTokenInput
"
rows
=
"
3
"
class
=
"
input w-full resize-y font-mono text-sm
"
:
placeholder
=
"
t('admin.accounts.oauth.openai.accessTokenPlaceholder', '粘贴 Access Token,每行一个')
"
><
/textarea
>
<
p
v
-
if
=
"
parsedAccessTokenCount > 1
"
class
=
"
mt-1 text-xs text-blue-600 dark:text-blue-400
"
>
{{
t
(
'
admin.accounts.oauth.batchCreateAccounts
'
,
{
count
:
parsedAccessTokenCount
}
)
}}
<
/p
>
<
/div
>
<
div
v
-
if
=
"
error
"
class
=
"
mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30
"
>
<
p
class
=
"
whitespace-pre-line text-sm text-red-600 dark:text-red-400
"
>
{{
error
}}
<
/p
>
<
/div
>
<
button
type
=
"
button
"
class
=
"
btn btn-primary w-full
"
:
disabled
=
"
loading || !accessTokenInput.trim()
"
@
click
=
"
handleImportAccessToken
"
>
<
Icon
name
=
"
sparkles
"
size
=
"
sm
"
class
=
"
mr-2
"
/>
{{
t
(
'
admin.accounts.oauth.openai.importAccessToken
'
,
'
导入 Access Token
'
)
}}
<
/button
>
<
/div
>
<
/div
>
<!--
Cookie
Auto
-
Auth
Form
-->
<
div
v
-
if
=
"
inputMethod === 'cookie'
"
class
=
"
space-y-4
"
>
<
div
...
...
@@ -753,7 +542,7 @@
import
{
ref
,
computed
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
{
parseSoraRawTokens
}
from
'
@/utils/soraTokenParser
'
import
{
useAppStore
}
from
'
@/stores
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
type
{
AddMethod
,
AuthInputMethod
}
from
'
@/composables/useAccountOAuth
'
import
type
{
AccountPlatform
}
from
'
@/types
'
...
...
@@ -771,8 +560,8 @@ interface Props {
showCookieOption
?:
boolean
// Whether to show cookie auto-auth option
showRefreshTokenOption
?:
boolean
// Whether to show refresh token input option (OpenAI only)
showMobileRefreshTokenOption
?:
boolean
// Whether to show mobile refresh token option (OpenAI only)
showSessionTokenOption
?:
boolean
// Whether to show session token input option (Sora only)
showAccessTokenOption
?:
boolean
// Whether to show access token input option (Sora only)
showSessionTokenOption
?:
boolean
showAccessTokenOption
?:
boolean
platform
?:
AccountPlatform
// Platform type for different UI/text
showProjectId
?:
boolean
// New prop to control project ID visibility
}
...
...
@@ -808,11 +597,11 @@ const emit = defineEmits<{
const
{
t
}
=
useI18n
()
const
isOpenAI
=
computed
(()
=>
props
.
platform
===
'
openai
'
||
props
.
platform
===
'
sora
'
)
const
isOpenAI
=
computed
(()
=>
props
.
platform
===
'
openai
'
)
// Get translation key based on platform
const
getOAuthKey
=
(
key
:
string
)
=>
{
if
(
props
.
platform
===
'
openai
'
||
props
.
platform
===
'
sora
'
)
return
`admin.accounts.oauth.openai.${key
}
`
if
(
props
.
platform
===
'
openai
'
)
return
`admin.accounts.oauth.openai.${key
}
`
if
(
props
.
platform
===
'
gemini
'
)
return
`admin.accounts.oauth.gemini.${key
}
`
if
(
props
.
platform
===
'
antigravity
'
)
return
`admin.accounts.oauth.antigravity.${key
}
`
return
`admin.accounts.oauth.${key
}
`
...
...
@@ -831,7 +620,7 @@ const oauthAuthCode = computed(() => t(getOAuthKey('authCode')))
const
oauthAuthCodePlaceholder
=
computed
(()
=>
t
(
getOAuthKey
(
'
authCodePlaceholder
'
)))
const
oauthAuthCodeHint
=
computed
(()
=>
t
(
getOAuthKey
(
'
authCodeHint
'
)))
const
oauthImportantNotice
=
computed
(()
=>
{
if
(
props
.
platform
===
'
openai
'
||
props
.
platform
===
'
sora
'
)
return
t
(
'
admin.accounts.oauth.openai.importantNotice
'
)
if
(
props
.
platform
===
'
openai
'
)
return
t
(
'
admin.accounts.oauth.openai.importantNotice
'
)
if
(
props
.
platform
===
'
antigravity
'
)
return
t
(
'
admin.accounts.oauth.antigravity.importantNotice
'
)
return
''
}
)
...
...
@@ -869,25 +658,13 @@ const parsedRefreshTokenCount = computed(() => {
.
filter
((
rt
)
=>
rt
).
length
}
)
const
parsedSoraRawTokens
=
computed
(()
=>
parseSoraRawTokens
(
sessionTokenInput
.
value
))
const
parsedSessionTokenCount
=
computed
(()
=>
{
return
parsedSoraRawTokens
.
value
.
sessionTokens
.
length
}
)
const
parsedSessionTokensText
=
computed
(()
=>
{
return
parsedSoraRawTokens
.
value
.
sessionTokens
.
join
(
'
\n
'
)
}
)
const
parsedSessionTokenCount
=
computed
(()
=>
0
)
const
parsedAccessTokenFromSessionInputCount
=
computed
(()
=>
{
return
parsedSoraRawTokens
.
value
.
accessTokens
.
length
}
)
const
parsedSessionTokensText
=
computed
(()
=>
''
)
const
parsedAccessTokensText
=
computed
(()
=>
{
return
parsedSoraRawTokens
.
value
.
accessTokens
.
join
(
'
\n
'
)
}
)
const
parsedAccessTokenFromSessionInputCount
=
computed
(()
=>
0
)
const
soraSessionUrl
=
'
https://sora.chatgpt.com/api/auth/session
'
const
parsedAccessTokensText
=
computed
(()
=>
''
)
const
parsedAccessTokenCount
=
computed
(()
=>
{
return
accessTokenInput
.
value
...
...
@@ -904,7 +681,7 @@ watch(inputMethod, (newVal) => {
// Auto-extract code from callback URL (OpenAI/Gemini/Antigravity)
// e.g., http://localhost:8085/callback?code=xxx...&state=...
watch
(
authCodeInput
,
(
newVal
)
=>
{
if
(
props
.
platform
!==
'
openai
'
&&
props
.
platform
!==
'
gemini
'
&&
props
.
platform
!==
'
antigravity
'
&&
props
.
platform
!==
'
sora
'
)
return
if
(
props
.
platform
!==
'
openai
'
&&
props
.
platform
!==
'
gemini
'
&&
props
.
platform
!==
'
antigravity
'
)
return
const
trimmed
=
newVal
.
trim
()
// Check if it looks like a URL with code parameter
...
...
@@ -914,7 +691,7 @@ watch(authCodeInput, (newVal) => {
const
url
=
new
URL
(
trimmed
)
const
code
=
url
.
searchParams
.
get
(
'
code
'
)
const
stateParam
=
url
.
searchParams
.
get
(
'
state
'
)
if
((
props
.
platform
===
'
openai
'
||
props
.
platform
===
'
sora
'
||
props
.
platform
===
'
gemini
'
||
props
.
platform
===
'
antigravity
'
)
&&
stateParam
)
{
if
((
props
.
platform
===
'
openai
'
||
props
.
platform
===
'
gemini
'
||
props
.
platform
===
'
antigravity
'
)
&&
stateParam
)
{
oauthState
.
value
=
stateParam
}
if
(
code
&&
code
!==
trimmed
)
{
...
...
@@ -925,7 +702,7 @@ watch(authCodeInput, (newVal) => {
// If URL parsing fails, try regex extraction
const
match
=
trimmed
.
match
(
/
[
?&
]
code=
([^
&
]
+
)
/
)
const
stateMatch
=
trimmed
.
match
(
/
[
?&
]
state=
([^
&
]
+
)
/
)
if
((
props
.
platform
===
'
openai
'
||
props
.
platform
===
'
sora
'
||
props
.
platform
===
'
gemini
'
||
props
.
platform
===
'
antigravity
'
)
&&
stateMatch
&&
stateMatch
[
1
])
{
if
((
props
.
platform
===
'
openai
'
||
props
.
platform
===
'
gemini
'
||
props
.
platform
===
'
antigravity
'
)
&&
stateMatch
&&
stateMatch
[
1
])
{
oauthState
.
value
=
stateMatch
[
1
]
}
if
(
match
&&
match
[
1
]
&&
match
[
1
]
!==
trimmed
)
{
...
...
@@ -973,14 +750,6 @@ const handleValidateSessionToken = () => {
}
}
const
handleOpenSoraSessionUrl
=
()
=>
{
window
.
open
(
soraSessionUrl
,
'
_blank
'
,
'
noopener,noreferrer
'
)
}
const
handleCopySoraSessionUrl
=
()
=>
{
copyToClipboard
(
soraSessionUrl
,
'
URL copied to clipboard
'
)
}
const
handleImportAccessToken
=
()
=>
{
if
(
accessTokenInput
.
value
.
trim
())
{
emit
(
'
import-access-token
'
,
accessTokenInput
.
value
.
trim
())
...
...
frontend/src/components/account/ReAuthAccountModal.vue
View file @
62e80c60
...
...
@@ -33,8 +33,6 @@
{{
isOpenAI
?
t
(
'
admin.accounts.openaiAccount
'
)
:
isSora
?
t
(
'
admin.accounts.soraAccount
'
)
:
isGemini
?
t
(
'
admin.accounts.geminiAccount
'
)
:
isAntigravity
...
...
@@ -130,7 +128,7 @@
:show-cookie-option=
"isAnthropic"
:allow-multiple=
"false"
:method-label=
"t('admin.accounts.inputMethod')"
:platform=
"isOpenAI ? 'openai' :
isSora ? 'sora' :
isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
:platform=
"isOpenAI ? 'openai' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
:show-project-id=
"isGemini && geminiOAuthType === 'code_assist'"
@
generate-url=
"handleGenerateUrl"
@
cookie-auth=
"handleCookieAuth"
...
...
@@ -227,7 +225,6 @@ const { t } = useI18n()
// OAuth composables
const
claudeOAuth
=
useAccountOAuth
()
const
openaiOAuth
=
useOpenAIOAuth
({
platform
:
'
openai
'
})
const
soraOAuth
=
useOpenAIOAuth
({
platform
:
'
sora
'
})
const
geminiOAuth
=
useGeminiOAuth
()
const
antigravityOAuth
=
useAntigravityOAuth
()
...
...
@@ -240,34 +237,32 @@ const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_as
// Computed - check platform
const
isOpenAI
=
computed
(()
=>
props
.
account
?.
platform
===
'
openai
'
)
const
isSora
=
computed
(()
=>
props
.
account
?.
platform
===
'
sora
'
)
const
isOpenAILike
=
computed
(()
=>
isOpenAI
.
value
||
isSora
.
value
)
const
isOpenAILike
=
computed
(()
=>
isOpenAI
.
value
)
const
isGemini
=
computed
(()
=>
props
.
account
?.
platform
===
'
gemini
'
)
const
isAnthropic
=
computed
(()
=>
props
.
account
?.
platform
===
'
anthropic
'
)
const
isAntigravity
=
computed
(()
=>
props
.
account
?.
platform
===
'
antigravity
'
)
const
activeOpenAIOAuth
=
computed
(()
=>
(
isSora
.
value
?
soraOAuth
:
openaiOAuth
))
// Computed - current OAuth state based on platform
const
currentAuthUrl
=
computed
(()
=>
{
if
(
isOpenAILike
.
value
)
return
activeO
pen
AI
OAuth
.
value
.
authUrl
.
value
if
(
isOpenAILike
.
value
)
return
o
pen
ai
OAuth
.
authUrl
.
value
if
(
isGemini
.
value
)
return
geminiOAuth
.
authUrl
.
value
if
(
isAntigravity
.
value
)
return
antigravityOAuth
.
authUrl
.
value
return
claudeOAuth
.
authUrl
.
value
})
const
currentSessionId
=
computed
(()
=>
{
if
(
isOpenAILike
.
value
)
return
activeO
pen
AI
OAuth
.
value
.
sessionId
.
value
if
(
isOpenAILike
.
value
)
return
o
pen
ai
OAuth
.
sessionId
.
value
if
(
isGemini
.
value
)
return
geminiOAuth
.
sessionId
.
value
if
(
isAntigravity
.
value
)
return
antigravityOAuth
.
sessionId
.
value
return
claudeOAuth
.
sessionId
.
value
})
const
currentLoading
=
computed
(()
=>
{
if
(
isOpenAILike
.
value
)
return
activeO
pen
AI
OAuth
.
value
.
loading
.
value
if
(
isOpenAILike
.
value
)
return
o
pen
ai
OAuth
.
loading
.
value
if
(
isGemini
.
value
)
return
geminiOAuth
.
loading
.
value
if
(
isAntigravity
.
value
)
return
antigravityOAuth
.
loading
.
value
return
claudeOAuth
.
loading
.
value
})
const
currentError
=
computed
(()
=>
{
if
(
isOpenAILike
.
value
)
return
activeO
pen
AI
OAuth
.
value
.
error
.
value
if
(
isOpenAILike
.
value
)
return
o
pen
ai
OAuth
.
error
.
value
if
(
isGemini
.
value
)
return
geminiOAuth
.
error
.
value
if
(
isAntigravity
.
value
)
return
antigravityOAuth
.
error
.
value
return
claudeOAuth
.
error
.
value
...
...
@@ -275,7 +270,7 @@ const currentError = computed(() => {
// Computed
const
isManualInputMethod
=
computed
(()
=>
{
// OpenAI/
Sora/
Gemini/Antigravity always use manual input (no cookie auth option)
// OpenAI/Gemini/Antigravity always use manual input (no cookie auth option)
return
isOpenAILike
.
value
||
isGemini
.
value
||
isAntigravity
.
value
||
oauthFlowRef
.
value
?.
inputMethod
===
'
manual
'
})
...
...
@@ -319,7 +314,6 @@ const resetState = () => {
geminiOAuthType
.
value
=
'
code_assist
'
claudeOAuth
.
resetState
()
openaiOAuth
.
resetState
()
soraOAuth
.
resetState
()
geminiOAuth
.
resetState
()
antigravityOAuth
.
resetState
()
oauthFlowRef
.
value
?.
reset
()
...
...
@@ -333,7 +327,7 @@ const handleGenerateUrl = async () => {
if
(
!
props
.
account
)
return
if
(
isOpenAILike
.
value
)
{
await
activeO
pen
AI
OAuth
.
value
.
generateAuthUrl
(
props
.
account
.
proxy_id
)
await
o
pen
ai
OAuth
.
generateAuthUrl
(
props
.
account
.
proxy_id
)
}
else
if
(
isGemini
.
value
)
{
const
creds
=
(
props
.
account
.
credentials
||
{})
as
Record
<
string
,
unknown
>
const
tierId
=
typeof
creds
.
tier_id
===
'
string
'
?
creds
.
tier_id
:
undefined
...
...
@@ -354,7 +348,7 @@ const handleExchangeCode = async () => {
if
(
isOpenAILike
.
value
)
{
// OpenAI OAuth flow
const
oauthClient
=
activeO
pen
AI
OAuth
.
value
const
oauthClient
=
o
pen
ai
OAuth
const
sessionId
=
oauthClient
.
sessionId
.
value
if
(
!
sessionId
)
return
const
stateToUse
=
(
oauthFlowRef
.
value
?.
oauthState
||
oauthClient
.
oauthState
.
value
||
''
).
trim
()
...
...
frontend/src/components/admin/account/AccountTableFilters.vue
View file @
62e80c60
...
...
@@ -25,7 +25,7 @@ const updateType = (value: string | number | boolean | null) => { emit('update:f
const
updateStatus
=
(
value
:
string
|
number
|
boolean
|
null
)
=>
{
emit
(
'
update:filters
'
,
{
...
props
.
filters
,
status
:
value
})
}
const
updatePrivacyMode
=
(
value
:
string
|
number
|
boolean
|
null
)
=>
{
emit
(
'
update:filters
'
,
{
...
props
.
filters
,
privacy_mode
:
value
})
}
const
updateGroup
=
(
value
:
string
|
number
|
boolean
|
null
)
=>
{
emit
(
'
update:filters
'
,
{
...
props
.
filters
,
group
:
value
})
}
const
pOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allPlatforms
'
)
},
{
value
:
'
anthropic
'
,
label
:
'
Anthropic
'
},
{
value
:
'
openai
'
,
label
:
'
OpenAI
'
},
{
value
:
'
gemini
'
,
label
:
'
Gemini
'
},
{
value
:
'
antigravity
'
,
label
:
'
Antigravity
'
},
{
value
:
'
sora
'
,
label
:
'
Sora
'
}])
const
pOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allPlatforms
'
)
},
{
value
:
'
anthropic
'
,
label
:
'
Anthropic
'
},
{
value
:
'
openai
'
,
label
:
'
OpenAI
'
},
{
value
:
'
gemini
'
,
label
:
'
Gemini
'
},
{
value
:
'
antigravity
'
,
label
:
'
Antigravity
'
}])
const
tOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allTypes
'
)
},
{
value
:
'
oauth
'
,
label
:
t
(
'
admin.accounts.oauthType
'
)
},
{
value
:
'
setup-token
'
,
label
:
t
(
'
admin.accounts.setupToken
'
)
},
{
value
:
'
apikey
'
,
label
:
t
(
'
admin.accounts.apiKey
'
)
},
{
value
:
'
bedrock
'
,
label
:
'
AWS Bedrock
'
}])
const
sOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allStatus
'
)
},
{
value
:
'
active
'
,
label
:
t
(
'
admin.accounts.status.active
'
)
},
{
value
:
'
inactive
'
,
label
:
t
(
'
admin.accounts.status.inactive
'
)
},
{
value
:
'
error
'
,
label
:
t
(
'
admin.accounts.status.error
'
)
},
{
value
:
'
rate_limited
'
,
label
:
t
(
'
admin.accounts.status.rateLimited
'
)
},
{
value
:
'
temp_unschedulable
'
,
label
:
t
(
'
admin.accounts.status.tempUnschedulable
'
)
}])
const
privacyOpts
=
computed
(()
=>
[
...
...
frontend/src/components/admin/account/AccountTestModal.vue
View file @
62e80c60
...
...
@@ -41,7 +41,7 @@
</span>
</div>
<div
v-if=
"!isSoraAccount"
class=
"space-y-1.5"
>
<div
class=
"space-y-1.5"
>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.accounts.selectTestModel
'
)
}}
</label>
...
...
@@ -54,12 +54,6 @@
:placeholder=
"loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')"
/>
</div>
<div
v-else
class=
"rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-300"
>
{{
t
(
'
admin.accounts.soraTestHint
'
)
}}
</div>
<div
v-if=
"supportsGeminiImageTest"
class=
"space-y-1.5"
>
<TextArea
...
...
@@ -152,17 +146,15 @@
<div
class=
"flex items-center gap-3"
>
<span
class=
"flex items-center gap-1"
>
<Icon
name=
"grid"
size=
"sm"
:stroke-width=
"2"
/>
{{
isSoraAccount
?
t
(
'
admin.accounts.soraTestTarget
'
)
:
t
(
'
admin.accounts.testModel
'
)
}}
{{
t
(
'
admin.accounts.testModel
'
)
}}
</span>
</div>
<span
class=
"flex items-center gap-1"
>
<Icon
name=
"chat"
size=
"sm"
:stroke-width=
"2"
/>
{{
isSoraAccount
?
t
(
'
admin.accounts.soraTestMode
'
)
:
supportsGeminiImageTest
?
t
(
'
admin.accounts.geminiImageTestMode
'
)
:
t
(
'
admin.accounts.testPrompt
'
)
supportsGeminiImageTest
?
t
(
'
admin.accounts.geminiImageTestMode
'
)
:
t
(
'
admin.accounts.testPrompt
'
)
}}
</span>
</div>
...
...
@@ -179,10 +171,10 @@
</button>
<button
@
click=
"startTest"
:disabled=
"status === 'connecting' ||
(!isSoraAccount &&
!selectedModelId
)
"
:disabled=
"status === 'connecting' || !selectedModelId"
:class=
"[
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
status === 'connecting' ||
(!isSoraAccount &&
!selectedModelId
)
status === 'connecting' || !selectedModelId
? 'cursor-not-allowed bg-primary-400 text-white'
: status === 'success'
? 'bg-green-500 text-white hover:bg-green-600'
...
...
@@ -258,11 +250,9 @@ const selectedModelId = ref('')
const
testPrompt
=
ref
(
''
)
const
loadingModels
=
ref
(
false
)
let
eventSource
:
EventSource
|
null
=
null
const
isSoraAccount
=
computed
(()
=>
props
.
account
?.
platform
===
'
sora
'
)
const
generatedImages
=
ref
<
PreviewImage
[]
>
([])
const
prioritizedGeminiModels
=
[
'
gemini-3.1-flash-image
'
,
'
gemini-2.5-flash-image
'
,
'
gemini-2.5-flash
'
,
'
gemini-2.5-pro
'
,
'
gemini-3-flash-preview
'
,
'
gemini-3-pro-preview
'
,
'
gemini-2.0-flash
'
]
const
supportsGeminiImageTest
=
computed
(()
=>
{
if
(
isSoraAccount
.
value
)
return
false
const
modelID
=
selectedModelId
.
value
.
toLowerCase
()
if
(
!
modelID
.
startsWith
(
'
gemini-
'
)
||
!
modelID
.
includes
(
'
-image
'
))
return
false
...
...
@@ -302,12 +292,6 @@ watch(selectedModelId, () => {
const
loadAvailableModels
=
async
()
=>
{
if
(
!
props
.
account
)
return
if
(
props
.
account
.
platform
===
'
sora
'
)
{
availableModels
.
value
=
[]
selectedModelId
.
value
=
''
loadingModels
.
value
=
false
return
}
loadingModels
.
value
=
true
selectedModelId
.
value
=
''
// Reset selection before loading
...
...
@@ -373,7 +357,7 @@ const scrollToBottom = async () => {
}
const
startTest
=
async
()
=>
{
if
(
!
props
.
account
||
(
!
isSoraAccount
.
value
&&
!
selectedModelId
.
value
)
)
return
if
(
!
props
.
account
||
!
selectedModelId
.
value
)
return
resetState
()
status
.
value
=
'
connecting
'
...
...
@@ -394,14 +378,10 @@ const startTest = async () => {
Authorization
:
`Bearer
${
localStorage
.
getItem
(
'
auth_token
'
)}
`
,
'
Content-Type
'
:
'
application/json
'
},
body
:
JSON
.
stringify
(
isSoraAccount
.
value
?
{}
:
{
body
:
JSON
.
stringify
({
model_id
:
selectedModelId
.
value
,
prompt
:
supportsGeminiImageTest
.
value
?
testPrompt
.
value
.
trim
()
:
''
}
)
})
})
if
(
!
response
.
ok
)
{
...
...
@@ -461,9 +441,7 @@ const handleEvent = (event: {
addLine
(
t
(
'
admin.accounts.usingModel
'
,
{
model
:
event
.
model
}),
'
text-cyan-400
'
)
}
addLine
(
isSoraAccount
.
value
?
t
(
'
admin.accounts.soraTestingFlow
'
)
:
supportsGeminiImageTest
.
value
supportsGeminiImageTest
.
value
?
t
(
'
admin.accounts.sendingGeminiImageRequest
'
)
:
t
(
'
admin.accounts.sendingTestMessage
'
),
'
text-gray-400
'
...
...
frontend/src/components/admin/account/ReAuthAccountModal.vue
View file @
62e80c60
...
...
@@ -33,8 +33,6 @@
{{
isOpenAI
?
t
(
'
admin.accounts.openaiAccount
'
)
:
isSora
?
t
(
'
admin.accounts.soraAccount
'
)
:
isGemini
?
t
(
'
admin.accounts.geminiAccount
'
)
:
isAntigravity
...
...
@@ -130,7 +128,7 @@
:show-cookie-option=
"isAnthropic"
:allow-multiple=
"false"
:method-label=
"t('admin.accounts.inputMethod')"
:platform=
"isOpenAI ? 'openai' :
isSora ? 'sora' :
isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
:platform=
"isOpenAI ? 'openai' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
:show-project-id=
"isGemini && geminiOAuthType === 'code_assist'"
@
generate-url=
"handleGenerateUrl"
@
cookie-auth=
"handleCookieAuth"
...
...
@@ -227,7 +225,6 @@ const { t } = useI18n()
// OAuth composables
const
claudeOAuth
=
useAccountOAuth
()
const
openaiOAuth
=
useOpenAIOAuth
({
platform
:
'
openai
'
})
const
soraOAuth
=
useOpenAIOAuth
({
platform
:
'
sora
'
})
const
geminiOAuth
=
useGeminiOAuth
()
const
antigravityOAuth
=
useAntigravityOAuth
()
...
...
@@ -240,34 +237,32 @@ const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_as
// Computed - check platform
const
isOpenAI
=
computed
(()
=>
props
.
account
?.
platform
===
'
openai
'
)
const
isSora
=
computed
(()
=>
props
.
account
?.
platform
===
'
sora
'
)
const
isOpenAILike
=
computed
(()
=>
isOpenAI
.
value
||
isSora
.
value
)
const
isOpenAILike
=
computed
(()
=>
isOpenAI
.
value
)
const
isGemini
=
computed
(()
=>
props
.
account
?.
platform
===
'
gemini
'
)
const
isAnthropic
=
computed
(()
=>
props
.
account
?.
platform
===
'
anthropic
'
)
const
isAntigravity
=
computed
(()
=>
props
.
account
?.
platform
===
'
antigravity
'
)
const
activeOpenAIOAuth
=
computed
(()
=>
(
isSora
.
value
?
soraOAuth
:
openaiOAuth
))
// Computed - current OAuth state based on platform
const
currentAuthUrl
=
computed
(()
=>
{
if
(
isOpenAILike
.
value
)
return
activeO
pen
AI
OAuth
.
value
.
authUrl
.
value
if
(
isOpenAILike
.
value
)
return
o
pen
ai
OAuth
.
authUrl
.
value
if
(
isGemini
.
value
)
return
geminiOAuth
.
authUrl
.
value
if
(
isAntigravity
.
value
)
return
antigravityOAuth
.
authUrl
.
value
return
claudeOAuth
.
authUrl
.
value
})
const
currentSessionId
=
computed
(()
=>
{
if
(
isOpenAILike
.
value
)
return
activeO
pen
AI
OAuth
.
value
.
sessionId
.
value
if
(
isOpenAILike
.
value
)
return
o
pen
ai
OAuth
.
sessionId
.
value
if
(
isGemini
.
value
)
return
geminiOAuth
.
sessionId
.
value
if
(
isAntigravity
.
value
)
return
antigravityOAuth
.
sessionId
.
value
return
claudeOAuth
.
sessionId
.
value
})
const
currentLoading
=
computed
(()
=>
{
if
(
isOpenAILike
.
value
)
return
activeO
pen
AI
OAuth
.
value
.
loading
.
value
if
(
isOpenAILike
.
value
)
return
o
pen
ai
OAuth
.
loading
.
value
if
(
isGemini
.
value
)
return
geminiOAuth
.
loading
.
value
if
(
isAntigravity
.
value
)
return
antigravityOAuth
.
loading
.
value
return
claudeOAuth
.
loading
.
value
})
const
currentError
=
computed
(()
=>
{
if
(
isOpenAILike
.
value
)
return
activeO
pen
AI
OAuth
.
value
.
error
.
value
if
(
isOpenAILike
.
value
)
return
o
pen
ai
OAuth
.
error
.
value
if
(
isGemini
.
value
)
return
geminiOAuth
.
error
.
value
if
(
isAntigravity
.
value
)
return
antigravityOAuth
.
error
.
value
return
claudeOAuth
.
error
.
value
...
...
@@ -275,7 +270,7 @@ const currentError = computed(() => {
// Computed
const
isManualInputMethod
=
computed
(()
=>
{
// OpenAI/
Sora/
Gemini/Antigravity always use manual input (no cookie auth option)
// OpenAI/Gemini/Antigravity always use manual input (no cookie auth option)
return
isOpenAILike
.
value
||
isGemini
.
value
||
isAntigravity
.
value
||
oauthFlowRef
.
value
?.
inputMethod
===
'
manual
'
})
...
...
@@ -319,7 +314,6 @@ const resetState = () => {
geminiOAuthType
.
value
=
'
code_assist
'
claudeOAuth
.
resetState
()
openaiOAuth
.
resetState
()
soraOAuth
.
resetState
()
geminiOAuth
.
resetState
()
antigravityOAuth
.
resetState
()
oauthFlowRef
.
value
?.
reset
()
...
...
@@ -333,7 +327,7 @@ const handleGenerateUrl = async () => {
if
(
!
props
.
account
)
return
if
(
isOpenAILike
.
value
)
{
await
activeO
pen
AI
OAuth
.
value
.
generateAuthUrl
(
props
.
account
.
proxy_id
)
await
o
pen
ai
OAuth
.
generateAuthUrl
(
props
.
account
.
proxy_id
)
}
else
if
(
isGemini
.
value
)
{
const
creds
=
(
props
.
account
.
credentials
||
{})
as
Record
<
string
,
unknown
>
const
tierId
=
typeof
creds
.
tier_id
===
'
string
'
?
creds
.
tier_id
:
undefined
...
...
@@ -354,7 +348,7 @@ const handleExchangeCode = async () => {
if
(
isOpenAILike
.
value
)
{
// OpenAI OAuth flow
const
oauthClient
=
activeO
pen
AI
OAuth
.
value
const
oauthClient
=
o
pen
ai
OAuth
const
sessionId
=
oauthClient
.
sessionId
.
value
if
(
!
sessionId
)
return
const
stateToUse
=
(
oauthFlowRef
.
value
?.
oauthState
||
oauthClient
.
oauthState
.
value
||
''
).
trim
()
...
...
frontend/src/components/admin/channel/types.ts
View file @
62e80c60
...
...
@@ -184,7 +184,6 @@ export function getPlatformTagClass(platform: string): string {
case
'
openai
'
:
return
'
bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400
'
case
'
gemini
'
:
return
'
bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400
'
case
'
antigravity
'
:
return
'
bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400
'
case
'
sora
'
:
return
'
bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400
'
default
:
return
'
bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400
'
}
}
frontend/src/components/admin/user/UserEditModal.vue
View file @
62e80c60
...
...
@@ -37,14 +37,6 @@
<label
class=
"input-label"
>
{{
t
(
'
admin.users.columns.concurrency
'
)
}}
</label>
<input
v-model.number=
"form.concurrency"
type=
"number"
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.soraStorageQuota
'
)
}}
</label>
<div
class=
"flex items-center gap-2"
>
<input
v-model.number=
"form.sora_storage_quota_gb"
type=
"number"
min=
"0"
step=
"0.1"
class=
"input"
placeholder=
"0"
/>
<span
class=
"shrink-0 text-sm text-gray-500"
>
GB
</span>
</div>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.users.soraStorageQuotaHint
'
)
}}
</p>
</div>
<UserAttributeForm
v-model=
"form.customAttributes"
:user-id=
"user?.id"
/>
</form>
<template
#footer
>
...
...
@@ -74,11 +66,11 @@ const emit = defineEmits(['close', 'success'])
const
{
t
}
=
useI18n
();
const
appStore
=
useAppStore
();
const
{
copyToClipboard
}
=
useClipboard
()
const
submitting
=
ref
(
false
);
const
passwordCopied
=
ref
(
false
)
const
form
=
reactive
({
email
:
''
,
password
:
''
,
username
:
''
,
notes
:
''
,
concurrency
:
1
,
sora_storage_quota_gb
:
0
,
customAttributes
:
{}
as
UserAttributeValuesMap
})
const
form
=
reactive
({
email
:
''
,
password
:
''
,
username
:
''
,
notes
:
''
,
concurrency
:
1
,
customAttributes
:
{}
as
UserAttributeValuesMap
})
watch
(()
=>
props
.
user
,
(
u
)
=>
{
if
(
u
)
{
Object
.
assign
(
form
,
{
email
:
u
.
email
,
password
:
''
,
username
:
u
.
username
||
''
,
notes
:
u
.
notes
||
''
,
concurrency
:
u
.
concurrency
,
sora_storage_quota_gb
:
Number
(((
u
.
sora_storage_quota_bytes
||
0
)
/
(
1024
*
1024
*
1024
)).
toFixed
(
2
)),
customAttributes
:
{}
})
Object
.
assign
(
form
,
{
email
:
u
.
email
,
password
:
''
,
username
:
u
.
username
||
''
,
notes
:
u
.
notes
||
''
,
concurrency
:
u
.
concurrency
,
customAttributes
:
{}
})
passwordCopied
.
value
=
false
}
},
{
immediate
:
true
})
...
...
@@ -105,7 +97,7 @@ const handleUpdateUser = async () => {
}
submitting
.
value
=
true
try
{
const
data
:
any
=
{
email
:
form
.
email
,
username
:
form
.
username
,
notes
:
form
.
notes
,
concurrency
:
form
.
concurrency
,
sora_storage_quota_bytes
:
Math
.
round
((
form
.
sora_storage_quota_gb
||
0
)
*
1024
*
1024
*
1024
)
}
const
data
:
any
=
{
email
:
form
.
email
,
username
:
form
.
username
,
notes
:
form
.
notes
,
concurrency
:
form
.
concurrency
}
if
(
form
.
password
.
trim
())
data
.
password
=
form
.
password
.
trim
()
await
adminAPI
.
users
.
update
(
props
.
user
.
id
,
data
)
if
(
Object
.
keys
(
form
.
customAttributes
).
length
>
0
)
await
adminAPI
.
userAttributes
.
updateUserAttributeValues
(
props
.
user
.
id
,
form
.
customAttributes
)
...
...
frontend/src/components/common/GroupBadge.vue
View file @
62e80c60
...
...
@@ -116,9 +116,6 @@ const labelClass = computed(() => {
if
(
props
.
platform
===
'
gemini
'
)
{
return
`
${
base
}
bg-blue-200/60 text-blue-800 dark:bg-blue-800/40 dark:text-blue-300`
}
if
(
props
.
platform
===
'
sora
'
)
{
return
`
${
base
}
bg-rose-200/60 text-rose-800 dark:bg-rose-800/40 dark:text-rose-300`
}
return
`
${
base
}
bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300`
})
...
...
@@ -140,11 +137,6 @@ const badgeClass = computed(() => {
?
'
bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400
'
:
'
bg-sky-50 text-sky-700 dark:bg-sky-900/20 dark:text-sky-400
'
}
if
(
props
.
platform
===
'
sora
'
)
{
return
isSubscription
.
value
?
'
bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400
'
:
'
bg-rose-50 text-rose-700 dark:bg-rose-900/20 dark:text-rose-400
'
}
// Fallback: original colors
return
isSubscription
.
value
?
'
bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400
'
...
...
frontend/src/components/common/GroupOptionItem.vue
View file @
62e80c60
...
...
@@ -91,8 +91,6 @@ const ratePillClass = computed(() => {
return
'
bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400
'
case
'
gemini
'
:
return
'
bg-sky-50 text-sky-700 dark:bg-sky-900/20 dark:text-sky-400
'
case
'
sora
'
:
return
'
bg-rose-50 text-rose-700 dark:bg-rose-900/20 dark:text-rose-400
'
default
:
// antigravity and others
return
'
bg-violet-50 text-violet-700 dark:bg-violet-900/20 dark:text-violet-400
'
}
...
...
frontend/src/components/common/PlatformIcon.vue
View file @
62e80c60
...
...
@@ -19,12 +19,6 @@
<svg
v-else-if=
"platform === 'antigravity'"
:class=
"sizeClass"
viewBox=
"0 0 24 24"
fill=
"currentColor"
>
<path
d=
"M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z"
/>
</svg>
<!-- Sora logo (sparkle) -->
<svg
v-else-if=
"platform === 'sora'"
:class=
"sizeClass"
viewBox=
"0 0 24 24"
fill=
"currentColor"
>
<path
d=
"M12 2.5l2.1 4.7 5.1.5-3.9 3.4 1.2 5-4.5-2.6-4.5 2.6 1.2-5-3.9-3.4 5.1-.5L12 2.5z"
/>
</svg>
<!-- Fallback: generic platform icon -->
<svg
v-else
:class=
"sizeClass"
fill=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
...
...
frontend/src/components/common/PlatformTypeBadge.vue
View file @
62e80c60
...
...
@@ -75,7 +75,6 @@ const platformLabel = computed(() => {
if
(
props
.
platform
===
'
anthropic
'
)
return
'
Anthropic
'
if
(
props
.
platform
===
'
openai
'
)
return
'
OpenAI
'
if
(
props
.
platform
===
'
antigravity
'
)
return
'
Antigravity
'
if
(
props
.
platform
===
'
sora
'
)
return
'
Sora
'
return
'
Gemini
'
})
...
...
@@ -124,9 +123,6 @@ const platformClass = computed(() => {
if
(
props
.
platform
===
'
antigravity
'
)
{
return
'
bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400
'
}
if
(
props
.
platform
===
'
sora
'
)
{
return
'
bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400
'
}
return
'
bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400
'
})
...
...
@@ -140,9 +136,6 @@ const typeClass = computed(() => {
if
(
props
.
platform
===
'
antigravity
'
)
{
return
'
bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400
'
}
if
(
props
.
platform
===
'
sora
'
)
{
return
'
bg-rose-100 text-rose-600 dark:bg-rose-900/30 dark:text-rose-400
'
}
return
'
bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400
'
})
...
...
frontend/src/components/sora/SoraDownloadDialog.vue
deleted
100644 → 0
View file @
dbb248df
<
template
>
<Teleport
to=
"body"
>
<Transition
name=
"sora-modal"
>
<div
v-if=
"visible && generation"
class=
"sora-download-overlay"
@
click.self=
"emit('close')"
>
<div
class=
"sora-download-backdrop"
/>
<div
class=
"sora-download-modal"
@
click.stop
>
<div
class=
"sora-download-modal-icon"
>
📥
</div>
<h3
class=
"sora-download-modal-title"
>
{{
t
(
'
sora.downloadTitle
'
)
}}
</h3>
<p
class=
"sora-download-modal-desc"
>
{{
t
(
'
sora.downloadExpirationWarning
'
)
}}
</p>
<!-- 倒计时 -->
<div
v-if=
"remainingText"
class=
"sora-download-countdown"
>
<svg
width=
"16"
height=
"16"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span
:class=
"
{ expired: isExpired }">
{{
isExpired
?
t
(
'
sora.upstreamExpired
'
)
:
t
(
'
sora.upstreamCountdown
'
,
{
time
:
remainingText
}
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
sora-download-modal-actions
"
>
<
a
v
-
if
=
"
generation.media_url
"
:
href
=
"
generation.media_url
"
target
=
"
_blank
"
download
class
=
"
sora-download-btn primary
"
>
{{
t
(
'
sora.downloadNow
'
)
}}
<
/a
>
<
button
class
=
"
sora-download-btn ghost
"
@
click
=
"
emit('close')
"
>
{{
t
(
'
sora.closePreview
'
)
}}
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<
/Transition
>
<
/Teleport
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
computed
,
watch
,
onUnmounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
type
{
SoraGeneration
}
from
'
@/api/sora
'
const
EXPIRATION_MINUTES
=
15
const
props
=
defineProps
<
{
visible
:
boolean
generation
:
SoraGeneration
|
null
}
>
()
const
emit
=
defineEmits
<
{
close
:
[]
}
>
()
const
{
t
}
=
useI18n
()
const
now
=
ref
(
Date
.
now
())
let
timer
:
ReturnType
<
typeof
setInterval
>
|
null
=
null
const
expiresAt
=
computed
(()
=>
{
if
(
!
props
.
generation
?.
completed_at
)
return
null
return
new
Date
(
props
.
generation
.
completed_at
).
getTime
()
+
EXPIRATION_MINUTES
*
60
*
1000
}
)
const
isExpired
=
computed
(()
=>
{
if
(
!
expiresAt
.
value
)
return
false
return
now
.
value
>=
expiresAt
.
value
}
)
const
remainingText
=
computed
(()
=>
{
if
(
!
expiresAt
.
value
)
return
''
const
diff
=
expiresAt
.
value
-
now
.
value
if
(
diff
<=
0
)
return
''
const
minutes
=
Math
.
floor
(
diff
/
60000
)
const
seconds
=
Math
.
floor
((
diff
%
60000
)
/
1000
)
return
`${minutes
}
:${String(seconds).padStart(2, '0')
}
`
}
)
watch
(
()
=>
props
.
visible
,
(
v
)
=>
{
if
(
v
)
{
now
.
value
=
Date
.
now
()
timer
=
setInterval
(()
=>
{
now
.
value
=
Date
.
now
()
}
,
1000
)
}
else
if
(
timer
)
{
clearInterval
(
timer
)
timer
=
null
}
}
,
{
immediate
:
true
}
)
onUnmounted
(()
=>
{
if
(
timer
)
clearInterval
(
timer
)
}
)
<
/script
>
<
style
scoped
>
.
sora
-
download
-
overlay
{
position
:
fixed
;
inset
:
0
;
z
-
index
:
50
;
display
:
flex
;
align
-
items
:
center
;
justify
-
content
:
center
;
}
.
sora
-
download
-
backdrop
{
position
:
absolute
;
inset
:
0
;
background
:
var
(
--
sora
-
modal
-
backdrop
,
rgba
(
0
,
0
,
0
,
0.4
));
backdrop
-
filter
:
blur
(
4
px
);
}
.
sora
-
download
-
modal
{
position
:
relative
;
z
-
index
:
10
;
background
:
var
(
--
sora
-
bg
-
secondary
,
#
FFF
);
border
:
1
px
solid
var
(
--
sora
-
border
-
color
,
#
E5E7EB
);
border
-
radius
:
20
px
;
padding
:
32
px
;
max
-
width
:
420
px
;
width
:
90
%
;
text
-
align
:
center
;
animation
:
sora
-
modal
-
in
0.3
s
ease
;
}
@
keyframes
sora
-
modal
-
in
{
from
{
transform
:
scale
(
0.95
);
opacity
:
0
;
}
to
{
transform
:
scale
(
1
);
opacity
:
1
;
}
}
.
sora
-
download
-
modal
-
icon
{
font
-
size
:
48
px
;
margin
-
bottom
:
16
px
;
}
.
sora
-
download
-
modal
-
title
{
font
-
size
:
18
px
;
font
-
weight
:
600
;
color
:
var
(
--
sora
-
text
-
primary
,
#
111827
);
margin
-
bottom
:
8
px
;
}
.
sora
-
download
-
modal
-
desc
{
font
-
size
:
14
px
;
color
:
var
(
--
sora
-
text
-
secondary
,
#
6
B7280
);
margin
-
bottom
:
20
px
;
line
-
height
:
1.6
;
}
.
sora
-
download
-
countdown
{
display
:
flex
;
align
-
items
:
center
;
justify
-
content
:
center
;
gap
:
6
px
;
font
-
size
:
14
px
;
color
:
var
(
--
sora
-
text
-
secondary
,
#
6
B7280
);
margin
-
bottom
:
24
px
;
}
.
sora
-
download
-
countdown
svg
{
color
:
var
(
--
sora
-
text
-
tertiary
,
#
9
CA3AF
);
}
.
sora
-
download
-
countdown
.
expired
{
color
:
#
EF4444
;
}
.
sora
-
download
-
modal
-
actions
{
display
:
flex
;
gap
:
12
px
;
justify
-
content
:
center
;
}
.
sora
-
download
-
btn
{
padding
:
10
px
24
px
;
border
-
radius
:
9999
px
;
font
-
size
:
14
px
;
font
-
weight
:
500
;
border
:
none
;
cursor
:
pointer
;
transition
:
all
150
ms
ease
;
text
-
decoration
:
none
;
display
:
inline
-
flex
;
align
-
items
:
center
;
gap
:
6
px
;
}
.
sora
-
download
-
btn
.
primary
{
background
:
var
(
--
sora
-
accent
-
gradient
);
color
:
white
;
}
.
sora
-
download
-
btn
.
primary
:
hover
{
box
-
shadow
:
var
(
--
sora
-
shadow
-
glow
);
}
.
sora
-
download
-
btn
.
ghost
{
background
:
var
(
--
sora
-
bg
-
tertiary
,
#
F3F4F6
);
color
:
var
(
--
sora
-
text
-
secondary
,
#
6
B7280
);
}
.
sora
-
download
-
btn
.
ghost
:
hover
{
background
:
var
(
--
sora
-
bg
-
hover
,
#
E5E7EB
);
color
:
var
(
--
sora
-
text
-
primary
,
#
111827
);
}
/* 过渡 */
.
sora
-
modal
-
enter
-
active
,
.
sora
-
modal
-
leave
-
active
{
transition
:
opacity
0.2
s
ease
;
}
.
sora
-
modal
-
enter
-
from
,
.
sora
-
modal
-
leave
-
to
{
opacity
:
0
;
}
<
/style
>
frontend/src/components/sora/SoraGeneratePage.vue
deleted
100644 → 0
View file @
dbb248df
<
template
>
<div
class=
"sora-generate-page"
>
<div
class=
"sora-task-area"
>
<!-- 欢迎区域(无任务时显示) -->
<div
v-if=
"activeGenerations.length === 0"
class=
"sora-welcome-section"
>
<h1
class=
"sora-welcome-title"
>
{{
t
(
'
sora.welcomeTitle
'
)
}}
</h1>
<p
class=
"sora-welcome-subtitle"
>
{{
t
(
'
sora.welcomeSubtitle
'
)
}}
</p>
</div>
<!-- 示例提示词(无任务时显示) -->
<div
v-if=
"activeGenerations.length === 0"
class=
"sora-example-prompts"
>
<button
v-for=
"(example, idx) in examplePrompts"
:key=
"idx"
class=
"sora-example-prompt"
@
click=
"fillPrompt(example)"
>
{{
example
}}
</button>
</div>
<!-- 任务卡片列表 -->
<div
v-if=
"activeGenerations.length > 0"
class=
"sora-task-cards"
>
<SoraProgressCard
v-for=
"gen in activeGenerations"
:key=
"gen.id"
:generation=
"gen"
@
cancel=
"handleCancel"
@
delete=
"handleDelete"
@
save=
"handleSave"
@
retry=
"handleRetry"
/>
</div>
<!-- 无存储提示 Toast -->
<div
v-if=
"showNoStorageToast"
class=
"sora-no-storage-toast"
>
<span>
⚠️
</span>
<span>
{{
t
(
'
sora.noStorageToastMessage
'
)
}}
</span>
</div>
</div>
<!-- 底部创作栏 -->
<SoraPromptBar
ref=
"promptBarRef"
:generating=
"generating"
:active-task-count=
"activeTaskCount"
:max-concurrent-tasks=
"3"
@
generate=
"handleGenerate"
/>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
onMounted
,
onUnmounted
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
soraAPI
,
{
type
SoraGeneration
,
type
GenerateRequest
}
from
'
@/api/sora
'
import
SoraProgressCard
from
'
./SoraProgressCard.vue
'
import
SoraPromptBar
from
'
./SoraPromptBar.vue
'
const
{
t
}
=
useI18n
()
const
emit
=
defineEmits
<
{
'
task-count-change
'
:
[
counts
:
{
active
:
number
;
generating
:
boolean
}]
}
>
()
const
activeGenerations
=
ref
<
SoraGeneration
[]
>
([])
const
generating
=
ref
(
false
)
const
showNoStorageToast
=
ref
(
false
)
let
pollTimers
:
Record
<
number
,
ReturnType
<
typeof
setTimeout
>>
=
{}
const
promptBarRef
=
ref
<
InstanceType
<
typeof
SoraPromptBar
>
|
null
>
(
null
)
// 示例提示词
const
examplePrompts
=
[
'
一只金色的柴犬在东京涩谷街头散步,镜头跟随,电影感画面,4K 高清
'
,
'
无人机航拍视角,冰岛极光下的冰川湖面反射绿色光芒,慢速推进
'
,
'
赛博朋克风格的未来城市,霓虹灯倒映在雨后积水中,夜景,电影级色彩
'
,
'
水墨画风格,一叶扁舟在山水间漂泊,薄雾缭绕,中国古典意境
'
]
// 活跃任务统计
const
activeTaskCount
=
computed
(()
=>
activeGenerations
.
value
.
filter
(
g
=>
g
.
status
===
'
pending
'
||
g
.
status
===
'
generating
'
).
length
)
const
hasGeneratingTask
=
computed
(()
=>
activeGenerations
.
value
.
some
(
g
=>
g
.
status
===
'
generating
'
)
)
// 通知父组件任务数变化
watch
([
activeTaskCount
,
hasGeneratingTask
],
()
=>
{
emit
(
'
task-count-change
'
,
{
active
:
activeTaskCount
.
value
,
generating
:
hasGeneratingTask
.
value
})
},
{
immediate
:
true
})
// ==================== 浏览器通知 ====================
function
requestNotificationPermission
()
{
if
(
'
Notification
'
in
window
&&
Notification
.
permission
===
'
default
'
)
{
Notification
.
requestPermission
()
}
}
function
sendNotification
(
title
:
string
,
body
:
string
)
{
if
(
'
Notification
'
in
window
&&
Notification
.
permission
===
'
granted
'
)
{
new
Notification
(
title
,
{
body
,
icon
:
'
/favicon.ico
'
})
}
}
const
originalTitle
=
document
.
title
let
titleBlinkTimer
:
ReturnType
<
typeof
setInterval
>
|
null
=
null
function
startTitleBlink
(
message
:
string
)
{
stopTitleBlink
()
let
show
=
true
titleBlinkTimer
=
setInterval
(()
=>
{
document
.
title
=
show
?
message
:
originalTitle
show
=
!
show
},
1000
)
const
onFocus
=
()
=>
{
stopTitleBlink
()
window
.
removeEventListener
(
'
focus
'
,
onFocus
)
}
window
.
addEventListener
(
'
focus
'
,
onFocus
)
}
function
stopTitleBlink
()
{
if
(
titleBlinkTimer
)
{
clearInterval
(
titleBlinkTimer
)
titleBlinkTimer
=
null
}
document
.
title
=
originalTitle
}
function
checkStatusTransition
(
oldGen
:
SoraGeneration
,
newGen
:
SoraGeneration
)
{
const
wasActive
=
oldGen
.
status
===
'
pending
'
||
oldGen
.
status
===
'
generating
'
if
(
!
wasActive
)
return
if
(
newGen
.
status
===
'
completed
'
)
{
const
title
=
t
(
'
sora.notificationCompleted
'
)
const
body
=
t
(
'
sora.notificationCompletedBody
'
,
{
model
:
newGen
.
model
})
sendNotification
(
title
,
body
)
if
(
document
.
hidden
)
startTitleBlink
(
title
)
}
else
if
(
newGen
.
status
===
'
failed
'
)
{
const
title
=
t
(
'
sora.notificationFailed
'
)
const
body
=
t
(
'
sora.notificationFailedBody
'
,
{
model
:
newGen
.
model
})
sendNotification
(
title
,
body
)
if
(
document
.
hidden
)
startTitleBlink
(
title
)
}
}
// ==================== beforeunload ====================
const
hasUpstreamRecords
=
computed
(()
=>
activeGenerations
.
value
.
some
(
g
=>
g
.
status
===
'
completed
'
&&
g
.
storage_type
===
'
upstream
'
)
)
function
beforeUnloadHandler
(
e
:
BeforeUnloadEvent
)
{
if
(
hasUpstreamRecords
.
value
)
{
e
.
preventDefault
()
e
.
returnValue
=
t
(
'
sora.beforeUnloadWarning
'
)
return
e
.
returnValue
}
}
// ==================== 轮询 ====================
function
getPollingIntervalByRuntime
(
createdAt
:
string
):
number
{
const
createdAtMs
=
new
Date
(
createdAt
).
getTime
()
if
(
Number
.
isNaN
(
createdAtMs
))
return
3000
const
elapsedMs
=
Date
.
now
()
-
createdAtMs
if
(
elapsedMs
<
2
*
60
*
1000
)
return
3000
if
(
elapsedMs
<
10
*
60
*
1000
)
return
10000
return
30000
}
function
schedulePolling
(
id
:
number
)
{
const
current
=
activeGenerations
.
value
.
find
(
g
=>
g
.
id
===
id
)
const
interval
=
current
?
getPollingIntervalByRuntime
(
current
.
created_at
)
:
3000
if
(
pollTimers
[
id
])
clearTimeout
(
pollTimers
[
id
])
pollTimers
[
id
]
=
setTimeout
(()
=>
{
void
pollGeneration
(
id
)
},
interval
)
}
async
function
pollGeneration
(
id
:
number
)
{
try
{
const
gen
=
await
soraAPI
.
getGeneration
(
id
)
const
idx
=
activeGenerations
.
value
.
findIndex
(
g
=>
g
.
id
===
id
)
if
(
idx
>=
0
)
{
checkStatusTransition
(
activeGenerations
.
value
[
idx
],
gen
)
activeGenerations
.
value
[
idx
]
=
gen
}
if
(
gen
.
status
===
'
pending
'
||
gen
.
status
===
'
generating
'
)
{
schedulePolling
(
id
)
}
else
{
delete
pollTimers
[
id
]
}
}
catch
{
delete
pollTimers
[
id
]
}
}
async
function
loadActiveGenerations
()
{
try
{
const
res
=
await
soraAPI
.
listGenerations
({
status
:
'
pending,generating,completed,failed,cancelled
'
,
page_size
:
50
})
const
generations
=
Array
.
isArray
(
res
.
data
)
?
res
.
data
:
[]
activeGenerations
.
value
=
generations
for
(
const
gen
of
generations
)
{
if
((
gen
.
status
===
'
pending
'
||
gen
.
status
===
'
generating
'
)
&&
!
pollTimers
[
gen
.
id
])
{
schedulePolling
(
gen
.
id
)
}
}
}
catch
(
e
)
{
console
.
error
(
'
Failed to load generations:
'
,
e
)
}
}
// ==================== 操作 ====================
async
function
handleGenerate
(
req
:
GenerateRequest
)
{
generating
.
value
=
true
try
{
const
res
=
await
soraAPI
.
generate
(
req
)
const
gen
=
await
soraAPI
.
getGeneration
(
res
.
generation_id
)
activeGenerations
.
value
.
unshift
(
gen
)
schedulePolling
(
gen
.
id
)
}
catch
(
e
:
any
)
{
console
.
error
(
'
Generate failed:
'
,
e
)
alert
(
e
?.
response
?.
data
?.
message
||
e
?.
message
||
'
Generation failed
'
)
}
finally
{
generating
.
value
=
false
}
}
async
function
handleCancel
(
id
:
number
)
{
try
{
await
soraAPI
.
cancelGeneration
(
id
)
const
idx
=
activeGenerations
.
value
.
findIndex
(
g
=>
g
.
id
===
id
)
if
(
idx
>=
0
)
activeGenerations
.
value
[
idx
].
status
=
'
cancelled
'
}
catch
(
e
)
{
console
.
error
(
'
Cancel failed:
'
,
e
)
}
}
async
function
handleDelete
(
id
:
number
)
{
try
{
await
soraAPI
.
deleteGeneration
(
id
)
activeGenerations
.
value
=
activeGenerations
.
value
.
filter
(
g
=>
g
.
id
!==
id
)
}
catch
(
e
)
{
console
.
error
(
'
Delete failed:
'
,
e
)
}
}
async
function
handleSave
(
id
:
number
)
{
try
{
await
soraAPI
.
saveToStorage
(
id
)
const
gen
=
await
soraAPI
.
getGeneration
(
id
)
const
idx
=
activeGenerations
.
value
.
findIndex
(
g
=>
g
.
id
===
id
)
if
(
idx
>=
0
)
activeGenerations
.
value
[
idx
]
=
gen
}
catch
(
e
)
{
console
.
error
(
'
Save failed:
'
,
e
)
}
}
function
handleRetry
(
gen
:
SoraGeneration
)
{
handleGenerate
({
model
:
gen
.
model
,
prompt
:
gen
.
prompt
,
media_type
:
gen
.
media_type
})
}
function
fillPrompt
(
text
:
string
)
{
promptBarRef
.
value
?.
fillPrompt
(
text
)
}
// ==================== 检查存储状态 ====================
async
function
checkStorageStatus
()
{
try
{
const
status
=
await
soraAPI
.
getStorageStatus
()
if
(
!
status
.
s3_enabled
||
!
status
.
s3_healthy
)
{
showNoStorageToast
.
value
=
true
setTimeout
(()
=>
{
showNoStorageToast
.
value
=
false
},
8000
)
}
}
catch
{
// 忽略
}
}
onMounted
(()
=>
{
loadActiveGenerations
()
requestNotificationPermission
()
checkStorageStatus
()
window
.
addEventListener
(
'
beforeunload
'
,
beforeUnloadHandler
)
})
onUnmounted
(()
=>
{
Object
.
values
(
pollTimers
).
forEach
(
clearTimeout
)
pollTimers
=
{}
stopTitleBlink
()
window
.
removeEventListener
(
'
beforeunload
'
,
beforeUnloadHandler
)
})
</
script
>
<
style
scoped
>
.sora-generate-page
{
padding-bottom
:
200px
;
min-height
:
calc
(
100vh
-
56px
);
display
:
flex
;
flex-direction
:
column
;
}
/* 任务区域 */
.sora-task-area
{
flex
:
1
;
display
:
flex
;
flex-direction
:
column
;
align-items
:
center
;
justify-content
:
center
;
padding
:
40px
24px
;
gap
:
24px
;
max-width
:
900px
;
margin
:
0
auto
;
width
:
100%
;
}
/* 欢迎区域 */
.sora-welcome-section
{
text-align
:
center
;
padding
:
60px
0
40px
;
}
.sora-welcome-title
{
font-size
:
36px
;
font-weight
:
700
;
letter-spacing
:
-0.03em
;
margin-bottom
:
12px
;
background
:
linear-gradient
(
135deg
,
var
(
--sora-text-primary
)
0%
,
var
(
--sora-text-secondary
)
100%
);
-webkit-background-clip
:
text
;
-webkit-text-fill-color
:
transparent
;
background-clip
:
text
;
}
.sora-welcome-subtitle
{
font-size
:
16px
;
color
:
var
(
--sora-text-secondary
,
#A0A0A0
);
max-width
:
480px
;
margin
:
0
auto
;
line-height
:
1.6
;
}
/* 示例提示词 */
.sora-example-prompts
{
display
:
grid
;
grid-template-columns
:
repeat
(
2
,
1
fr
);
gap
:
12px
;
width
:
100%
;
max-width
:
640px
;
}
.sora-example-prompt
{
padding
:
16px
20px
;
background
:
var
(
--sora-bg-secondary
,
#1A1A1A
);
border
:
1px
solid
var
(
--sora-border-color
,
#2A2A2A
);
border-radius
:
var
(
--sora-radius-md
,
12px
);
font-size
:
13px
;
color
:
var
(
--sora-text-secondary
,
#A0A0A0
);
cursor
:
pointer
;
transition
:
all
150ms
ease
;
text-align
:
left
;
line-height
:
1.5
;
font-family
:
inherit
;
}
.sora-example-prompt
:hover
{
background
:
var
(
--sora-bg-tertiary
,
#242424
);
border-color
:
var
(
--sora-bg-hover
,
#333
);
color
:
var
(
--sora-text-primary
,
#FFF
);
transform
:
translateY
(
-1px
);
}
/* 任务卡片列表 */
.sora-task-cards
{
width
:
100%
;
display
:
flex
;
flex-direction
:
column
;
gap
:
16px
;
}
/* 无存储 Toast */
.sora-no-storage-toast
{
position
:
fixed
;
top
:
80px
;
right
:
24px
;
background
:
var
(
--sora-bg-elevated
,
#2A2A2A
);
border
:
1px
solid
var
(
--sora-warning
,
#F59E0B
);
border-radius
:
var
(
--sora-radius-md
,
12px
);
padding
:
14px
20px
;
font-size
:
13px
;
color
:
var
(
--sora-warning
,
#F59E0B
);
z-index
:
50
;
box-shadow
:
var
(
--sora-shadow-lg
,
0
8px
32px
rgba
(
0
,
0
,
0
,
0.5
));
animation
:
sora-slide-in-right
0.3s
ease
;
max-width
:
340px
;
display
:
flex
;
align-items
:
center
;
gap
:
10px
;
}
@keyframes
sora-slide-in-right
{
from
{
transform
:
translateX
(
100%
);
opacity
:
0
;
}
to
{
transform
:
translateX
(
0
);
opacity
:
1
;
}
}
/* 响应式 */
@media
(
max-width
:
900px
)
{
.sora-example-prompts
{
grid-template-columns
:
1
fr
;
}
}
@media
(
max-width
:
600px
)
{
.sora-welcome-title
{
font-size
:
28px
;
}
.sora-task-area
{
padding
:
24px
16px
;
}
}
</
style
>
frontend/src/components/sora/SoraLibraryPage.vue
deleted
100644 → 0
View file @
dbb248df
<
template
>
<div
class=
"sora-gallery-page"
>
<!-- 筛选栏 -->
<div
class=
"sora-gallery-filter-bar"
>
<div
class=
"sora-gallery-filters"
>
<button
v-for=
"f in filters"
:key=
"f.value"
:class=
"['sora-gallery-filter', activeFilter === f.value && 'active']"
@
click=
"activeFilter = f.value"
>
{{
f
.
label
}}
</button>
</div>
<span
class=
"sora-gallery-count"
>
{{
t
(
'
sora.galleryCount
'
,
{
count
:
filteredItems
.
length
}
)
}}
<
/span
>
<
/div
>
<!--
作品网格
-->
<
div
v
-
if
=
"
filteredItems.length > 0
"
class
=
"
sora-gallery-grid
"
>
<
div
v
-
for
=
"
item in filteredItems
"
:
key
=
"
item.id
"
class
=
"
sora-gallery-card
"
@
click
=
"
openPreview(item)
"
>
<
div
class
=
"
sora-gallery-card-thumb
"
>
<!--
媒体
-->
<
video
v
-
if
=
"
item.media_type === 'video' && item.media_url
"
:
src
=
"
item.media_url
"
class
=
"
sora-gallery-card-image
"
muted
loop
@
mouseenter
=
"
($event.target as HTMLVideoElement).play()
"
@
mouseleave
=
"
($event.target as HTMLVideoElement).pause()
"
/>
<
img
v
-
else
-
if
=
"
item.media_url
"
:
src
=
"
item.media_url
"
class
=
"
sora-gallery-card-image
"
alt
=
""
/>
<
div
v
-
else
class
=
"
sora-gallery-card-image sora-gallery-card-placeholder
"
:
class
=
"
getGradientClass(item.id)
"
>
{{
item
.
media_type
===
'
video
'
?
'
🎬
'
:
'
🎨
'
}}
<
/div
>
<!--
类型角标
-->
<
span
class
=
"
sora-gallery-card-badge
"
:
class
=
"
item.media_type === 'video' ? 'video' : 'image'
"
>
{{
item
.
media_type
===
'
video
'
?
'
VIDEO
'
:
'
IMAGE
'
}}
<
/span
>
<!--
Hover
操作层
-->
<
div
class
=
"
sora-gallery-card-overlay
"
>
<
button
v
-
if
=
"
item.media_url
"
class
=
"
sora-gallery-card-action
"
title
=
"
下载
"
@
click
.
stop
=
"
handleDownload(item)
"
>
📥
<
/button
>
<
button
class
=
"
sora-gallery-card-action
"
title
=
"
删除
"
@
click
.
stop
=
"
handleDelete(item.id)
"
>
🗑
<
/button
>
<
/div
>
<!--
视频播放指示
-->
<
div
v
-
if
=
"
item.media_type === 'video'
"
class
=
"
sora-gallery-card-play
"
>
▶
<
/div
>
<!--
视频时长
-->
<
span
v
-
if
=
"
item.media_type === 'video'
"
class
=
"
sora-gallery-card-duration
"
>
{{
formatDuration
(
item
)
}}
<
/span
>
<
/div
>
<!--
卡片底部信息
-->
<
div
class
=
"
sora-gallery-card-info
"
>
<
div
class
=
"
sora-gallery-card-model
"
>
{{
item
.
model
}}
<
/div
>
<
div
class
=
"
sora-gallery-card-time
"
>
{{
formatTime
(
item
.
created_at
)
}}
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
空状态
-->
<
div
v
-
else
-
if
=
"
!loading
"
class
=
"
sora-gallery-empty
"
>
<
div
class
=
"
sora-gallery-empty-icon
"
>
🎬
<
/div
>
<
h2
class
=
"
sora-gallery-empty-title
"
>
{{
t
(
'
sora.galleryEmptyTitle
'
)
}}
<
/h2
>
<
p
class
=
"
sora-gallery-empty-desc
"
>
{{
t
(
'
sora.galleryEmptyDesc
'
)
}}
<
/p
>
<
button
class
=
"
sora-gallery-empty-btn
"
@
click
=
"
emit('switchToGenerate')
"
>
{{
t
(
'
sora.startCreating
'
)
}}
<
/button
>
<
/div
>
<!--
加载更多
-->
<
div
v
-
if
=
"
hasMore && filteredItems.length > 0
"
class
=
"
sora-gallery-load-more
"
>
<
button
class
=
"
sora-gallery-load-more-btn
"
:
disabled
=
"
loading
"
@
click
=
"
loadMore
"
>
{{
loading
?
t
(
'
sora.loading
'
)
:
t
(
'
sora.loadMore
'
)
}}
<
/button
>
<
/div
>
<!--
预览弹窗
-->
<
SoraMediaPreview
:
visible
=
"
previewVisible
"
:
generation
=
"
previewItem
"
@
close
=
"
previewVisible = false
"
@
save
=
"
handleSaveFromPreview
"
@
download
=
"
handleDownloadUrl
"
/>
<
/div
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
computed
,
onMounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
soraAPI
,
{
type
SoraGeneration
}
from
'
@/api/sora
'
import
{
getPersistedPageSize
}
from
'
@/composables/usePersistedPageSize
'
import
SoraMediaPreview
from
'
./SoraMediaPreview.vue
'
const
emit
=
defineEmits
<
{
'
switchToGenerate
'
:
[]
}
>
()
const
{
t
}
=
useI18n
()
const
items
=
ref
<
SoraGeneration
[]
>
([])
const
loading
=
ref
(
false
)
const
page
=
ref
(
1
)
const
hasMore
=
ref
(
true
)
const
activeFilter
=
ref
(
'
all
'
)
const
previewVisible
=
ref
(
false
)
const
previewItem
=
ref
<
SoraGeneration
|
null
>
(
null
)
const
filters
=
computed
(()
=>
[
{
value
:
'
all
'
,
label
:
t
(
'
sora.filterAll
'
)
}
,
{
value
:
'
video
'
,
label
:
t
(
'
sora.filterVideo
'
)
}
,
{
value
:
'
image
'
,
label
:
t
(
'
sora.filterImage
'
)
}
])
const
filteredItems
=
computed
(()
=>
{
if
(
activeFilter
.
value
===
'
all
'
)
return
items
.
value
return
items
.
value
.
filter
(
i
=>
i
.
media_type
===
activeFilter
.
value
)
}
)
const
gradientClasses
=
[
'
gradient-bg-1
'
,
'
gradient-bg-2
'
,
'
gradient-bg-3
'
,
'
gradient-bg-4
'
,
'
gradient-bg-5
'
,
'
gradient-bg-6
'
,
'
gradient-bg-7
'
,
'
gradient-bg-8
'
]
function
getGradientClass
(
id
:
number
):
string
{
return
gradientClasses
[
id
%
gradientClasses
.
length
]
}
function
formatTime
(
iso
:
string
):
string
{
const
d
=
new
Date
(
iso
)
const
now
=
new
Date
()
const
diff
=
now
.
getTime
()
-
d
.
getTime
()
if
(
diff
<
60000
)
return
t
(
'
sora.justNow
'
)
if
(
diff
<
3600000
)
return
t
(
'
sora.minutesAgo
'
,
{
n
:
Math
.
floor
(
diff
/
60000
)
}
)
if
(
diff
<
86400000
)
return
t
(
'
sora.hoursAgo
'
,
{
n
:
Math
.
floor
(
diff
/
3600000
)
}
)
if
(
diff
<
2
*
86400000
)
return
t
(
'
sora.yesterday
'
)
return
d
.
toLocaleDateString
()
}
function
formatDuration
(
item
:
SoraGeneration
):
string
{
// 从模型名提取时长,如 sora2-landscape-10s -> 0:10
const
match
=
item
.
model
.
match
(
/
(\d
+
)
s$/
)
if
(
match
)
{
const
sec
=
parseInt
(
match
[
1
])
return
`0:${sec.toString().padStart(2, '0')
}
`
}
return
'
0:10
'
}
async
function
loadItems
(
pageNum
:
number
)
{
loading
.
value
=
true
try
{
const
res
=
await
soraAPI
.
listGenerations
({
status
:
'
completed
'
,
storage_type
:
'
s3,local
'
,
page
:
pageNum
,
page_size
:
getPersistedPageSize
()
}
)
const
rows
=
Array
.
isArray
(
res
.
data
)
?
res
.
data
:
[]
if
(
pageNum
===
1
)
{
items
.
value
=
rows
}
else
{
items
.
value
.
push
(...
rows
)
}
hasMore
.
value
=
items
.
value
.
length
<
res
.
total
}
catch
(
e
)
{
console
.
error
(
'
Failed to load library:
'
,
e
)
}
finally
{
loading
.
value
=
false
}
}
function
loadMore
()
{
page
.
value
++
loadItems
(
page
.
value
)
}
function
openPreview
(
item
:
SoraGeneration
)
{
previewItem
.
value
=
item
previewVisible
.
value
=
true
}
async
function
handleDelete
(
id
:
number
)
{
if
(
!
confirm
(
t
(
'
sora.confirmDelete
'
)))
return
try
{
await
soraAPI
.
deleteGeneration
(
id
)
items
.
value
=
items
.
value
.
filter
(
i
=>
i
.
id
!==
id
)
}
catch
(
e
)
{
console
.
error
(
'
Delete failed:
'
,
e
)
}
}
function
handleDownload
(
item
:
SoraGeneration
)
{
if
(
item
.
media_url
)
{
window
.
open
(
item
.
media_url
,
'
_blank
'
)
}
}
function
handleDownloadUrl
(
url
:
string
)
{
window
.
open
(
url
,
'
_blank
'
)
}
async
function
handleSaveFromPreview
(
id
:
number
)
{
try
{
await
soraAPI
.
saveToStorage
(
id
)
const
gen
=
await
soraAPI
.
getGeneration
(
id
)
const
idx
=
items
.
value
.
findIndex
(
i
=>
i
.
id
===
id
)
if
(
idx
>=
0
)
items
.
value
[
idx
]
=
gen
}
catch
(
e
)
{
console
.
error
(
'
Save failed:
'
,
e
)
}
}
onMounted
(()
=>
loadItems
(
1
))
<
/script
>
<
style
scoped
>
.
sora
-
gallery
-
page
{
padding
:
24
px
;
padding
-
bottom
:
40
px
;
}
/* 筛选栏 */
.
sora
-
gallery
-
filter
-
bar
{
display
:
flex
;
align
-
items
:
center
;
justify
-
content
:
space
-
between
;
margin
-
bottom
:
24
px
;
}
.
sora
-
gallery
-
filters
{
display
:
flex
;
gap
:
4
px
;
background
:
var
(
--
sora
-
bg
-
secondary
,
#
1
A1A1A
);
border
-
radius
:
var
(
--
sora
-
radius
-
full
,
9999
px
);
padding
:
3
px
;
}
.
sora
-
gallery
-
filter
{
padding
:
6
px
18
px
;
border
-
radius
:
var
(
--
sora
-
radius
-
full
,
9999
px
);
font
-
size
:
13
px
;
font
-
weight
:
500
;
color
:
var
(
--
sora
-
text
-
secondary
,
#
A0A0A0
);
background
:
none
;
border
:
none
;
cursor
:
pointer
;
transition
:
all
150
ms
ease
;
user
-
select
:
none
;
}
.
sora
-
gallery
-
filter
:
hover
{
color
:
var
(
--
sora
-
text
-
primary
,
#
FFF
);
}
.
sora
-
gallery
-
filter
.
active
{
background
:
var
(
--
sora
-
bg
-
tertiary
,
#
242424
);
color
:
var
(
--
sora
-
text
-
primary
,
#
FFF
);
}
.
sora
-
gallery
-
count
{
font
-
size
:
13
px
;
color
:
var
(
--
sora
-
text
-
tertiary
,
#
666
);
}
/* 网格 */
.
sora
-
gallery
-
grid
{
display
:
grid
;
grid
-
template
-
columns
:
repeat
(
4
,
1
fr
);
gap
:
16
px
;
}
/* 卡片 */
.
sora
-
gallery
-
card
{
position
:
relative
;
border
-
radius
:
var
(
--
sora
-
radius
-
md
,
12
px
);
overflow
:
hidden
;
background
:
var
(
--
sora
-
bg
-
secondary
,
#
1
A1A1A
);
border
:
1
px
solid
var
(
--
sora
-
border
-
color
,
#
2
A2A2A
);
cursor
:
pointer
;
transition
:
all
250
ms
ease
;
}
.
sora
-
gallery
-
card
:
hover
{
border
-
color
:
var
(
--
sora
-
bg
-
hover
,
#
333
);
transform
:
translateY
(
-
2
px
);
box
-
shadow
:
var
(
--
sora
-
shadow
-
lg
,
0
8
px
32
px
rgba
(
0
,
0
,
0
,
0.5
));
}
.
sora
-
gallery
-
card
-
thumb
{
position
:
relative
;
width
:
100
%
;
aspect
-
ratio
:
16
/
9
;
overflow
:
hidden
;
}
.
sora
-
gallery
-
card
-
image
{
width
:
100
%
;
height
:
100
%
;
object
-
fit
:
cover
;
display
:
block
;
transition
:
transform
400
ms
ease
;
}
.
sora
-
gallery
-
card
:
hover
.
sora
-
gallery
-
card
-
image
{
transform
:
scale
(
1.05
);
}
.
sora
-
gallery
-
card
-
placeholder
{
display
:
flex
;
align
-
items
:
center
;
justify
-
content
:
center
;
font
-
size
:
32
px
;
}
/* 渐变背景 */
.
gradient
-
bg
-
1
{
background
:
linear
-
gradient
(
135
deg
,
#
667
eea
0
%
,
#
764
ba2
100
%
);
}
.
gradient
-
bg
-
2
{
background
:
linear
-
gradient
(
135
deg
,
#
f093fb
0
%
,
#
f5576c
100
%
);
}
.
gradient
-
bg
-
3
{
background
:
linear
-
gradient
(
135
deg
,
#
4
facfe
0
%
,
#
00
f2fe
100
%
);
}
.
gradient
-
bg
-
4
{
background
:
linear
-
gradient
(
135
deg
,
#
43
e97b
0
%
,
#
38
f9d7
100
%
);
}
.
gradient
-
bg
-
5
{
background
:
linear
-
gradient
(
135
deg
,
#
fa709a
0
%
,
#
fee140
100
%
);
}
.
gradient
-
bg
-
6
{
background
:
linear
-
gradient
(
135
deg
,
#
a18cd1
0
%
,
#
fbc2eb
100
%
);
}
.
gradient
-
bg
-
7
{
background
:
linear
-
gradient
(
135
deg
,
#
fccb90
0
%
,
#
d57eeb
100
%
);
}
.
gradient
-
bg
-
8
{
background
:
linear
-
gradient
(
135
deg
,
#
e0c3fc
0
%
,
#
8
ec5fc
100
%
);
}
/* 类型角标 */
.
sora
-
gallery
-
card
-
badge
{
position
:
absolute
;
top
:
8
px
;
left
:
8
px
;
padding
:
3
px
8
px
;
border
-
radius
:
var
(
--
sora
-
radius
-
sm
,
8
px
);
font
-
size
:
10
px
;
font
-
weight
:
600
;
text
-
transform
:
uppercase
;
letter
-
spacing
:
0.05
em
;
backdrop
-
filter
:
blur
(
8
px
);
}
.
sora
-
gallery
-
card
-
badge
.
video
{
background
:
rgba
(
20
,
184
,
166
,
0.8
);
color
:
white
;
}
.
sora
-
gallery
-
card
-
badge
.
image
{
background
:
rgba
(
16
,
185
,
129
,
0.8
);
color
:
white
;
}
/* Hover 操作层 */
.
sora
-
gallery
-
card
-
overlay
{
position
:
absolute
;
inset
:
0
;
background
:
rgba
(
0
,
0
,
0
,
0.6
);
display
:
flex
;
align
-
items
:
center
;
justify
-
content
:
center
;
gap
:
12
px
;
opacity
:
0
;
transition
:
opacity
150
ms
ease
;
}
.
sora
-
gallery
-
card
:
hover
.
sora
-
gallery
-
card
-
overlay
{
opacity
:
1
;
}
.
sora
-
gallery
-
card
-
action
{
width
:
40
px
;
height
:
40
px
;
border
-
radius
:
50
%
;
background
:
rgba
(
255
,
255
,
255
,
0.15
);
backdrop
-
filter
:
blur
(
8
px
);
display
:
flex
;
align
-
items
:
center
;
justify
-
content
:
center
;
font
-
size
:
16
px
;
color
:
white
;
border
:
none
;
cursor
:
pointer
;
transition
:
all
150
ms
ease
;
}
.
sora
-
gallery
-
card
-
action
:
hover
{
background
:
rgba
(
255
,
255
,
255
,
0.25
);
transform
:
scale
(
1.1
);
}
/* 播放指示 */
.
sora
-
gallery
-
card
-
play
{
position
:
absolute
;
top
:
50
%
;
left
:
50
%
;
transform
:
translate
(
-
50
%
,
-
50
%
);
width
:
48
px
;
height
:
48
px
;
border
-
radius
:
50
%
;
background
:
rgba
(
255
,
255
,
255
,
0.2
);
backdrop
-
filter
:
blur
(
8
px
);
display
:
flex
;
align
-
items
:
center
;
justify
-
content
:
center
;
font
-
size
:
20
px
;
color
:
white
;
opacity
:
0
;
transition
:
all
150
ms
ease
;
pointer
-
events
:
none
;
}
.
sora
-
gallery
-
card
:
hover
.
sora
-
gallery
-
card
-
play
{
opacity
:
1
;
}
/* 视频时长 */
.
sora
-
gallery
-
card
-
duration
{
position
:
absolute
;
bottom
:
8
px
;
right
:
8
px
;
padding
:
2
px
6
px
;
border
-
radius
:
4
px
;
background
:
rgba
(
0
,
0
,
0
,
0.7
);
font
-
size
:
11
px
;
font
-
family
:
"
SF Mono
"
,
"
Fira Code
"
,
monospace
;
color
:
white
;
}
/* 卡片信息 */
.
sora
-
gallery
-
card
-
info
{
padding
:
12
px
;
}
.
sora
-
gallery
-
card
-
model
{
font
-
size
:
11
px
;
font
-
family
:
"
SF Mono
"
,
"
Fira Code
"
,
monospace
;
color
:
var
(
--
sora
-
text
-
tertiary
,
#
666
);
margin
-
bottom
:
4
px
;
}
.
sora
-
gallery
-
card
-
time
{
font
-
size
:
12
px
;
color
:
var
(
--
sora
-
text
-
muted
,
#
4
A4A4A
);
}
/* 空状态 */
.
sora
-
gallery
-
empty
{
display
:
flex
;
flex
-
direction
:
column
;
align
-
items
:
center
;
justify
-
content
:
center
;
padding
:
120
px
40
px
;
text
-
align
:
center
;
}
.
sora
-
gallery
-
empty
-
icon
{
font
-
size
:
64
px
;
margin
-
bottom
:
24
px
;
opacity
:
0.3
;
}
.
sora
-
gallery
-
empty
-
title
{
font
-
size
:
20
px
;
font
-
weight
:
600
;
margin
-
bottom
:
8
px
;
color
:
var
(
--
sora
-
text
-
secondary
,
#
A0A0A0
);
}
.
sora
-
gallery
-
empty
-
desc
{
font
-
size
:
14
px
;
color
:
var
(
--
sora
-
text
-
tertiary
,
#
666
);
max
-
width
:
360
px
;
line
-
height
:
1.6
;
}
.
sora
-
gallery
-
empty
-
btn
{
margin
-
top
:
24
px
;
padding
:
10
px
28
px
;
background
:
var
(
--
sora
-
accent
-
gradient
,
linear
-
gradient
(
135
deg
,
#
14
b8a6
,
#
0
d9488
));
border
-
radius
:
var
(
--
sora
-
radius
-
full
,
9999
px
);
font
-
size
:
14
px
;
font
-
weight
:
500
;
color
:
white
;
border
:
none
;
cursor
:
pointer
;
transition
:
all
150
ms
ease
;
}
.
sora
-
gallery
-
empty
-
btn
:
hover
{
box
-
shadow
:
var
(
--
sora
-
shadow
-
glow
,
0
0
20
px
rgba
(
20
,
184
,
166
,
0.3
));
}
/* 加载更多 */
.
sora
-
gallery
-
load
-
more
{
display
:
flex
;
justify
-
content
:
center
;
margin
-
top
:
24
px
;
}
.
sora
-
gallery
-
load
-
more
-
btn
{
padding
:
10
px
28
px
;
background
:
var
(
--
sora
-
bg
-
secondary
,
#
1
A1A1A
);
border
:
1
px
solid
var
(
--
sora
-
border
-
color
,
#
2
A2A2A
);
border
-
radius
:
var
(
--
sora
-
radius
-
full
,
9999
px
);
font
-
size
:
13
px
;
color
:
var
(
--
sora
-
text
-
secondary
,
#
A0A0A0
);
cursor
:
pointer
;
transition
:
all
150
ms
ease
;
}
.
sora
-
gallery
-
load
-
more
-
btn
:
hover
{
background
:
var
(
--
sora
-
bg
-
tertiary
,
#
242424
);
color
:
var
(
--
sora
-
text
-
primary
,
#
FFF
);
}
.
sora
-
gallery
-
load
-
more
-
btn
:
disabled
{
opacity
:
0.5
;
cursor
:
not
-
allowed
;
}
/* 响应式 */
@
media
(
max
-
width
:
1200
px
)
{
.
sora
-
gallery
-
grid
{
grid
-
template
-
columns
:
repeat
(
3
,
1
fr
);
}
}
@
media
(
max
-
width
:
900
px
)
{
.
sora
-
gallery
-
grid
{
grid
-
template
-
columns
:
repeat
(
2
,
1
fr
);
}
}
@
media
(
max
-
width
:
600
px
)
{
.
sora
-
gallery
-
page
{
padding
:
16
px
;
}
.
sora
-
gallery
-
grid
{
grid
-
template
-
columns
:
1
fr
;
}
}
<
/style
>
frontend/src/components/sora/SoraMediaPreview.vue
deleted
100644 → 0
View file @
dbb248df
<
template
>
<Teleport
to=
"body"
>
<Transition
name=
"sora-modal"
>
<div
v-if=
"visible && generation"
class=
"sora-preview-overlay"
@
keydown.esc=
"emit('close')"
>
<!-- 背景遮罩 -->
<div
class=
"sora-preview-backdrop"
@
click=
"emit('close')"
/>
<!-- 内容区 -->
<div
class=
"sora-preview-modal"
>
<!-- 顶部栏 -->
<div
class=
"sora-preview-header"
>
<h3
class=
"sora-preview-title"
>
{{
t
(
'
sora.previewTitle
'
)
}}
</h3>
<button
class=
"sora-preview-close"
@
click=
"emit('close')"
>
<svg
width=
"20"
height=
"20"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
d=
"M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- 媒体区 -->
<div
class=
"sora-preview-media-area"
>
<video
v-if=
"generation.media_type === 'video'"
:src=
"generation.media_url"
class=
"sora-preview-media"
controls
autoplay
/>
<img
v-else
:src=
"generation.media_url"
class=
"sora-preview-media"
alt=
""
/>
</div>
<!-- 详情 + 操作 -->
<div
class=
"sora-preview-footer"
>
<!-- 模型 + 时间 -->
<div
class=
"sora-preview-meta"
>
<span
class=
"sora-preview-model-tag"
>
{{
generation
.
model
}}
</span>
<span>
{{
formatDateTime
(
generation
.
created_at
)
}}
</span>
</div>
<!-- 提示词 -->
<p
class=
"sora-preview-prompt"
>
{{
generation
.
prompt
}}
</p>
<!-- 操作按钮 -->
<div
class=
"sora-preview-actions"
>
<button
v-if=
"generation.storage_type === 'upstream'"
class=
"sora-preview-btn primary"
@
click=
"emit('save', generation.id)"
>
☁️
{{
t
(
'
sora.save
'
)
}}
</button>
<a
v-if=
"generation.media_url"
:href=
"generation.media_url"
target=
"_blank"
download
class=
"sora-preview-btn secondary"
@
click=
"emit('download', generation.media_url)"
>
📥
{{
t
(
'
sora.download
'
)
}}
</a>
<button
class=
"sora-preview-btn ghost"
@
click=
"emit('close')"
>
{{
t
(
'
sora.closePreview
'
)
}}
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
type
{
SoraGeneration
}
from
'
@/api/sora
'
defineProps
<
{
visible
:
boolean
generation
:
SoraGeneration
|
null
}
>
()
const
emit
=
defineEmits
<
{
close
:
[]
save
:
[
id
:
number
]
download
:
[
url
:
string
]
}
>
()
const
{
t
}
=
useI18n
()
function
formatDateTime
(
iso
:
string
):
string
{
return
new
Date
(
iso
).
toLocaleString
()
}
function
handleKeydown
(
e
:
KeyboardEvent
)
{
if
(
e
.
key
===
'
Escape
'
)
emit
(
'
close
'
)
}
onMounted
(()
=>
document
.
addEventListener
(
'
keydown
'
,
handleKeydown
))
onUnmounted
(()
=>
document
.
removeEventListener
(
'
keydown
'
,
handleKeydown
))
</
script
>
<
style
scoped
>
.sora-preview-overlay
{
position
:
fixed
;
inset
:
0
;
z-index
:
50
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
}
.sora-preview-backdrop
{
position
:
absolute
;
inset
:
0
;
background
:
var
(
--sora-modal-backdrop
,
rgba
(
0
,
0
,
0
,
0.4
));
backdrop-filter
:
blur
(
4px
);
}
.sora-preview-modal
{
position
:
relative
;
z-index
:
10
;
display
:
flex
;
flex-direction
:
column
;
max-height
:
90vh
;
max-width
:
90vw
;
overflow
:
hidden
;
border-radius
:
20px
;
background
:
var
(
--sora-bg-secondary
,
#FFF
);
border
:
1px
solid
var
(
--sora-border-color
,
#E5E7EB
);
box-shadow
:
0
8px
32px
rgba
(
0
,
0
,
0
,
0.5
);
animation
:
sora-modal-in
0.3s
ease
;
}
@keyframes
sora-modal-in
{
from
{
transform
:
scale
(
0.95
);
opacity
:
0
;
}
to
{
transform
:
scale
(
1
);
opacity
:
1
;
}
}
.sora-preview-header
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
padding
:
16px
20px
;
border-bottom
:
1px
solid
var
(
--sora-border-color
,
#E5E7EB
);
}
.sora-preview-title
{
font-size
:
14px
;
font-weight
:
500
;
color
:
var
(
--sora-text-primary
,
#111827
);
}
.sora-preview-close
{
padding
:
6px
;
border-radius
:
8px
;
color
:
var
(
--sora-text-tertiary
,
#9CA3AF
);
background
:
none
;
border
:
none
;
cursor
:
pointer
;
transition
:
all
150ms
ease
;
}
.sora-preview-close
:hover
{
background
:
var
(
--sora-bg-tertiary
,
#F3F4F6
);
color
:
var
(
--sora-text-secondary
,
#6B7280
);
}
.sora-preview-media-area
{
flex
:
1
;
overflow
:
auto
;
background
:
var
(
--sora-bg-primary
,
#F9FAFB
);
padding
:
8px
;
}
.sora-preview-media
{
max-height
:
70vh
;
width
:
100%
;
border-radius
:
8px
;
object-fit
:
contain
;
}
.sora-preview-footer
{
padding
:
16px
20px
;
border-top
:
1px
solid
var
(
--sora-border-color
,
#E5E7EB
);
}
.sora-preview-meta
{
display
:
flex
;
align-items
:
center
;
gap
:
12px
;
font-size
:
12px
;
color
:
var
(
--sora-text-tertiary
,
#9CA3AF
);
margin-bottom
:
8px
;
}
.sora-preview-model-tag
{
padding
:
2px
8px
;
background
:
var
(
--sora-bg-tertiary
,
#F3F4F6
);
border-radius
:
9999px
;
font-family
:
"SF Mono"
,
"Fira Code"
,
monospace
;
font-size
:
11px
;
color
:
var
(
--sora-text-secondary
,
#6B7280
);
}
.sora-preview-prompt
{
font-size
:
13px
;
color
:
var
(
--sora-text-secondary
,
#6B7280
);
line-height
:
1.5
;
margin-bottom
:
16px
;
display
:
-webkit-box
;
-webkit-line-clamp
:
3
;
-webkit-box-orient
:
vertical
;
overflow
:
hidden
;
}
.sora-preview-actions
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
}
.sora-preview-btn
{
padding
:
8px
16px
;
border-radius
:
9999px
;
font-size
:
13px
;
font-weight
:
500
;
border
:
none
;
cursor
:
pointer
;
transition
:
all
150ms
ease
;
text-decoration
:
none
;
display
:
inline-flex
;
align-items
:
center
;
gap
:
4px
;
}
.sora-preview-btn.primary
{
background
:
var
(
--sora-accent-gradient
);
color
:
white
;
}
.sora-preview-btn.primary
:hover
{
box-shadow
:
var
(
--sora-shadow-glow
);
}
.sora-preview-btn.secondary
{
background
:
var
(
--sora-bg-tertiary
,
#F3F4F6
);
color
:
var
(
--sora-text-secondary
,
#6B7280
);
}
.sora-preview-btn.secondary
:hover
{
background
:
var
(
--sora-bg-hover
,
#E5E7EB
);
color
:
var
(
--sora-text-primary
,
#111827
);
}
.sora-preview-btn.ghost
{
background
:
transparent
;
color
:
var
(
--sora-text-tertiary
,
#9CA3AF
);
margin-left
:
auto
;
}
.sora-preview-btn.ghost
:hover
{
color
:
var
(
--sora-text-secondary
,
#6B7280
);
}
/* 过渡动画 */
.sora-modal-enter-active
,
.sora-modal-leave-active
{
transition
:
opacity
0.2s
ease
;
}
.sora-modal-enter-from
,
.sora-modal-leave-to
{
opacity
:
0
;
}
</
style
>
frontend/src/components/sora/SoraNoStorageWarning.vue
deleted
100644 → 0
View file @
dbb248df
<
template
>
<div
class=
"sora-no-storage-warning"
>
<span>
⚠️
</span>
<div>
<p
class=
"sora-no-storage-title"
>
{{
t
(
'
sora.noStorageWarningTitle
'
)
}}
</p>
<p
class=
"sora-no-storage-desc"
>
{{
t
(
'
sora.noStorageWarningDesc
'
)
}}
</p>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
useI18n
}
from
'
vue-i18n
'
const
{
t
}
=
useI18n
()
</
script
>
<
style
scoped
>
.sora-no-storage-warning
{
display
:
flex
;
align-items
:
flex-start
;
gap
:
10px
;
padding
:
14px
20px
;
background
:
rgba
(
245
,
158
,
11
,
0.08
);
border
:
1px
solid
rgba
(
245
,
158
,
11
,
0.2
);
border-radius
:
12px
;
font-size
:
13px
;
}
.sora-no-storage-title
{
font-weight
:
600
;
color
:
var
(
--sora-warning
,
#F59E0B
);
margin-bottom
:
4px
;
}
.sora-no-storage-desc
{
color
:
var
(
--sora-text-secondary
,
#A0A0A0
);
line-height
:
1.5
;
}
</
style
>
Prev
1
2
3
4
5
6
7
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment