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
eb2dce92
Commit
eb2dce92
authored
Apr 06, 2026
by
陈曦
Browse files
升级v1.0.8 解决冲突
parents
7b83d6e7
339d906e
Changes
178
Hide whitespace changes
Inline
Side-by-side
frontend/src/components/account/AccountTestModal.vue
View file @
eb2dce92
...
...
@@ -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 @
eb2dce92
...
...
@@ -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
"
...
...
@@ -2557,7 +2478,6 @@
@
validate
-
refresh
-
token
=
"
handleValidateRefreshToken
"
@
validate
-
mobile
-
refresh
-
token
=
"
handleOpenAIValidateMobileRT
"
@
validate
-
session
-
token
=
"
handleValidateSessionToken
"
@
import
-
access
-
token
=
"
handleImportAccessToken
"
/>
<
/div
>
...
...
@@ -2943,7 +2863,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 +2871,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
'
)
}
)
...
...
@@ -2978,36 +2898,34 @@ 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
openaiOAuth
=
useOpenAIOAuth
()
// For OpenAI 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 +2983,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 +3194,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 +3237,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 +3264,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 +3299,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 +3311,7 @@ watch(
// Reset OAuth states
oauth
.
resetState
()
openaiOAuth
.
resetState
()
soraOAuth
.
resetState
()
geminiOAuth
.
resetState
()
antigravityOAuth
.
resetState
()
}
...
...
@@ -3816,7 +3721,6 @@ const resetForm = () => {
geminiTierAIStudio
.
value
=
'
aistudio_free
'
oauth
.
resetState
()
openaiOAuth
.
resetState
()
soraOAuth
.
resetState
()
geminiOAuth
.
resetState
()
antigravityOAuth
.
resetState
()
oauthFlowRef
.
value
?.
reset
()
...
...
@@ -3877,29 +3781,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 +3945,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 +4002,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 +4025,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 +4099,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 +4125,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 +4139,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 +4156,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 +4170,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 +4195,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 +4230,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 +4247,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 +4284,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 +4555,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 @
eb2dce92
...
...
@@ -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 @
eb2dce92
...
...
@@ -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,6 @@
import
{
ref
,
computed
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
{
parseSoraRawTokens
}
from
'
@/utils/soraTokenParser
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
type
{
AddMethod
,
AuthInputMethod
}
from
'
@/composables/useAccountOAuth
'
import
type
{
AccountPlatform
}
from
'
@/types
'
...
...
@@ -771,8 +559,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 +596,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 +619,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
''
}
)
...
...
@@ -842,7 +630,6 @@ const authCodeInput = ref('')
const
sessionKeyInput
=
ref
(
''
)
const
refreshTokenInput
=
ref
(
''
)
const
sessionTokenInput
=
ref
(
''
)
const
accessTokenInput
=
ref
(
''
)
const
showHelpDialog
=
ref
(
false
)
const
oauthState
=
ref
(
''
)
const
projectId
=
ref
(
''
)
...
...
@@ -869,33 +656,6 @@ 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
parsedAccessTokenFromSessionInputCount
=
computed
(()
=>
{
return
parsedSoraRawTokens
.
value
.
accessTokens
.
length
}
)
const
parsedAccessTokensText
=
computed
(()
=>
{
return
parsedSoraRawTokens
.
value
.
accessTokens
.
join
(
'
\n
'
)
}
)
const
soraSessionUrl
=
'
https://sora.chatgpt.com/api/auth/session
'
const
parsedAccessTokenCount
=
computed
(()
=>
{
return
accessTokenInput
.
value
.
split
(
'
\n
'
)
.
map
((
at
)
=>
at
.
trim
())
.
filter
((
at
)
=>
at
).
length
}
)
// Watchers
watch
(
inputMethod
,
(
newVal
)
=>
{
emit
(
'
update:inputMethod
'
,
newVal
)
...
...
@@ -904,7 +664,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 +674,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 +685,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
)
{
...
...
@@ -967,26 +727,6 @@ const handleValidateRefreshToken = () => {
}
}
const
handleValidateSessionToken
=
()
=>
{
if
(
parsedSessionTokenCount
.
value
>
0
)
{
emit
(
'
validate-session-token
'
,
parsedSessionTokensText
.
value
)
}
}
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
())
}
}
// Expose methods and state
defineExpose
({
authCode
:
authCodeInput
,
...
...
frontend/src/components/account/ReAuthAccountModal.vue
View file @
eb2dce92
...
...
@@ -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"
...
...
@@ -226,8 +224,7 @@ const { t } = useI18n()
// OAuth composables
const
claudeOAuth
=
useAccountOAuth
()
const
openaiOAuth
=
useOpenAIOAuth
({
platform
:
'
openai
'
})
const
soraOAuth
=
useOpenAIOAuth
({
platform
:
'
sora
'
})
const
openaiOAuth
=
useOpenAIOAuth
()
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 @
eb2dce92
...
...
@@ -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 @
eb2dce92
...
...
@@ -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 @
eb2dce92
...
...
@@ -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"
...
...
@@ -226,8 +224,7 @@ const { t } = useI18n()
// OAuth composables
const
claudeOAuth
=
useAccountOAuth
()
const
openaiOAuth
=
useOpenAIOAuth
({
platform
:
'
openai
'
})
const
soraOAuth
=
useOpenAIOAuth
({
platform
:
'
sora
'
})
const
openaiOAuth
=
useOpenAIOAuth
()
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 @
eb2dce92
...
...
@@ -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 @
eb2dce92
...
...
@@ -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 @
eb2dce92
...
...
@@ -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 @
eb2dce92
...
...
@@ -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 @
eb2dce92
...
...
@@ -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 @
eb2dce92
...
...
@@ -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/layout/AppSidebar.vue
View file @
eb2dce92
...
...
@@ -467,21 +467,6 @@ const ChevronDoubleLeftIcon = {
)
}
const
SoraIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z
'
})
]
)
}
const
ChevronDoubleRightIcon
=
{
render
:
()
=>
h
(
...
...
@@ -504,9 +489,6 @@ const userNavItems = computed((): NavItem[] => {
{
path
:
'
/keys
'
,
label
:
t
(
'
nav.apiKeys
'
),
icon
:
KeyIcon
},
{
path
:
'
/usage
'
,
label
:
t
(
'
nav.usage
'
),
icon
:
ChartIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/subscriptions
'
,
label
:
t
(
'
nav.mySubscriptions
'
),
icon
:
CreditCardIcon
,
hideInSimpleMode
:
true
},
...(
appStore
.
cachedPublicSettings
?.
sora_client_enabled
?
[{
path
:
'
/sora
'
,
label
:
t
(
'
nav.sora
'
),
icon
:
SoraIcon
}]
:
[]),
...(
appStore
.
cachedPublicSettings
?.
purchase_subscription_enabled
?
[
{
...
...
@@ -535,9 +517,6 @@ const personalNavItems = computed((): NavItem[] => {
{
path
:
'
/keys
'
,
label
:
t
(
'
nav.apiKeys
'
),
icon
:
KeyIcon
},
{
path
:
'
/usage
'
,
label
:
t
(
'
nav.usage
'
),
icon
:
ChartIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/subscriptions
'
,
label
:
t
(
'
nav.mySubscriptions
'
),
icon
:
CreditCardIcon
,
hideInSimpleMode
:
true
},
...(
appStore
.
cachedPublicSettings
?.
sora_client_enabled
?
[{
path
:
'
/sora
'
,
label
:
t
(
'
nav.sora
'
),
icon
:
SoraIcon
}]
:
[]),
...(
appStore
.
cachedPublicSettings
?.
purchase_subscription_enabled
?
[
{
...
...
frontend/src/components/sora/SoraDownloadDialog.vue
deleted
100644 → 0
View file @
7b83d6e7
<
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 @
7b83d6e7
<
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 @
7b83d6e7
<
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 @
7b83d6e7
<
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 @
7b83d6e7
<
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
…
4
5
6
7
8
9
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