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/sora/SoraProgressCard.vue
deleted
100644 → 0
View file @
7b83d6e7
<
template
>
<div
class=
"sora-task-card"
:class=
"
{
cancelled: generation.status === 'cancelled',
'countdown-warning': isUpstream
&&
!isExpired
&&
remainingMs
<
=
2
*
60
*
1000
}"
>
<!-- 头部:状态 + 模型 + 取消按钮 -->
<div
class=
"sora-task-header"
>
<div
class=
"sora-task-status"
>
<span
class=
"sora-status-dot"
:class=
"statusDotClass"
/>
<span
class=
"sora-status-label"
:class=
"statusLabelClass"
>
{{
statusText
}}
</span>
</div>
<div
class=
"sora-task-header-right"
>
<span
class=
"sora-model-tag"
>
{{
generation
.
model
}}
</span>
<button
v-if=
"generation.status === 'pending' || generation.status === 'generating'"
class=
"sora-cancel-btn"
@
click=
"emit('cancel', generation.id)"
>
✕
{{
t
(
'
sora.cancel
'
)
}}
</button>
</div>
</div>
<!-- 提示词 -->
<div
class=
"sora-task-prompt"
:class=
"
{ 'line-through': generation.status === 'cancelled' }">
{{
generation
.
prompt
}}
</div>
<!-- 错误分类(失败时) -->
<div
v-if=
"generation.status === 'failed' && generation.error_message"
class=
"sora-task-error-category"
>
⛔
{{
t
(
'
sora.errorCategory
'
)
}}
</div>
<div
v-if=
"generation.status === 'failed' && generation.error_message"
class=
"sora-task-error-message"
>
{{
generation
.
error_message
}}
</div>
<!-- 进度条(排队/生成/失败时) -->
<div
v-if=
"showProgress"
class=
"sora-task-progress-wrapper"
>
<div
class=
"sora-task-progress-bar"
>
<div
class=
"sora-task-progress-fill"
:class=
"progressFillClass"
:style=
"
{ width: progressWidth }"
/>
</div>
<div
v-if=
"generation.status !== 'failed'"
class=
"sora-task-progress-info"
>
<span>
{{
progressInfoText
}}
</span>
<span>
{{
progressInfoRight
}}
</span>
</div>
</div>
<!-- 完成预览区 -->
<div
v-if=
"generation.status === 'completed' && generation.media_url"
class=
"sora-task-preview"
>
<video
v-if=
"generation.media_type === 'video'"
:src=
"generation.media_url"
class=
"sora-task-preview-media"
muted
loop
@
mouseenter=
"($event.target as HTMLVideoElement).play()"
@
mouseleave=
"($event.target as HTMLVideoElement).pause()"
/>
<img
v-else
:src=
"generation.media_url"
class=
"sora-task-preview-media"
alt=
""
/>
</div>
<!-- 完成占位预览(无 media_url 时) -->
<div
v-else-if=
"generation.status === 'completed' && !generation.media_url"
class=
"sora-task-preview"
>
<div
class=
"sora-task-preview-placeholder"
>
🎨
</div>
</div>
<!-- 操作按钮 -->
<div
v-if=
"showActions"
class=
"sora-task-actions"
>
<!-- 已完成 -->
<template
v-if=
"generation.status === 'completed'"
>
<!-- 已保存标签 -->
<span
v-if=
"generation.storage_type !== 'upstream'"
class=
"sora-saved-badge"
>
✓
{{
t
(
'
sora.savedToCloud
'
)
}}
</span>
<!-- 保存到存储按钮(upstream 时) -->
<button
v-if=
"generation.storage_type === 'upstream'"
class=
"sora-action-btn save-storage"
@
click=
"emit('save', generation.id)"
>
☁️
{{
t
(
'
sora.save
'
)
}}
</button>
<!-- 本地下载 -->
<a
v-if=
"generation.media_url"
:href=
"generation.media_url"
target=
"_blank"
download
class=
"sora-action-btn primary"
>
📥
{{
t
(
'
sora.downloadLocal
'
)
}}
</a>
<!-- 倒计时文本(upstream) -->
<span
v-if=
"isUpstream && !isExpired"
class=
"sora-countdown-text"
>
⏱
{{
t
(
'
sora.upstreamCountdown
'
,
{
time
:
countdownText
}
)
}}
{{
t
(
'
sora.canDownload
'
)
}}
<
/span
>
<
span
v
-
if
=
"
isUpstream && isExpired
"
class
=
"
sora-countdown-text expired
"
>
{{
t
(
'
sora.upstreamExpired
'
)
}}
<
/span
>
<
/template
>
<!--
失败
/
取消
-->
<
template
v
-
if
=
"
generation.status === 'failed' || generation.status === 'cancelled'
"
>
<
button
class
=
"
sora-action-btn primary
"
@
click
=
"
emit('retry', generation)
"
>
🔄
{{
generation
.
status
===
'
cancelled
'
?
t
(
'
sora.regenrate
'
)
:
t
(
'
sora.retry
'
)
}}
<
/button
>
<
button
class
=
"
sora-action-btn secondary
"
@
click
=
"
emit('delete', generation.id)
"
>
🗑
{{
t
(
'
sora.delete
'
)
}}
<
/button
>
<
/template
>
<
/div
>
<!--
倒计时进度条
(
upstream
已完成
)
-->
<
div
v
-
if
=
"
isUpstream && !isExpired && generation.status === 'completed'
"
class
=
"
sora-countdown-bar-wrapper
"
>
<
div
class
=
"
sora-countdown-bar
"
>
<
div
class
=
"
sora-countdown-bar-fill
"
:
style
=
"
{ width: countdownPercent + '%'
}
"
/>
<
/div
>
<
/div
>
<
/div
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
computed
,
ref
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
type
{
SoraGeneration
}
from
'
@/api/sora
'
const
props
=
defineProps
<
{
generation
:
SoraGeneration
}
>
()
const
emit
=
defineEmits
<
{
cancel
:
[
id
:
number
]
delete
:
[
id
:
number
]
save
:
[
id
:
number
]
retry
:
[
gen
:
SoraGeneration
]
}
>
()
const
{
t
}
=
useI18n
()
// ==================== 状态样式 ====================
const
statusDotClass
=
computed
(()
=>
{
const
s
=
props
.
generation
.
status
return
{
queued
:
s
===
'
pending
'
,
generating
:
s
===
'
generating
'
,
completed
:
s
===
'
completed
'
,
failed
:
s
===
'
failed
'
,
cancelled
:
s
===
'
cancelled
'
}
}
)
const
statusLabelClass
=
computed
(()
=>
statusDotClass
.
value
)
const
statusText
=
computed
(()
=>
{
const
map
:
Record
<
string
,
string
>
=
{
pending
:
t
(
'
sora.statusPending
'
),
generating
:
t
(
'
sora.statusGenerating
'
),
completed
:
t
(
'
sora.statusCompleted
'
),
failed
:
t
(
'
sora.statusFailed
'
),
cancelled
:
t
(
'
sora.statusCancelled
'
)
}
return
map
[
props
.
generation
.
status
]
||
props
.
generation
.
status
}
)
// ==================== 进度条 ====================
const
showProgress
=
computed
(()
=>
{
const
s
=
props
.
generation
.
status
return
s
===
'
pending
'
||
s
===
'
generating
'
||
s
===
'
failed
'
}
)
const
progressFillClass
=
computed
(()
=>
{
const
s
=
props
.
generation
.
status
return
{
generating
:
s
===
'
pending
'
||
s
===
'
generating
'
,
completed
:
s
===
'
completed
'
,
failed
:
s
===
'
failed
'
}
}
)
const
progressWidth
=
computed
(()
=>
{
const
s
=
props
.
generation
.
status
if
(
s
===
'
failed
'
)
return
'
100%
'
if
(
s
===
'
pending
'
)
return
'
0%
'
if
(
s
===
'
generating
'
)
{
// 根据创建时间估算进度
const
created
=
new
Date
(
props
.
generation
.
created_at
).
getTime
()
const
elapsed
=
Date
.
now
()
-
created
// 假设平均 10 分钟完成,最多到 95%
const
progress
=
Math
.
min
(
95
,
(
elapsed
/
(
10
*
60
*
1000
))
*
100
)
return
`${Math.round(progress)
}
%`
}
return
'
100%
'
}
)
const
progressInfoText
=
computed
(()
=>
{
const
s
=
props
.
generation
.
status
if
(
s
===
'
pending
'
)
return
t
(
'
sora.queueWaiting
'
)
if
(
s
===
'
generating
'
)
{
const
created
=
new
Date
(
props
.
generation
.
created_at
).
getTime
()
const
elapsed
=
Date
.
now
()
-
created
return
`${t('sora.waited')
}
${formatElapsed(elapsed)
}
`
}
return
''
}
)
const
progressInfoRight
=
computed
(()
=>
{
const
s
=
props
.
generation
.
status
if
(
s
===
'
pending
'
)
return
t
(
'
sora.waiting
'
)
return
''
}
)
function
formatElapsed
(
ms
:
number
):
string
{
const
s
=
Math
.
floor
(
ms
/
1000
)
const
m
=
Math
.
floor
(
s
/
60
)
const
sec
=
s
%
60
return
`${m
}
:${sec.toString().padStart(2, '0')
}
`
}
// ==================== 操作按钮 ====================
const
showActions
=
computed
(()
=>
{
const
s
=
props
.
generation
.
status
return
s
===
'
completed
'
||
s
===
'
failed
'
||
s
===
'
cancelled
'
}
)
// ==================== Upstream 倒计时 ====================
const
UPSTREAM_TTL
=
15
*
60
*
1000
const
now
=
ref
(
Date
.
now
())
let
countdownTimer
:
ReturnType
<
typeof
setInterval
>
|
null
=
null
const
isUpstream
=
computed
(()
=>
props
.
generation
.
status
===
'
completed
'
&&
props
.
generation
.
storage_type
===
'
upstream
'
)
const
expireTime
=
computed
(()
=>
{
if
(
!
props
.
generation
.
completed_at
)
return
0
return
new
Date
(
props
.
generation
.
completed_at
).
getTime
()
+
UPSTREAM_TTL
}
)
const
remainingMs
=
computed
(()
=>
Math
.
max
(
0
,
expireTime
.
value
-
now
.
value
))
const
isExpired
=
computed
(()
=>
remainingMs
.
value
<=
0
)
const
countdownPercent
=
computed
(()
=>
{
if
(
isExpired
.
value
)
return
0
return
Math
.
round
((
remainingMs
.
value
/
UPSTREAM_TTL
)
*
100
)
}
)
const
countdownText
=
computed
(()
=>
{
const
totalSec
=
Math
.
ceil
(
remainingMs
.
value
/
1000
)
const
m
=
Math
.
floor
(
totalSec
/
60
)
const
s
=
totalSec
%
60
return
`${m
}
:${s.toString().padStart(2, '0')
}
`
}
)
onMounted
(()
=>
{
if
(
isUpstream
.
value
)
{
countdownTimer
=
setInterval
(()
=>
{
now
.
value
=
Date
.
now
()
if
(
now
.
value
>=
expireTime
.
value
&&
countdownTimer
)
{
clearInterval
(
countdownTimer
)
countdownTimer
=
null
}
}
,
1000
)
}
}
)
onUnmounted
(()
=>
{
if
(
countdownTimer
)
{
clearInterval
(
countdownTimer
)
countdownTimer
=
null
}
}
)
<
/script
>
<
style
scoped
>
.
sora
-
task
-
card
{
background
:
var
(
--
sora
-
bg
-
secondary
,
#
1
A1A1A
);
border
:
1
px
solid
var
(
--
sora
-
border
-
color
,
#
2
A2A2A
);
border
-
radius
:
var
(
--
sora
-
radius
-
lg
,
16
px
);
padding
:
24
px
;
transition
:
all
250
ms
ease
;
animation
:
sora
-
fade
-
in
0.4
s
ease
;
}
.
sora
-
task
-
card
:
hover
{
border
-
color
:
var
(
--
sora
-
bg
-
hover
,
#
333
);
}
.
sora
-
task
-
card
.
cancelled
{
opacity
:
0.6
;
border
-
color
:
var
(
--
sora
-
border
-
subtle
,
#
1
F1F1F
);
}
.
sora
-
task
-
card
.
countdown
-
warning
{
border
-
color
:
var
(
--
sora
-
error
,
#
EF4444
)
!
important
;
box
-
shadow
:
0
0
12
px
rgba
(
239
,
68
,
68
,
0.15
);
}
@
keyframes
sora
-
fade
-
in
{
from
{
opacity
:
0
;
transform
:
translateY
(
8
px
);
}
to
{
opacity
:
1
;
transform
:
translateY
(
0
);
}
}
/* 头部 */
.
sora
-
task
-
header
{
display
:
flex
;
align
-
items
:
center
;
justify
-
content
:
space
-
between
;
margin
-
bottom
:
16
px
;
}
.
sora
-
task
-
status
{
display
:
flex
;
align
-
items
:
center
;
gap
:
8
px
;
font
-
size
:
13
px
;
font
-
weight
:
500
;
}
.
sora
-
task
-
header
-
right
{
display
:
flex
;
align
-
items
:
center
;
gap
:
8
px
;
}
/* 状态指示点 */
.
sora
-
status
-
dot
{
width
:
8
px
;
height
:
8
px
;
border
-
radius
:
50
%
;
}
.
sora
-
status
-
dot
.
queued
{
background
:
var
(
--
sora
-
text
-
tertiary
,
#
666
);
}
.
sora
-
status
-
dot
.
generating
{
background
:
var
(
--
sora
-
warning
,
#
F59E0B
);
animation
:
sora
-
pulse
-
dot
1.5
s
ease
-
in
-
out
infinite
;
}
.
sora
-
status
-
dot
.
completed
{
background
:
var
(
--
sora
-
success
,
#
10
B981
);
}
.
sora
-
status
-
dot
.
failed
{
background
:
var
(
--
sora
-
error
,
#
EF4444
);
}
.
sora
-
status
-
dot
.
cancelled
{
background
:
var
(
--
sora
-
text
-
tertiary
,
#
666
);
}
@
keyframes
sora
-
pulse
-
dot
{
0
%
,
100
%
{
opacity
:
1
;
}
50
%
{
opacity
:
0.4
;
}
}
/* 状态标签 */
.
sora
-
status
-
label
.
queued
{
color
:
var
(
--
sora
-
text
-
secondary
,
#
A0A0A0
);
}
.
sora
-
status
-
label
.
generating
{
color
:
var
(
--
sora
-
warning
,
#
F59E0B
);
}
.
sora
-
status
-
label
.
completed
{
color
:
var
(
--
sora
-
success
,
#
10
B981
);
}
.
sora
-
status
-
label
.
failed
{
color
:
var
(
--
sora
-
error
,
#
EF4444
);
}
.
sora
-
status
-
label
.
cancelled
{
color
:
var
(
--
sora
-
text
-
tertiary
,
#
666
);
}
/* 模型标签 */
.
sora
-
model
-
tag
{
font
-
size
:
11
px
;
padding
:
3
px
10
px
;
background
:
var
(
--
sora
-
bg
-
tertiary
,
#
242424
);
border
-
radius
:
var
(
--
sora
-
radius
-
full
,
9999
px
);
color
:
var
(
--
sora
-
text
-
secondary
,
#
A0A0A0
);
font
-
family
:
"
SF Mono
"
,
"
Fira Code
"
,
"
Cascadia Code
"
,
monospace
;
}
/* 取消按钮 */
.
sora
-
cancel
-
btn
{
display
:
inline
-
flex
;
align
-
items
:
center
;
gap
:
4
px
;
padding
:
4
px
12
px
;
border
-
radius
:
var
(
--
sora
-
radius
-
full
,
9999
px
);
font
-
size
:
12
px
;
color
:
var
(
--
sora
-
text
-
secondary
,
#
A0A0A0
);
background
:
var
(
--
sora
-
bg
-
tertiary
,
#
242424
);
border
:
none
;
cursor
:
pointer
;
transition
:
all
150
ms
ease
;
}
.
sora
-
cancel
-
btn
:
hover
{
background
:
rgba
(
239
,
68
,
68
,
0.15
);
color
:
var
(
--
sora
-
error
,
#
EF4444
);
}
/* 提示词 */
.
sora
-
task
-
prompt
{
font
-
size
:
14
px
;
color
:
var
(
--
sora
-
text
-
secondary
,
#
A0A0A0
);
margin
-
bottom
:
16
px
;
line
-
height
:
1.6
;
display
:
-
webkit
-
box
;
-
webkit
-
line
-
clamp
:
2
;
-
webkit
-
box
-
orient
:
vertical
;
overflow
:
hidden
;
}
.
sora
-
task
-
prompt
.
line
-
through
{
text
-
decoration
:
line
-
through
;
color
:
var
(
--
sora
-
text
-
tertiary
,
#
666
);
}
/* 错误分类 */
.
sora
-
task
-
error
-
category
{
display
:
inline
-
flex
;
align
-
items
:
center
;
gap
:
6
px
;
padding
:
4
px
10
px
;
background
:
rgba
(
239
,
68
,
68
,
0.1
);
border
-
radius
:
var
(
--
sora
-
radius
-
sm
,
8
px
);
font
-
size
:
12
px
;
color
:
var
(
--
sora
-
error
,
#
EF4444
);
margin
-
bottom
:
8
px
;
}
.
sora
-
task
-
error
-
message
{
font
-
size
:
13
px
;
color
:
var
(
--
sora
-
text
-
secondary
,
#
A0A0A0
);
line
-
height
:
1.5
;
margin
-
bottom
:
12
px
;
}
/* 进度条 */
.
sora
-
task
-
progress
-
wrapper
{
margin
-
bottom
:
16
px
;
}
.
sora
-
task
-
progress
-
bar
{
width
:
100
%
;
height
:
4
px
;
background
:
var
(
--
sora
-
bg
-
hover
,
#
333
);
border
-
radius
:
2
px
;
overflow
:
hidden
;
}
.
sora
-
task
-
progress
-
fill
{
height
:
100
%
;
border
-
radius
:
2
px
;
transition
:
width
400
ms
ease
;
}
.
sora
-
task
-
progress
-
fill
.
generating
{
background
:
var
(
--
sora
-
accent
-
gradient
,
linear
-
gradient
(
135
deg
,
#
14
b8a6
,
#
0
d9488
));
animation
:
sora
-
progress
-
shimmer
2
s
ease
-
in
-
out
infinite
;
}
.
sora
-
task
-
progress
-
fill
.
completed
{
background
:
var
(
--
sora
-
success
,
#
10
B981
);
}
.
sora
-
task
-
progress
-
fill
.
failed
{
background
:
var
(
--
sora
-
error
,
#
EF4444
);
}
@
keyframes
sora
-
progress
-
shimmer
{
0
%
{
opacity
:
1
;
}
50
%
{
opacity
:
0.6
;
}
100
%
{
opacity
:
1
;
}
}
.
sora
-
task
-
progress
-
info
{
display
:
flex
;
justify
-
content
:
space
-
between
;
margin
-
top
:
8
px
;
font
-
size
:
12
px
;
color
:
var
(
--
sora
-
text
-
tertiary
,
#
666
);
}
/* 预览 */
.
sora
-
task
-
preview
{
margin
-
top
:
16
px
;
border
-
radius
:
var
(
--
sora
-
radius
-
md
,
12
px
);
overflow
:
hidden
;
background
:
var
(
--
sora
-
bg
-
tertiary
,
#
242424
);
}
.
sora
-
task
-
preview
-
media
{
width
:
100
%
;
height
:
280
px
;
object
-
fit
:
cover
;
display
:
block
;
}
.
sora
-
task
-
preview
-
placeholder
{
width
:
100
%
;
height
:
280
px
;
display
:
flex
;
align
-
items
:
center
;
justify
-
content
:
center
;
background
:
var
(
--
sora
-
placeholder
-
gradient
,
linear
-
gradient
(
135
deg
,
#
e0e7ff
0
%
,
#
dbeafe
50
%
,
#
cffafe
100
%
));
font
-
size
:
48
px
;
}
/* 操作按钮 */
.
sora
-
task
-
actions
{
display
:
flex
;
flex
-
wrap
:
wrap
;
gap
:
8
px
;
margin
-
top
:
16
px
;
align
-
items
:
center
;
}
.
sora
-
action
-
btn
{
padding
:
8
px
20
px
;
border
-
radius
:
var
(
--
sora
-
radius
-
full
,
9999
px
);
font
-
size
:
13
px
;
font
-
weight
:
500
;
border
:
none
;
cursor
:
pointer
;
transition
:
all
150
ms
ease
;
text
-
decoration
:
none
;
display
:
inline
-
flex
;
align
-
items
:
center
;
gap
:
4
px
;
}
.
sora
-
action
-
btn
.
primary
{
background
:
var
(
--
sora
-
accent
-
gradient
,
linear
-
gradient
(
135
deg
,
#
14
b8a6
,
#
0
d9488
));
color
:
white
;
}
.
sora
-
action
-
btn
.
primary
:
hover
{
background
:
var
(
--
sora
-
accent
-
gradient
-
hover
,
linear
-
gradient
(
135
deg
,
#
2
dd4bf
,
#
14
b8a6
));
box
-
shadow
:
var
(
--
sora
-
shadow
-
glow
,
0
0
20
px
rgba
(
20
,
184
,
166
,
0.3
));
}
.
sora
-
action
-
btn
.
secondary
{
background
:
var
(
--
sora
-
bg
-
tertiary
,
#
242424
);
color
:
var
(
--
sora
-
text
-
secondary
,
#
A0A0A0
);
}
.
sora
-
action
-
btn
.
secondary
:
hover
{
background
:
var
(
--
sora
-
bg
-
hover
,
#
333
);
color
:
var
(
--
sora
-
text
-
primary
,
#
FFF
);
}
.
sora
-
action
-
btn
.
save
-
storage
{
background
:
linear
-
gradient
(
135
deg
,
#
10
B981
0
%
,
#
059669
100
%
);
color
:
white
;
}
.
sora
-
action
-
btn
.
save
-
storage
:
hover
{
box
-
shadow
:
0
0
16
px
rgba
(
16
,
185
,
129
,
0.3
);
}
/* 已保存标签 */
.
sora
-
saved
-
badge
{
display
:
inline
-
flex
;
align
-
items
:
center
;
gap
:
6
px
;
padding
:
6
px
14
px
;
background
:
rgba
(
16
,
185
,
129
,
0.1
);
border
:
1
px
solid
rgba
(
16
,
185
,
129
,
0.25
);
border
-
radius
:
var
(
--
sora
-
radius
-
full
,
9999
px
);
font
-
size
:
13
px
;
font
-
weight
:
500
;
color
:
var
(
--
sora
-
success
,
#
10
B981
);
}
/* 倒计时文本 */
.
sora
-
countdown
-
text
{
display
:
inline
-
flex
;
align
-
items
:
center
;
gap
:
4
px
;
font
-
size
:
12
px
;
font
-
weight
:
500
;
color
:
var
(
--
sora
-
warning
,
#
F59E0B
);
}
.
sora
-
countdown
-
text
.
expired
{
color
:
var
(
--
sora
-
error
,
#
EF4444
);
}
/* 倒计时进度条 */
.
sora
-
countdown
-
bar
-
wrapper
{
margin
-
top
:
12
px
;
}
.
sora
-
countdown
-
bar
{
width
:
100
%
;
height
:
3
px
;
background
:
var
(
--
sora
-
bg
-
hover
,
#
333
);
border
-
radius
:
2
px
;
overflow
:
hidden
;
}
.
sora
-
countdown
-
bar
-
fill
{
height
:
100
%
;
background
:
var
(
--
sora
-
warning
,
#
F59E0B
);
border
-
radius
:
2
px
;
transition
:
width
1
s
linear
;
}
.
countdown
-
warning
.
sora
-
countdown
-
bar
-
fill
{
background
:
var
(
--
sora
-
error
,
#
EF4444
);
}
.
countdown
-
warning
.
sora
-
countdown
-
text
{
color
:
var
(
--
sora
-
error
,
#
EF4444
);
}
<
/style
>
frontend/src/components/sora/SoraPromptBar.vue
deleted
100644 → 0
View file @
7b83d6e7
<
template
>
<div
class=
"sora-creator-bar-wrapper"
>
<div
class=
"sora-creator-bar"
>
<div
class=
"sora-creator-bar-inner"
:class=
"
{ focused: isFocused }">
<!-- 模型选择行 -->
<div
class=
"sora-creator-model-row"
>
<div
class=
"sora-model-select-wrapper"
>
<select
v-model=
"selectedFamily"
class=
"sora-model-select"
@
change=
"onFamilyChange"
>
<optgroup
v-if=
"videoFamilies.length"
:label=
"t('sora.videoModels')"
>
<option
v-for=
"f in videoFamilies"
:key=
"f.id"
:value=
"f.id"
>
{{
f
.
name
}}
</option>
</optgroup>
<optgroup
v-if=
"imageFamilies.length"
:label=
"t('sora.imageModels')"
>
<option
v-for=
"f in imageFamilies"
:key=
"f.id"
:value=
"f.id"
>
{{
f
.
name
}}
</option>
</optgroup>
</select>
<span
class=
"sora-model-select-arrow"
>
▼
</span>
</div>
<!-- 凭证选择器 -->
<div
class=
"sora-credential-select-wrapper"
>
<select
v-model=
"selectedCredentialId"
class=
"sora-model-select"
>
<option
:value=
"0"
disabled
>
{{
t
(
'
sora.selectCredential
'
)
}}
</option>
<optgroup
v-if=
"apiKeyOptions.length"
:label=
"t('sora.apiKeys')"
>
<option
v-for=
"k in apiKeyOptions"
:key=
"'k'+k.id"
:value=
"k.id"
>
{{
k
.
name
}}{{
k
.
group
?
'
·
'
+
k
.
group
.
name
:
''
}}
</option>
</optgroup>
<optgroup
v-if=
"subscriptionOptions.length"
:label=
"t('sora.subscriptions')"
>
<option
v-for=
"s in subscriptionOptions"
:key=
"'s'+s.id"
:value=
"-s.id"
>
{{
s
.
group
?.
name
||
t
(
'
sora.subscription
'
)
}}
</option>
</optgroup>
</select>
<span
class=
"sora-model-select-arrow"
>
▼
</span>
</div>
<!-- 无凭证提示 -->
<span
v-if=
"soraCredentialEmpty"
class=
"sora-no-storage-badge"
>
⚠
{{
t
(
'
sora.noCredentialHint
'
)
}}
</span>
<!-- 无存储提示 -->
<span
v-if=
"!hasStorage"
class=
"sora-no-storage-badge"
>
⚠
{{
t
(
'
sora.noStorageConfigured
'
)
}}
</span>
</div>
<!-- 参考图预览 -->
<div
v-if=
"imagePreview"
class=
"sora-image-preview-row"
>
<div
class=
"sora-image-preview-thumb"
>
<img
:src=
"imagePreview"
alt=
""
/>
<button
class=
"sora-image-preview-remove"
@
click=
"removeImage"
>
✕
</button>
</div>
<span
class=
"sora-image-preview-label"
>
{{
t
(
'
sora.referenceImage
'
)
}}
</span>
</div>
<!-- 输入框 -->
<div
class=
"sora-creator-input-wrapper"
>
<textarea
ref=
"textareaRef"
v-model=
"prompt"
class=
"sora-creator-textarea"
:placeholder=
"t('sora.creatorPlaceholder')"
rows=
"1"
@
input=
"autoResize"
@
focus=
"isFocused = true"
@
blur=
"isFocused = false"
@
keydown.enter.ctrl=
"submit"
@
keydown.enter.meta=
"submit"
/>
</div>
<!-- 底部工具行 -->
<div
class=
"sora-creator-tools-row"
>
<div
class=
"sora-creator-tools-left"
>
<!-- 方向选择(根据所选模型家族支持的方向动态渲染) -->
<template
v-if=
"availableAspects.length > 0"
>
<button
v-for=
"a in availableAspects"
:key=
"a.value"
class=
"sora-tool-btn"
:class=
"
{ active: currentAspect === a.value }"
@click="currentAspect = a.value"
>
<span
class=
"sora-tool-btn-icon"
>
{{
a
.
icon
}}
</span>
{{
a
.
label
}}
</button>
<span
v-if=
"availableDurations.length > 0"
class=
"sora-tool-divider"
/>
</
template
>
<!-- 时长选择(根据所选模型家族支持的时长动态渲染) -->
<
template
v-if=
"availableDurations.length > 0"
>
<button
v-for=
"d in availableDurations"
:key=
"d"
class=
"sora-tool-btn"
:class=
"
{ active: currentDuration === d }"
@click="currentDuration = d"
>
{{
d
}}
s
</button>
<span
class=
"sora-tool-divider"
/>
</
template
>
<!-- 视频数量(官方 Videos 1/2/3) -->
<
template
v-if=
"availableVideoCounts.length > 0"
>
<button
v-for=
"count in availableVideoCounts"
:key=
"count"
class=
"sora-tool-btn"
:class=
"
{ active: currentVideoCount === count }"
@click="currentVideoCount = count"
>
{{
count
}}
</button>
<span
class=
"sora-tool-divider"
/>
</
template
>
<!-- 图片上传 -->
<button
class=
"sora-upload-btn"
:title=
"t('sora.uploadReference')"
@
click=
"triggerFileInput"
>
📎
</button>
<input
ref=
"fileInputRef"
type=
"file"
accept=
"image/png,image/jpeg,image/webp"
style=
"display: none"
@
change=
"onFileChange"
/>
</div>
<!-- 活跃任务计数 -->
<span
v-if=
"activeTaskCount > 0"
class=
"sora-active-tasks-label"
>
<span
class=
"sora-pulse-indicator"
/>
<span>
{{ t('sora.generatingCount', { current: activeTaskCount, max: maxConcurrentTasks }) }}
</span>
</span>
<!-- 生成按钮 -->
<button
class=
"sora-generate-btn"
:class=
"{ 'max-reached': isMaxReached }"
:disabled=
"!canSubmit || generating || isMaxReached"
@
click=
"submit"
>
<span
class=
"sora-generate-btn-icon"
>
✨
</span>
<span>
{{ generating ? t('sora.generating') : t('sora.generate') }}
</span>
</button>
</div>
</div>
</div>
<!-- 文件大小错误 -->
<p
v-if=
"imageError"
class=
"sora-image-error"
>
{{ imageError }}
</p>
</div>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
onMounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
soraAPI
,
{
type
SoraModelFamily
,
type
GenerateRequest
}
from
'
@/api/sora
'
import
keysAPI
from
'
@/api/keys
'
import
{
useSubscriptionStore
}
from
'
@/stores/subscriptions
'
import
type
{
ApiKey
,
UserSubscription
}
from
'
@/types
'
const
MAX_IMAGE_SIZE
=
20
*
1024
*
1024
/** 方向显示配置 */
const
ASPECT_META
:
Record
<
string
,
{
icon
:
string
;
label
:
string
}
>
=
{
landscape
:
{
icon
:
'
▬
'
,
label
:
'
横屏
'
},
portrait
:
{
icon
:
'
▮
'
,
label
:
'
竖屏
'
},
square
:
{
icon
:
'
◻
'
,
label
:
'
方形
'
}
}
const
props
=
defineProps
<
{
generating
:
boolean
activeTaskCount
:
number
maxConcurrentTasks
:
number
}
>
()
const
emit
=
defineEmits
<
{
generate
:
[
req
:
GenerateRequest
]
fillPrompt
:
[
prompt
:
string
]
}
>
()
const
{
t
}
=
useI18n
()
const
prompt
=
ref
(
''
)
const
families
=
ref
<
SoraModelFamily
[]
>
([])
const
selectedFamily
=
ref
(
''
)
const
currentAspect
=
ref
(
'
landscape
'
)
const
currentDuration
=
ref
(
10
)
const
currentVideoCount
=
ref
(
1
)
const
isFocused
=
ref
(
false
)
const
imagePreview
=
ref
<
string
|
null
>
(
null
)
const
imageError
=
ref
(
''
)
const
fileInputRef
=
ref
<
HTMLInputElement
|
null
>
(
null
)
const
textareaRef
=
ref
<
HTMLTextAreaElement
|
null
>
(
null
)
const
hasStorage
=
ref
(
true
)
// 凭证相关状态
const
apiKeyOptions
=
ref
<
ApiKey
[]
>
([])
const
subscriptionOptions
=
ref
<
UserSubscription
[]
>
([])
const
selectedCredentialId
=
ref
<
number
>
(
0
)
// >0 = api_key.id,
<
0
=
-
subscription
.
id
const
soraCredentialEmpty
=
computed
(()
=>
apiKeyOptions
.
value
.
length
===
0
&&
subscriptionOptions
.
value
.
length
===
0
)
// 按类型分组
const
videoFamilies
=
computed
(()
=>
families
.
value
.
filter
(
f
=>
f
.
type
===
'
video
'
))
const
imageFamilies
=
computed
(()
=>
families
.
value
.
filter
(
f
=>
f
.
type
===
'
image
'
))
// 当前选中的家族对象
const
currentFamily
=
computed
(()
=>
families
.
value
.
find
(
f
=>
f
.
id
===
selectedFamily
.
value
))
// 当前家族支持的方向列表
const
availableAspects
=
computed
(()
=>
{
const
fam
=
currentFamily
.
value
if
(
!
fam
?.
orientations
?.
length
)
return
[]
return
fam
.
orientations
.
map
(
o
=>
({
value
:
o
,
...(
ASPECT_META
[
o
]
||
{
icon
:
'
?
'
,
label
:
o
})
}))
})
// 当前家族支持的时长列表
const
availableDurations
=
computed
(()
=>
currentFamily
.
value
?.
durations
??
[])
const
availableVideoCounts
=
computed
(()
=>
(
currentFamily
.
value
?.
type
===
'
video
'
?
[
1
,
2
,
3
]
:
[]))
const
isMaxReached
=
computed
(()
=>
props
.
activeTaskCount
>=
props
.
maxConcurrentTasks
)
const
canSubmit
=
computed
(()
=>
prompt
.
value
.
trim
().
length
>
0
&&
selectedFamily
.
value
&&
selectedCredentialId
.
value
!==
0
)
/** 构建最终 model ID(family + orientation + duration) */
function
buildModelID
():
string
{
const
fam
=
currentFamily
.
value
if
(
!
fam
)
return
selectedFamily
.
value
if
(
fam
.
type
===
'
image
'
)
{
// 图像模型: "gpt-image"(方形)或 "gpt-image-landscape"
return
currentAspect
.
value
===
'
square
'
?
fam
.
id
:
`
${
fam
.
id
}
-
${
currentAspect
.
value
}
`
}
// 视频模型: "sora2-landscape-10s"
return
`
${
fam
.
id
}
-
${
currentAspect
.
value
}
-
${
currentDuration
.
value
}
s`
}
/** 切换家族时自动调整方向和时长为首个可用值 */
function
onFamilyChange
()
{
const
fam
=
families
.
value
.
find
(
f
=>
f
.
id
===
selectedFamily
.
value
)
if
(
!
fam
)
return
// 若当前方向不在新家族支持列表中,重置为首个
if
(
fam
.
orientations
?.
length
&&
!
fam
.
orientations
.
includes
(
currentAspect
.
value
))
{
currentAspect
.
value
=
fam
.
orientations
[
0
]
}
// 若当前时长不在新家族支持列表中,重置为首个
if
(
fam
.
durations
?.
length
&&
!
fam
.
durations
.
includes
(
currentDuration
.
value
))
{
currentDuration
.
value
=
fam
.
durations
[
0
]
}
if
(
fam
.
type
!==
'
video
'
)
{
currentVideoCount
.
value
=
1
}
}
async
function
loadModels
()
{
try
{
families
.
value
=
await
soraAPI
.
getModels
()
if
(
families
.
value
.
length
>
0
&&
!
selectedFamily
.
value
)
{
selectedFamily
.
value
=
families
.
value
[
0
].
id
onFamilyChange
()
}
}
catch
(
e
)
{
console
.
error
(
'
Failed to load models:
'
,
e
)
}
}
async
function
loadStorageStatus
()
{
try
{
const
status
=
await
soraAPI
.
getStorageStatus
()
hasStorage
.
value
=
status
.
s3_enabled
&&
status
.
s3_healthy
}
catch
{
hasStorage
.
value
=
false
}
}
async
function
loadSoraCredentials
()
{
try
{
// 加载 API Keys,筛选 sora 平台 + active 状态
const
keysRes
=
await
keysAPI
.
list
(
1
,
100
)
apiKeyOptions
.
value
=
(
keysRes
.
items
||
[]).
filter
(
(
k
:
ApiKey
)
=>
k
.
status
===
'
active
'
&&
k
.
group
?.
platform
===
'
sora
'
)
// 加载活跃订阅,筛选 sora 平台
const
subStore
=
useSubscriptionStore
()
const
subs
=
await
subStore
.
fetchActiveSubscriptions
()
subscriptionOptions
.
value
=
subs
.
filter
(
(
s
:
UserSubscription
)
=>
s
.
status
===
'
active
'
&&
s
.
group
?.
platform
===
'
sora
'
)
// 自动选择第一个
if
(
apiKeyOptions
.
value
.
length
>
0
)
{
selectedCredentialId
.
value
=
apiKeyOptions
.
value
[
0
].
id
}
else
if
(
subscriptionOptions
.
value
.
length
>
0
)
{
selectedCredentialId
.
value
=
-
subscriptionOptions
.
value
[
0
].
id
}
}
catch
(
e
)
{
console
.
error
(
'
Failed to load sora credentials:
'
,
e
)
}
}
function
autoResize
()
{
const
el
=
textareaRef
.
value
if
(
!
el
)
return
el
.
style
.
height
=
'
auto
'
el
.
style
.
height
=
Math
.
min
(
el
.
scrollHeight
,
120
)
+
'
px
'
}
function
triggerFileInput
()
{
fileInputRef
.
value
?.
click
()
}
function
onFileChange
(
event
:
Event
)
{
const
input
=
event
.
target
as
HTMLInputElement
const
file
=
input
.
files
?.[
0
]
if
(
!
file
)
return
imageError
.
value
=
''
if
(
file
.
size
>
MAX_IMAGE_SIZE
)
{
imageError
.
value
=
t
(
'
sora.imageTooLarge
'
)
input
.
value
=
''
return
}
const
reader
=
new
FileReader
()
reader
.
onload
=
(
e
)
=>
{
imagePreview
.
value
=
e
.
target
?.
result
as
string
}
reader
.
readAsDataURL
(
file
)
input
.
value
=
''
}
function
removeImage
()
{
imagePreview
.
value
=
null
imageError
.
value
=
''
}
function
submit
()
{
if
(
!
canSubmit
.
value
||
props
.
generating
||
isMaxReached
.
value
)
return
const
modelID
=
buildModelID
()
const
req
:
GenerateRequest
=
{
model
:
modelID
,
prompt
:
prompt
.
value
.
trim
(),
media_type
:
currentFamily
.
value
?.
type
||
'
video
'
}
if
((
currentFamily
.
value
?.
type
||
'
video
'
)
===
'
video
'
)
{
req
.
video_count
=
currentVideoCount
.
value
}
if
(
imagePreview
.
value
)
{
req
.
image_input
=
imagePreview
.
value
}
if
(
selectedCredentialId
.
value
>
0
)
{
req
.
api_key_id
=
selectedCredentialId
.
value
}
emit
(
'
generate
'
,
req
)
prompt
.
value
=
''
imagePreview
.
value
=
null
imageError
.
value
=
''
if
(
textareaRef
.
value
)
{
textareaRef
.
value
.
style
.
height
=
'
auto
'
}
}
/** 外部调用:填充提示词 */
function
fillPrompt
(
text
:
string
)
{
prompt
.
value
=
text
setTimeout
(
autoResize
,
0
)
textareaRef
.
value
?.
focus
()
}
defineExpose
({
fillPrompt
})
onMounted
(()
=>
{
loadModels
()
loadStorageStatus
()
loadSoraCredentials
()
})
</
script
>
<
style
scoped
>
.sora-creator-bar-wrapper
{
position
:
fixed
;
bottom
:
0
;
left
:
0
;
right
:
0
;
z-index
:
40
;
background
:
linear-gradient
(
to
top
,
var
(
--sora-bg-primary
,
#0D0D0D
)
60%
,
transparent
100%
);
padding
:
20px
24px
24px
;
pointer-events
:
none
;
}
.sora-creator-bar
{
max-width
:
780px
;
margin
:
0
auto
;
pointer-events
:
all
;
}
.sora-creator-bar-inner
{
background
:
var
(
--sora-bg-secondary
,
#1A1A1A
);
border
:
1px
solid
var
(
--sora-border-color
,
#2A2A2A
);
border-radius
:
var
(
--sora-radius-xl
,
20px
);
padding
:
12px
16px
;
transition
:
border-color
150ms
ease
,
box-shadow
150ms
ease
;
}
.sora-creator-bar-inner.focused
{
border-color
:
var
(
--sora-accent-primary
,
#14b8a6
);
box-shadow
:
0
0
0
1px
var
(
--sora-accent-primary
,
#14b8a6
),
var
(
--sora-shadow-glow
,
0
0
20px
rgba
(
20
,
184
,
166
,
0.3
));
}
/* 模型选择行 */
.sora-creator-model-row
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
margin-bottom
:
8px
;
padding
:
0
4px
;
}
.sora-model-select-wrapper
{
position
:
relative
;
}
.sora-model-select
{
appearance
:
none
;
background
:
var
(
--sora-bg-tertiary
,
#242424
);
color
:
var
(
--sora-text-primary
,
#FFF
);
padding
:
5px
28px
5px
10px
;
border-radius
:
var
(
--sora-radius-sm
,
8px
);
font-size
:
12px
;
font-family
:
"SF Mono"
,
"Fira Code"
,
monospace
;
cursor
:
pointer
;
border
:
1px
solid
transparent
;
transition
:
all
150ms
ease
;
}
.sora-model-select
:hover
{
border-color
:
var
(
--sora-bg-hover
,
#333
);
}
.sora-model-select
:focus
{
border-color
:
var
(
--sora-accent-primary
,
#14b8a6
);
outline
:
none
;
}
.sora-model-select
option
{
background
:
var
(
--sora-bg-secondary
,
#1A1A1A
);
color
:
var
(
--sora-text-primary
,
#FFF
);
}
.sora-model-select-arrow
{
position
:
absolute
;
right
:
8px
;
top
:
50%
;
transform
:
translateY
(
-50%
);
pointer-events
:
none
;
font-size
:
10px
;
color
:
var
(
--sora-text-tertiary
,
#666
);
}
.sora-credential-select-wrapper
{
position
:
relative
;
max-width
:
200px
;
}
/* 无存储提示 */
.sora-no-storage-badge
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
4px
;
padding
:
3px
10px
;
background
:
rgba
(
245
,
158
,
11
,
0.1
);
border
:
1px
solid
rgba
(
245
,
158
,
11
,
0.2
);
border-radius
:
var
(
--sora-radius-full
,
9999px
);
font-size
:
11px
;
color
:
var
(
--sora-warning
,
#F59E0B
);
}
/* 参考图预览 */
.sora-image-preview-row
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
padding
:
0
4px
;
margin-bottom
:
8px
;
}
.sora-image-preview-thumb
{
position
:
relative
;
width
:
48px
;
height
:
48px
;
}
.sora-image-preview-thumb
img
{
width
:
100%
;
height
:
100%
;
object-fit
:
cover
;
border-radius
:
8px
;
border
:
1px
solid
var
(
--sora-border-color
,
#2A2A2A
);
}
.sora-image-preview-remove
{
position
:
absolute
;
top
:
-6px
;
right
:
-6px
;
width
:
18px
;
height
:
18px
;
border-radius
:
50%
;
background
:
var
(
--sora-error
,
#EF4444
);
color
:
white
;
font-size
:
10px
;
border
:
none
;
cursor
:
pointer
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
line-height
:
1
;
}
.sora-image-preview-label
{
font-size
:
12px
;
color
:
var
(
--sora-text-tertiary
,
#666
);
}
/* 输入框 */
.sora-creator-input-wrapper
{
position
:
relative
;
}
.sora-creator-textarea
{
width
:
100%
;
min-height
:
44px
;
max-height
:
120px
;
padding
:
10px
4px
;
font-size
:
14px
;
color
:
var
(
--sora-text-primary
,
#FFF
);
background
:
transparent
;
resize
:
none
;
line-height
:
1.5
;
overflow-y
:
auto
;
border
:
none
;
outline
:
none
;
font-family
:
inherit
;
}
.sora-creator-textarea
::placeholder
{
color
:
var
(
--sora-text-muted
,
#4A4A4A
);
}
/* 底部工具行 */
.sora-creator-tools-row
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
padding
:
4px
4px
0
;
border-top
:
1px
solid
var
(
--sora-border-subtle
,
#1F1F1F
);
margin-top
:
4px
;
padding-top
:
10px
;
gap
:
8px
;
}
.sora-creator-tools-left
{
display
:
flex
;
align-items
:
center
;
gap
:
6px
;
flex-wrap
:
wrap
;
}
.sora-tool-btn
{
display
:
flex
;
align-items
:
center
;
gap
:
5px
;
padding
:
6px
12px
;
border-radius
:
var
(
--sora-radius-full
,
9999px
);
font-size
:
12px
;
color
:
var
(
--sora-text-secondary
,
#A0A0A0
);
background
:
var
(
--sora-bg-tertiary
,
#242424
);
border
:
none
;
cursor
:
pointer
;
transition
:
all
150ms
ease
;
white-space
:
nowrap
;
}
.sora-tool-btn
:hover
{
background
:
var
(
--sora-bg-hover
,
#333
);
color
:
var
(
--sora-text-primary
,
#FFF
);
}
.sora-tool-btn.active
{
background
:
rgba
(
20
,
184
,
166
,
0.15
);
color
:
var
(
--sora-accent-primary
,
#14b8a6
);
border
:
1px
solid
rgba
(
20
,
184
,
166
,
0.3
);
}
.sora-tool-btn-icon
{
font-size
:
14px
;
line-height
:
1
;
}
.sora-tool-divider
{
width
:
1px
;
height
:
20px
;
background
:
var
(
--sora-border-color
,
#2A2A2A
);
margin
:
0
4px
;
}
/* 上传按钮 */
.sora-upload-btn
{
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
width
:
32px
;
height
:
32px
;
border-radius
:
var
(
--sora-radius-sm
,
8px
);
background
:
var
(
--sora-bg-tertiary
,
#242424
);
color
:
var
(
--sora-text-secondary
,
#A0A0A0
);
font-size
:
16px
;
border
:
none
;
cursor
:
pointer
;
transition
:
all
150ms
ease
;
}
.sora-upload-btn
:hover
{
background
:
var
(
--sora-bg-hover
,
#333
);
color
:
var
(
--sora-text-primary
,
#FFF
);
}
/* 活跃任务计数 */
.sora-active-tasks-label
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
6px
;
padding
:
4px
12px
;
background
:
rgba
(
20
,
184
,
166
,
0.12
);
border
:
1px
solid
rgba
(
20
,
184
,
166
,
0.25
);
border-radius
:
var
(
--sora-radius-full
,
9999px
);
font-size
:
12px
;
font-weight
:
500
;
color
:
var
(
--sora-accent-primary
,
#14b8a6
);
white-space
:
nowrap
;
animation
:
sora-fade-in
0.3s
ease
;
}
.sora-pulse-indicator
{
width
:
6px
;
height
:
6px
;
border-radius
:
50%
;
background
:
var
(
--sora-accent-primary
,
#14b8a6
);
animation
:
sora-pulse-dot
1.5s
ease-in-out
infinite
;
}
@keyframes
sora-pulse-dot
{
0
%,
100
%
{
opacity
:
1
;
}
50
%
{
opacity
:
0.4
;
}
}
@keyframes
sora-fade-in
{
from
{
opacity
:
0
;
transform
:
translateY
(
8px
);
}
to
{
opacity
:
1
;
transform
:
translateY
(
0
);
}
}
/* 生成按钮 */
.sora-generate-btn
{
display
:
flex
;
align-items
:
center
;
gap
:
6px
;
padding
:
8px
24px
;
background
:
var
(
--sora-accent-gradient
,
linear-gradient
(
135deg
,
#14b8a6
,
#0d9488
));
border-radius
:
var
(
--sora-radius-full
,
9999px
);
font-size
:
13px
;
font-weight
:
600
;
color
:
white
;
border
:
none
;
cursor
:
pointer
;
transition
:
all
150ms
ease
;
flex-shrink
:
0
;
}
.sora-generate-btn
:hover:not
(
:disabled
)
{
background
:
var
(
--sora-accent-gradient-hover
,
linear-gradient
(
135deg
,
#2dd4bf
,
#14b8a6
));
box-shadow
:
var
(
--sora-shadow-glow
,
0
0
20px
rgba
(
20
,
184
,
166
,
0.3
));
transform
:
translateY
(
-1px
);
}
.sora-generate-btn
:active:not
(
:disabled
)
{
transform
:
translateY
(
0
);
}
.sora-generate-btn
:disabled
{
opacity
:
0.4
;
cursor
:
not-allowed
;
transform
:
none
;
box-shadow
:
none
;
}
.sora-generate-btn.max-reached
{
opacity
:
0.4
;
cursor
:
not-allowed
;
}
.sora-generate-btn-icon
{
font-size
:
16px
;
}
/* 图片错误 */
.sora-image-error
{
text-align
:
center
;
font-size
:
12px
;
color
:
var
(
--sora-error
,
#EF4444
);
margin-top
:
8px
;
pointer-events
:
all
;
}
/* 响应式 */
@media
(
max-width
:
600px
)
{
.sora-creator-bar-wrapper
{
padding
:
12px
12px
16px
;
}
.sora-creator-tools-left
{
gap
:
4px
;
}
.sora-tool-btn
{
padding
:
5px
8px
;
font-size
:
11px
;
}
}
</
style
>
frontend/src/components/sora/SoraQuotaBar.vue
deleted
100644 → 0
View file @
7b83d6e7
<
template
>
<div
v-if=
"quota && quota.source !== 'none'"
class=
"sora-quota-info"
>
<div
class=
"sora-quota-bar-wrapper"
>
<div
class=
"sora-quota-bar-fill"
:class=
"
{ warning: percentage > 80, danger: percentage > 95 }"
:style="{ width: `${Math.min(percentage, 100)}%` }"
/>
</div>
<span
class=
"sora-quota-text"
:class=
"
{ warning: percentage > 80, danger: percentage > 95 }">
{{
formatBytes
(
quota
.
used_bytes
)
}}
/
{{
quota
.
quota_bytes
===
0
?
'
∞
'
:
formatBytes
(
quota
.
quota_bytes
)
}}
</span>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
type
{
QuotaInfo
}
from
'
@/api/sora
'
const
props
=
defineProps
<
{
quota
:
QuotaInfo
}
>
()
const
percentage
=
computed
(()
=>
{
if
(
!
props
.
quota
||
props
.
quota
.
quota_bytes
===
0
)
return
0
return
(
props
.
quota
.
used_bytes
/
props
.
quota
.
quota_bytes
)
*
100
})
function
formatBytes
(
bytes
:
number
):
string
{
if
(
bytes
===
0
)
return
'
0 B
'
const
units
=
[
'
B
'
,
'
KB
'
,
'
MB
'
,
'
GB
'
,
'
TB
'
]
const
i
=
Math
.
floor
(
Math
.
log
(
bytes
)
/
Math
.
log
(
1024
))
return
`
${(
bytes
/
Math
.
pow
(
1024
,
i
)).
toFixed
(
1
)}
${
units
[
i
]}
`
}
</
script
>
<
style
scoped
>
.sora-quota-info
{
display
:
flex
;
align-items
:
center
;
gap
:
10px
;
padding
:
6px
14px
;
background
:
var
(
--sora-bg-secondary
);
border-radius
:
var
(
--sora-radius-full
,
9999px
);
font-size
:
12px
;
color
:
var
(
--sora-text-secondary
,
#A0A0A0
);
}
.sora-quota-bar-wrapper
{
width
:
80px
;
height
:
4px
;
background
:
var
(
--sora-bg-hover
,
#333
);
border-radius
:
2px
;
overflow
:
hidden
;
}
.sora-quota-bar-fill
{
height
:
100%
;
background
:
var
(
--sora-accent-gradient
,
linear-gradient
(
135deg
,
#14b8a6
,
#0d9488
));
border-radius
:
2px
;
transition
:
width
400ms
ease
;
}
.sora-quota-bar-fill.warning
{
background
:
var
(
--sora-warning
,
#F59E0B
)
!important
;
}
.sora-quota-bar-fill.danger
{
background
:
var
(
--sora-error
,
#EF4444
)
!important
;
}
.sora-quota-text
{
white-space
:
nowrap
;
}
.sora-quota-text.warning
{
color
:
var
(
--sora-warning
,
#F59E0B
);
}
.sora-quota-text.danger
{
color
:
var
(
--sora-error
,
#EF4444
);
}
@media
(
max-width
:
900px
)
{
.sora-quota-info
{
display
:
none
;
}
}
</
style
>
frontend/src/composables/__tests__/useOpenAIOAuth.spec.ts
View file @
eb2dce92
...
...
@@ -11,8 +11,7 @@ vi.mock('@/api/admin', () => ({
accounts
:
{
generateAuthUrl
:
vi
.
fn
(),
exchangeCode
:
vi
.
fn
(),
refreshOpenAIToken
:
vi
.
fn
(),
validateSoraSessionToken
:
vi
.
fn
()
refreshOpenAIToken
:
vi
.
fn
()
}
}
}))
...
...
@@ -21,21 +20,21 @@ import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
describe
(
'
useOpenAIOAuth.buildCredentials
'
,
()
=>
{
it
(
'
should keep client_id when token response contains it
'
,
()
=>
{
const
oauth
=
useOpenAIOAuth
(
{
platform
:
'
sora
'
}
)
const
oauth
=
useOpenAIOAuth
()
const
creds
=
oauth
.
buildCredentials
({
access_token
:
'
at
'
,
refresh_token
:
'
rt
'
,
client_id
:
'
app_
sora
_client
'
,
client_id
:
'
app_
test
_client
'
,
expires_at
:
1700000000
})
expect
(
creds
.
client_id
).
toBe
(
'
app_
sora
_client
'
)
expect
(
creds
.
client_id
).
toBe
(
'
app_
test
_client
'
)
expect
(
creds
.
access_token
).
toBe
(
'
at
'
)
expect
(
creds
.
refresh_token
).
toBe
(
'
rt
'
)
})
it
(
'
should keep legacy behavior when client_id is missing
'
,
()
=>
{
const
oauth
=
useOpenAIOAuth
(
{
platform
:
'
openai
'
}
)
const
oauth
=
useOpenAIOAuth
()
const
creds
=
oauth
.
buildCredentials
({
access_token
:
'
at
'
,
refresh_token
:
'
rt
'
,
...
...
frontend/src/composables/useModelWhitelist.ts
View file @
eb2dce92
...
...
@@ -60,22 +60,6 @@ const geminiModels = [
'
gemini-3-pro-preview
'
]
// Sora
const
soraModels
=
[
'
gpt-image
'
,
'
gpt-image-landscape
'
,
'
gpt-image-portrait
'
,
'
sora2-landscape-10s
'
,
'
sora2-portrait-10s
'
,
'
sora2-landscape-15s
'
,
'
sora2-portrait-15s
'
,
'
sora2-landscape-25s
'
,
'
sora2-portrait-25s
'
,
'
sora2pro-landscape-10s
'
,
'
sora2pro-portrait-10s
'
,
'
sora2pro-landscape-15s
'
,
'
sora2pro-portrait-15s
'
,
'
sora2pro-landscape-25s
'
,
'
sora2pro-portrait-25s
'
,
'
sora2pro-hd-landscape-10s
'
,
'
sora2pro-hd-portrait-10s
'
,
'
sora2pro-hd-landscape-15s
'
,
'
sora2pro-hd-portrait-15s
'
,
'
prompt-enhance-short-10s
'
,
'
prompt-enhance-short-15s
'
,
'
prompt-enhance-short-20s
'
,
'
prompt-enhance-medium-10s
'
,
'
prompt-enhance-medium-15s
'
,
'
prompt-enhance-medium-20s
'
,
'
prompt-enhance-long-10s
'
,
'
prompt-enhance-long-15s
'
,
'
prompt-enhance-long-20s
'
]
// Antigravity 官方支持的模型(精确匹配)
// 基于官方 API 返回的模型列表,只支持 Claude 4.5+ 和 Gemini 2.5+
const
antigravityModels
=
[
...
...
@@ -236,7 +220,6 @@ const allModelsList: string[] = [
...
openaiModels
,
...
claudeModels
,
...
geminiModels
,
...
soraModels
,
...
zhipuModels
,
...
qwenModels
,
...
deepseekModels
,
...
...
@@ -289,8 +272,6 @@ const openaiPresetMappings = [
{
label
:
'
Sonnet→5.4
'
,
from
:
'
claude-sonnet-4-6
'
,
to
:
'
gpt-5.4
'
,
color
:
'
bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400
'
}
]
const
soraPresetMappings
:
{
label
:
string
;
from
:
string
;
to
:
string
;
color
:
string
}[]
=
[]
const
geminiPresetMappings
=
[
{
label
:
'
Flash 2.0
'
,
from
:
'
gemini-2.0-flash
'
,
to
:
'
gemini-2.0-flash
'
,
color
:
'
bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400
'
},
{
label
:
'
2.5 Flash
'
,
from
:
'
gemini-2.5-flash
'
,
to
:
'
gemini-2.5-flash
'
,
color
:
'
bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400
'
},
...
...
@@ -385,7 +366,6 @@ export function getModelsByPlatform(platform: string): string[] {
case
'
anthropic
'
:
case
'
claude
'
:
return
claudeModels
case
'
gemini
'
:
return
geminiModels
case
'
sora
'
:
return
soraModels
case
'
antigravity
'
:
return
antigravityModels
case
'
zhipu
'
:
return
zhipuModels
case
'
qwen
'
:
return
qwenModels
...
...
@@ -410,7 +390,6 @@ export function getModelsByPlatform(platform: string): string[] {
export
function
getPresetMappingsByPlatform
(
platform
:
string
)
{
if
(
platform
===
'
openai
'
)
return
openaiPresetMappings
if
(
platform
===
'
gemini
'
)
return
geminiPresetMappings
if
(
platform
===
'
sora
'
)
return
soraPresetMappings
if
(
platform
===
'
antigravity
'
)
return
antigravityPresetMappings
if
(
platform
===
'
bedrock
'
)
return
bedrockPresetMappings
return
anthropicPresetMappings
...
...
frontend/src/composables/useOpenAIOAuth.ts
View file @
eb2dce92
...
...
@@ -22,16 +22,11 @@ export interface OpenAITokenInfo {
[
key
:
string
]:
unknown
}
export
type
OpenAIOAuthPlatform
=
'
openai
'
|
'
sora
'
export
type
OpenAIOAuthPlatform
=
'
openai
'
interface
UseOpenAIOAuthOptions
{
platform
?:
OpenAIOAuthPlatform
}
export
function
useOpenAIOAuth
(
options
?:
UseOpenAIOAuthOptions
)
{
export
function
useOpenAIOAuth
()
{
const
appStore
=
useAppStore
()
const
oauthPlatform
=
options
?.
platform
??
'
openai
'
const
endpointPrefix
=
oauthPlatform
===
'
sora
'
?
'
/admin/sora
'
:
'
/admin/openai
'
const
endpointPrefix
=
'
/admin/openai
'
// State
const
authUrl
=
ref
(
''
)
...
...
@@ -160,33 +155,6 @@ export function useOpenAIOAuth(options?: UseOpenAIOAuthOptions) {
}
}
// Validate Sora session token and get access token
const
validateSessionToken
=
async
(
sessionToken
:
string
,
proxyId
?:
number
|
null
):
Promise
<
OpenAITokenInfo
|
null
>
=>
{
if
(
!
sessionToken
.
trim
())
{
error
.
value
=
'
Missing session token
'
return
null
}
loading
.
value
=
true
error
.
value
=
''
try
{
const
tokenInfo
=
await
adminAPI
.
accounts
.
validateSoraSessionToken
(
sessionToken
.
trim
(),
proxyId
,
`
${
endpointPrefix
}
/st2at`
)
return
tokenInfo
as
OpenAITokenInfo
}
catch
(
err
:
any
)
{
error
.
value
=
err
.
response
?.
data
?.
detail
||
'
Failed to validate session token
'
appStore
.
showError
(
error
.
value
)
return
null
}
finally
{
loading
.
value
=
false
}
}
// Build credentials for OpenAI OAuth account (aligned with backend BuildAccountCredentials)
const
buildCredentials
=
(
tokenInfo
:
OpenAITokenInfo
):
Record
<
string
,
unknown
>
=>
{
const
creds
:
Record
<
string
,
unknown
>
=
{
...
...
@@ -250,7 +218,6 @@ export function useOpenAIOAuth(options?: UseOpenAIOAuthOptions) {
generateAuthUrl
,
exchangeAuthCode
,
validateRefreshToken
,
validateSessionToken
,
buildCredentials
,
buildExtraInfo
}
...
...
frontend/src/router/index.ts
View file @
eb2dce92
...
...
@@ -201,18 +201,6 @@ const routes: RouteRecordRaw[] = [
descriptionKey
:
'
purchase.description
'
}
},
{
path
:
'
/sora
'
,
name
:
'
Sora
'
,
component
:
()
=>
import
(
'
@/views/user/SoraView.vue
'
),
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
false
,
title
:
'
Sora
'
,
titleKey
:
'
sora.title
'
,
descriptionKey
:
'
sora.description
'
}
},
{
path
:
'
/custom/:id
'
,
name
:
'
CustomPage
'
,
...
...
frontend/src/stores/app.ts
View file @
eb2dce92
...
...
@@ -332,7 +332,6 @@ export const useAppStore = defineStore('app', () => {
custom_menu_items
:
[],
custom_endpoints
:
[],
linuxdo_oauth_enabled
:
false
,
sora_client_enabled
:
false
,
backend_mode_enabled
:
false
,
version
:
siteVersion
.
value
}
...
...
frontend/src/types/index.ts
View file @
eb2dce92
...
...
@@ -45,9 +45,6 @@ export interface AdminUser extends User {
group_rates
?:
Record
<
number
,
number
>
// 当前并发数(仅管理员列表接口返回)
current_concurrency
?:
number
// Sora 存储配额(字节)
sora_storage_quota_bytes
:
number
sora_storage_used_bytes
:
number
}
export
interface
LoginRequest
{
...
...
@@ -112,7 +109,6 @@ export interface PublicSettings {
custom_menu_items
:
CustomMenuItem
[]
custom_endpoints
:
CustomEndpoint
[]
linuxdo_oauth_enabled
:
boolean
sora_client_enabled
:
boolean
backend_mode_enabled
:
boolean
version
:
string
}
...
...
@@ -366,7 +362,7 @@ export interface PaginationConfig {
// ==================== API Key & Group Types ====================
export
type
GroupPlatform
=
'
anthropic
'
|
'
openai
'
|
'
gemini
'
|
'
antigravity
'
|
'
sora
'
export
type
GroupPlatform
=
'
anthropic
'
|
'
openai
'
|
'
gemini
'
|
'
antigravity
'
export
type
SubscriptionType
=
'
standard
'
|
'
subscription
'
...
...
@@ -386,13 +382,6 @@ export interface Group {
image_price_1k
:
number
|
null
image_price_2k
:
number
|
null
image_price_4k
:
number
|
null
// Sora 按次计费配置
sora_image_price_360
:
number
|
null
sora_image_price_540
:
number
|
null
sora_video_price_per_request
:
number
|
null
sora_video_price_per_request_hd
:
number
|
null
// Sora 存储配额(字节)
sora_storage_quota_bytes
:
number
// Claude Code 客户端限制
claude_code_only
:
boolean
fallback_group_id
:
number
|
null
...
...
@@ -501,11 +490,6 @@ export interface CreateGroupRequest {
image_price_1k
?:
number
|
null
image_price_2k
?:
number
|
null
image_price_4k
?:
number
|
null
sora_image_price_360
?:
number
|
null
sora_image_price_540
?:
number
|
null
sora_video_price_per_request
?:
number
|
null
sora_video_price_per_request_hd
?:
number
|
null
sora_storage_quota_bytes
?:
number
claude_code_only
?:
boolean
fallback_group_id
?:
number
|
null
fallback_group_id_on_invalid_request
?:
number
|
null
...
...
@@ -532,11 +516,6 @@ export interface UpdateGroupRequest {
image_price_1k
?:
number
|
null
image_price_2k
?:
number
|
null
image_price_4k
?:
number
|
null
sora_image_price_360
?:
number
|
null
sora_image_price_540
?:
number
|
null
sora_video_price_per_request
?:
number
|
null
sora_video_price_per_request_hd
?:
number
|
null
sora_storage_quota_bytes
?:
number
claude_code_only
?:
boolean
fallback_group_id
?:
number
|
null
fallback_group_id_on_invalid_request
?:
number
|
null
...
...
@@ -550,7 +529,7 @@ export interface UpdateGroupRequest {
// ==================== Account & Proxy Types ====================
export
type
AccountPlatform
=
'
anthropic
'
|
'
openai
'
|
'
gemini
'
|
'
antigravity
'
|
'
sora
'
export
type
AccountPlatform
=
'
anthropic
'
|
'
openai
'
|
'
gemini
'
|
'
antigravity
'
export
type
AccountType
=
'
oauth
'
|
'
setup-token
'
|
'
apikey
'
|
'
upstream
'
|
'
bedrock
'
export
type
OAuthAddMethod
=
'
oauth
'
|
'
setup-token
'
export
type
ProxyProtocol
=
'
http
'
|
'
https
'
|
'
socks5
'
|
'
socks5h
'
...
...
frontend/src/utils/__tests__/soraTokenParser.spec.ts
deleted
100644 → 0
View file @
7b83d6e7
import
{
describe
,
expect
,
it
}
from
'
vitest
'
import
{
parseSoraRawTokens
}
from
'
@/utils/soraTokenParser
'
describe
(
'
parseSoraRawTokens
'
,
()
=>
{
it
(
'
parses sessionToken and accessToken from JSON payload
'
,
()
=>
{
const
payload
=
JSON
.
stringify
({
user
:
{
id
:
'
u1
'
},
accessToken
:
'
at-json-1
'
,
sessionToken
:
'
st-json-1
'
})
const
result
=
parseSoraRawTokens
(
payload
)
expect
(
result
.
sessionTokens
).
toEqual
([
'
st-json-1
'
])
expect
(
result
.
accessTokens
).
toEqual
([
'
at-json-1
'
])
})
it
(
'
supports plain session tokens (one per line)
'
,
()
=>
{
const
result
=
parseSoraRawTokens
(
'
st-1
\n
st-2
'
)
expect
(
result
.
sessionTokens
).
toEqual
([
'
st-1
'
,
'
st-2
'
])
expect
(
result
.
accessTokens
).
toEqual
([])
})
it
(
'
supports non-standard object snippets via regex
'
,
()
=>
{
const
raw
=
"
sessionToken: 'st-snippet', access_token:
\"
at-snippet
\"
"
const
result
=
parseSoraRawTokens
(
raw
)
expect
(
result
.
sessionTokens
).
toEqual
([
'
st-snippet
'
])
expect
(
result
.
accessTokens
).
toEqual
([
'
at-snippet
'
])
})
it
(
'
keeps unique tokens and extracts JWT-like plain line as AT too
'
,
()
=>
{
const
jwt
=
'
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.signature
'
const
raw
=
`st-dup\nst-dup\n
${
jwt
}
\n
${
JSON
.
stringify
({
sessionToken
:
'
st-json
'
,
accessToken
:
jwt
})}
`
const
result
=
parseSoraRawTokens
(
raw
)
expect
(
result
.
sessionTokens
).
toEqual
([
'
st-json
'
,
'
st-dup
'
])
expect
(
result
.
accessTokens
).
toEqual
([
jwt
])
})
it
(
'
parses session token from Set-Cookie line and strips cookie attributes
'
,
()
=>
{
const
raw
=
'
__Secure-next-auth.session-token.0=st-cookie-part-0; Domain=.chatgpt.com; Path=/; Expires=Thu, 28 May 2026 11:43:36 GMT; HttpOnly; Secure; SameSite=Lax
'
const
result
=
parseSoraRawTokens
(
raw
)
expect
(
result
.
sessionTokens
).
toEqual
([
'
st-cookie-part-0
'
])
expect
(
result
.
accessTokens
).
toEqual
([])
})
it
(
'
merges chunked session-token cookies by numeric suffix order
'
,
()
=>
{
const
raw
=
[
'
Set-Cookie: __Secure-next-auth.session-token.1=part-1; Path=/; HttpOnly
'
,
'
Set-Cookie: __Secure-next-auth.session-token.0=part-0; Path=/; HttpOnly
'
].
join
(
'
\n
'
)
const
result
=
parseSoraRawTokens
(
raw
)
expect
(
result
.
sessionTokens
).
toEqual
([
'
part-0part-1
'
])
expect
(
result
.
accessTokens
).
toEqual
([])
})
it
(
'
prefers latest duplicate chunk values when multiple cookie groups exist
'
,
()
=>
{
const
raw
=
[
'
Set-Cookie: __Secure-next-auth.session-token.0=old-0; Path=/; HttpOnly
'
,
'
Set-Cookie: __Secure-next-auth.session-token.1=old-1; Path=/; HttpOnly
'
,
'
Set-Cookie: __Secure-next-auth.session-token.0=new-0; Path=/; HttpOnly
'
,
'
Set-Cookie: __Secure-next-auth.session-token.1=new-1; Path=/; HttpOnly
'
].
join
(
'
\n
'
)
const
result
=
parseSoraRawTokens
(
raw
)
expect
(
result
.
sessionTokens
).
toEqual
([
'
new-0new-1
'
])
expect
(
result
.
accessTokens
).
toEqual
([])
})
it
(
'
uses latest complete chunk group and ignores incomplete latest group
'
,
()
=>
{
const
raw
=
[
'
set-cookie
'
,
'
__Secure-next-auth.session-token.0=ok-0; Domain=.chatgpt.com; Path=/
'
,
'
set-cookie
'
,
'
__Secure-next-auth.session-token.1=ok-1; Domain=.chatgpt.com; Path=/
'
,
'
set-cookie
'
,
'
__Secure-next-auth.session-token.0=partial-0; Domain=.chatgpt.com; Path=/
'
].
join
(
'
\n
'
)
const
result
=
parseSoraRawTokens
(
raw
)
expect
(
result
.
sessionTokens
).
toEqual
([
'
ok-0ok-1
'
])
expect
(
result
.
accessTokens
).
toEqual
([])
})
})
frontend/src/utils/soraTokenParser.ts
deleted
100644 → 0
View file @
7b83d6e7
export
interface
ParsedSoraTokens
{
sessionTokens
:
string
[]
accessTokens
:
string
[]
}
const
sessionKeyNames
=
new
Set
([
'
sessiontoken
'
,
'
session_token
'
,
'
st
'
])
const
accessKeyNames
=
new
Set
([
'
accesstoken
'
,
'
access_token
'
,
'
at
'
])
const
sessionRegexes
=
[
/
\b
sessionToken
\b\s
*:
\s
*
[
"'
]([^
"'
]
+
)[
"'
]
/gi
,
/
\b
session_token
\b\s
*:
\s
*
[
"'
]([^
"'
]
+
)[
"'
]
/gi
]
const
accessRegexes
=
[
/
\b
accessToken
\b\s
*:
\s
*
[
"'
]([^
"'
]
+
)[
"'
]
/gi
,
/
\b
access_token
\b\s
*:
\s
*
[
"'
]([^
"'
]
+
)[
"'
]
/gi
]
const
sessionCookieRegex
=
/
(?:
^|
[\n\r
;
])\s
*
(?:(?:
set-cookie|cookie
)\s
*:
\s
*
)?
__Secure-
(?:
next-auth|authjs
)\.
session-token
(?:\.(\d
+
))?
=
([^
;
\r\n]
+
)
/gi
interface
SessionCookieChunk
{
index
:
number
value
:
string
}
const
ignoredPlainLines
=
new
Set
([
'
set-cookie
'
,
'
cookie
'
,
'
strict-transport-security
'
,
'
vary
'
,
'
x-content-type-options
'
,
'
x-openai-proxy-wasm
'
])
function
sanitizeToken
(
raw
:
string
):
string
{
return
raw
.
trim
().
replace
(
/^
[
"'`
]
+|
[
"'`,;
]
+$/g
,
''
)
}
function
addUnique
(
list
:
string
[],
seen
:
Set
<
string
>
,
rawValue
:
string
):
void
{
const
token
=
sanitizeToken
(
rawValue
)
if
(
!
token
||
seen
.
has
(
token
))
{
return
}
seen
.
add
(
token
)
list
.
push
(
token
)
}
function
isLikelyJWT
(
token
:
string
):
boolean
{
if
(
!
token
.
startsWith
(
'
eyJ
'
))
{
return
false
}
return
token
.
split
(
'
.
'
).
length
===
3
}
function
collectFromObject
(
value
:
unknown
,
sessionTokens
:
string
[],
sessionSeen
:
Set
<
string
>
,
accessTokens
:
string
[],
accessSeen
:
Set
<
string
>
):
void
{
if
(
Array
.
isArray
(
value
))
{
for
(
const
item
of
value
)
{
collectFromObject
(
item
,
sessionTokens
,
sessionSeen
,
accessTokens
,
accessSeen
)
}
return
}
if
(
!
value
||
typeof
value
!==
'
object
'
)
{
return
}
for
(
const
[
key
,
fieldValue
]
of
Object
.
entries
(
value
as
Record
<
string
,
unknown
>
))
{
if
(
typeof
fieldValue
===
'
string
'
)
{
const
normalizedKey
=
key
.
toLowerCase
()
if
(
sessionKeyNames
.
has
(
normalizedKey
))
{
addUnique
(
sessionTokens
,
sessionSeen
,
fieldValue
)
}
if
(
accessKeyNames
.
has
(
normalizedKey
))
{
addUnique
(
accessTokens
,
accessSeen
,
fieldValue
)
}
continue
}
collectFromObject
(
fieldValue
,
sessionTokens
,
sessionSeen
,
accessTokens
,
accessSeen
)
}
}
function
collectFromJSONString
(
raw
:
string
,
sessionTokens
:
string
[],
sessionSeen
:
Set
<
string
>
,
accessTokens
:
string
[],
accessSeen
:
Set
<
string
>
):
void
{
const
trimmed
=
raw
.
trim
()
if
(
!
trimmed
)
{
return
}
const
candidates
=
[
trimmed
]
const
firstBrace
=
trimmed
.
indexOf
(
'
{
'
)
const
lastBrace
=
trimmed
.
lastIndexOf
(
'
}
'
)
if
(
firstBrace
>=
0
&&
lastBrace
>
firstBrace
)
{
candidates
.
push
(
trimmed
.
slice
(
firstBrace
,
lastBrace
+
1
))
}
for
(
const
candidate
of
candidates
)
{
try
{
const
parsed
=
JSON
.
parse
(
candidate
)
collectFromObject
(
parsed
,
sessionTokens
,
sessionSeen
,
accessTokens
,
accessSeen
)
return
}
catch
{
// ignore and keep trying other candidates
}
}
}
function
collectByRegex
(
raw
:
string
,
regexes
:
RegExp
[],
tokens
:
string
[],
seen
:
Set
<
string
>
):
void
{
for
(
const
regex
of
regexes
)
{
regex
.
lastIndex
=
0
let
match
:
RegExpExecArray
|
null
match
=
regex
.
exec
(
raw
)
while
(
match
)
{
if
(
match
[
1
])
{
addUnique
(
tokens
,
seen
,
match
[
1
])
}
match
=
regex
.
exec
(
raw
)
}
}
}
function
collectFromSessionCookies
(
raw
:
string
,
sessionTokens
:
string
[],
sessionSeen
:
Set
<
string
>
):
void
{
const
chunkMatches
:
SessionCookieChunk
[]
=
[]
const
singleValues
:
string
[]
=
[]
sessionCookieRegex
.
lastIndex
=
0
let
match
:
RegExpExecArray
|
null
match
=
sessionCookieRegex
.
exec
(
raw
)
while
(
match
)
{
const
chunkIndex
=
match
[
1
]
const
rawValue
=
match
[
2
]
const
value
=
sanitizeToken
(
rawValue
||
''
)
if
(
value
)
{
if
(
chunkIndex
!==
undefined
&&
chunkIndex
!==
''
)
{
const
idx
=
Number
.
parseInt
(
chunkIndex
,
10
)
if
(
Number
.
isInteger
(
idx
)
&&
idx
>=
0
)
{
chunkMatches
.
push
({
index
:
idx
,
value
})
}
}
else
{
singleValues
.
push
(
value
)
}
}
match
=
sessionCookieRegex
.
exec
(
raw
)
}
const
mergedChunkToken
=
mergeLatestChunkedSessionToken
(
chunkMatches
)
if
(
mergedChunkToken
)
{
addUnique
(
sessionTokens
,
sessionSeen
,
mergedChunkToken
)
}
for
(
const
value
of
singleValues
)
{
addUnique
(
sessionTokens
,
sessionSeen
,
value
)
}
}
function
mergeChunkSegment
(
chunks
:
SessionCookieChunk
[],
requiredMaxIndex
:
number
,
requireComplete
:
boolean
):
string
{
if
(
chunks
.
length
===
0
)
{
return
''
}
const
byIndex
=
new
Map
<
number
,
string
>
()
for
(
const
chunk
of
chunks
)
{
byIndex
.
set
(
chunk
.
index
,
chunk
.
value
)
}
if
(
!
byIndex
.
has
(
0
))
{
return
''
}
if
(
requireComplete
)
{
for
(
let
i
=
0
;
i
<=
requiredMaxIndex
;
i
++
)
{
if
(
!
byIndex
.
has
(
i
))
{
return
''
}
}
}
const
orderedIndexes
=
Array
.
from
(
byIndex
.
keys
()).
sort
((
a
,
b
)
=>
a
-
b
)
return
orderedIndexes
.
map
((
idx
)
=>
byIndex
.
get
(
idx
)
||
''
).
join
(
''
)
}
function
mergeLatestChunkedSessionToken
(
chunks
:
SessionCookieChunk
[]):
string
{
if
(
chunks
.
length
===
0
)
{
return
''
}
const
requiredMaxIndex
=
chunks
.
reduce
((
max
,
chunk
)
=>
Math
.
max
(
max
,
chunk
.
index
),
0
)
const
groupStarts
:
number
[]
=
[]
chunks
.
forEach
((
chunk
,
idx
)
=>
{
if
(
chunk
.
index
===
0
)
{
groupStarts
.
push
(
idx
)
}
})
if
(
groupStarts
.
length
===
0
)
{
return
mergeChunkSegment
(
chunks
,
requiredMaxIndex
,
false
)
}
for
(
let
i
=
groupStarts
.
length
-
1
;
i
>=
0
;
i
--
)
{
const
start
=
groupStarts
[
i
]
const
end
=
i
+
1
<
groupStarts
.
length
?
groupStarts
[
i
+
1
]
:
chunks
.
length
const
merged
=
mergeChunkSegment
(
chunks
.
slice
(
start
,
end
),
requiredMaxIndex
,
true
)
if
(
merged
)
{
return
merged
}
}
return
mergeChunkSegment
(
chunks
,
requiredMaxIndex
,
false
)
}
function
collectPlainLines
(
raw
:
string
,
sessionTokens
:
string
[],
sessionSeen
:
Set
<
string
>
,
accessTokens
:
string
[],
accessSeen
:
Set
<
string
>
):
void
{
const
lines
=
raw
.
split
(
'
\n
'
)
.
map
((
line
)
=>
line
.
trim
())
.
filter
((
line
)
=>
line
.
length
>
0
)
for
(
const
line
of
lines
)
{
const
normalized
=
line
.
toLowerCase
()
if
(
ignoredPlainLines
.
has
(
normalized
))
{
continue
}
if
(
/^__secure-
(
next-auth|authjs
)\.
session-token
(\.\d
+
)?
=/i
.
test
(
line
))
{
continue
}
if
(
line
.
includes
(
'
;
'
))
{
continue
}
if
(
/^
[
a-zA-Z_
][
a-zA-Z0-9_
]
*=/
.
test
(
line
))
{
const
parts
=
line
.
split
(
'
=
'
,
2
)
const
key
=
parts
[
0
]?.
trim
().
toLowerCase
()
const
value
=
parts
[
1
]?.
trim
()
||
''
if
(
key
&&
sessionKeyNames
.
has
(
key
))
{
addUnique
(
sessionTokens
,
sessionSeen
,
value
)
continue
}
if
(
key
&&
accessKeyNames
.
has
(
key
))
{
addUnique
(
accessTokens
,
accessSeen
,
value
)
continue
}
}
if
(
line
.
includes
(
'
{
'
)
||
line
.
includes
(
'
}
'
)
||
line
.
includes
(
'
:
'
)
||
/
\s
/
.
test
(
line
))
{
continue
}
if
(
isLikelyJWT
(
line
))
{
addUnique
(
accessTokens
,
accessSeen
,
line
)
continue
}
addUnique
(
sessionTokens
,
sessionSeen
,
line
)
}
}
export
function
parseSoraRawTokens
(
rawInput
:
string
):
ParsedSoraTokens
{
const
raw
=
rawInput
.
trim
()
if
(
!
raw
)
{
return
{
sessionTokens
:
[],
accessTokens
:
[]
}
}
const
sessionTokens
:
string
[]
=
[]
const
accessTokens
:
string
[]
=
[]
const
sessionSeen
=
new
Set
<
string
>
()
const
accessSeen
=
new
Set
<
string
>
()
collectFromJSONString
(
raw
,
sessionTokens
,
sessionSeen
,
accessTokens
,
accessSeen
)
collectByRegex
(
raw
,
sessionRegexes
,
sessionTokens
,
sessionSeen
)
collectByRegex
(
raw
,
accessRegexes
,
accessTokens
,
accessSeen
)
collectFromSessionCookies
(
raw
,
sessionTokens
,
sessionSeen
)
collectPlainLines
(
raw
,
sessionTokens
,
sessionSeen
,
accessTokens
,
accessSeen
)
return
{
sessionTokens
,
accessTokens
}
}
frontend/src/views/admin/ChannelsView.vue
View file @
eb2dce92
...
...
@@ -523,7 +523,6 @@ function getPlatformTextColor(platform: string): string {
case
'
openai
'
:
return
'
text-emerald-600 dark:text-emerald-400
'
case
'
gemini
'
:
return
'
text-blue-600 dark:text-blue-400
'
case
'
antigravity
'
:
return
'
text-purple-600 dark:text-purple-400
'
case
'
sora
'
:
return
'
text-rose-600 dark:text-rose-400
'
default
:
return
'
text-gray-600 dark:text-gray-400
'
}
}
...
...
@@ -534,7 +533,6 @@ function getRateBadgeClass(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/views/admin/DataManagementView.vue
deleted
100644 → 0
View file @
7b83d6e7
<
template
>
<div
class=
"space-y-6"
>
<div
class=
"card p-6"
>
<div
class=
"mb-4 flex flex-wrap items-center justify-between gap-3"
>
<div>
<h3
class=
"text-base font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
admin.settings.soraS3.title
'
)
}}
</h3>
<p
class=
"mt-1 text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.settings.soraS3.description
'
)
}}
</p>
</div>
<div
class=
"flex flex-wrap gap-2"
>
<button
type=
"button"
class=
"btn btn-secondary btn-sm"
@
click=
"startCreateSoraProfile"
>
{{
t
(
'
admin.settings.soraS3.newProfile
'
)
}}
</button>
<button
type=
"button"
class=
"btn btn-secondary btn-sm"
:disabled=
"loadingSoraProfiles"
@
click=
"loadSoraS3Profiles"
>
{{
loadingSoraProfiles
?
t
(
'
common.loading
'
)
:
t
(
'
admin.settings.soraS3.reloadProfiles
'
)
}}
</button>
</div>
</div>
<div
class=
"overflow-x-auto"
>
<table
class=
"w-full min-w-[1000px] text-sm"
>
<thead>
<tr
class=
"border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500 dark:border-dark-700 dark:text-gray-400"
>
<th
class=
"py-2 pr-4"
>
{{
t
(
'
admin.settings.soraS3.columns.profile
'
)
}}
</th>
<th
class=
"py-2 pr-4"
>
{{
t
(
'
admin.settings.soraS3.columns.active
'
)
}}
</th>
<th
class=
"py-2 pr-4"
>
{{
t
(
'
admin.settings.soraS3.columns.endpoint
'
)
}}
</th>
<th
class=
"py-2 pr-4"
>
{{
t
(
'
admin.settings.soraS3.columns.bucket
'
)
}}
</th>
<th
class=
"py-2 pr-4"
>
{{
t
(
'
admin.settings.soraS3.columns.quota
'
)
}}
</th>
<th
class=
"py-2 pr-4"
>
{{
t
(
'
admin.settings.soraS3.columns.updatedAt
'
)
}}
</th>
<th
class=
"py-2"
>
{{
t
(
'
admin.settings.soraS3.columns.actions
'
)
}}
</th>
</tr>
</thead>
<tbody>
<tr
v-for=
"profile in soraS3Profiles"
:key=
"profile.profile_id"
class=
"border-b border-gray-100 align-top dark:border-dark-800"
>
<td
class=
"py-3 pr-4"
>
<div
class=
"font-mono text-xs"
>
{{
profile
.
profile_id
}}
</div>
<div
class=
"mt-1 text-xs text-gray-600 dark:text-gray-400"
>
{{
profile
.
name
}}
</div>
</td>
<td
class=
"py-3 pr-4"
>
<span
class=
"rounded px-2 py-0.5 text-xs"
:class=
"profile.is_active ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' : 'bg-gray-100 text-gray-700 dark:bg-dark-800 dark:text-gray-300'"
>
{{
profile
.
is_active
?
t
(
'
common.enabled
'
)
:
t
(
'
common.disabled
'
)
}}
</span>
</td>
<td
class=
"py-3 pr-4 text-xs"
>
<div>
{{
profile
.
endpoint
||
'
-
'
}}
</div>
<div
class=
"mt-1 text-gray-500 dark:text-gray-400"
>
{{
profile
.
region
||
'
-
'
}}
</div>
</td>
<td
class=
"py-3 pr-4 text-xs"
>
{{
profile
.
bucket
||
'
-
'
}}
</td>
<td
class=
"py-3 pr-4 text-xs"
>
{{
formatStorageQuotaGB
(
profile
.
default_storage_quota_bytes
)
}}
</td>
<td
class=
"py-3 pr-4 text-xs"
>
{{
formatDate
(
profile
.
updated_at
)
}}
</td>
<td
class=
"py-3 text-xs"
>
<div
class=
"flex flex-wrap gap-2"
>
<button
type=
"button"
class=
"btn btn-secondary btn-xs"
@
click=
"editSoraProfile(profile.profile_id)"
>
{{
t
(
'
common.edit
'
)
}}
</button>
<button
v-if=
"!profile.is_active"
type=
"button"
class=
"btn btn-secondary btn-xs"
:disabled=
"activatingSoraProfile"
@
click=
"activateSoraProfile(profile.profile_id)"
>
{{
t
(
'
admin.settings.soraS3.activateProfile
'
)
}}
</button>
<button
type=
"button"
class=
"btn btn-danger btn-xs"
:disabled=
"deletingSoraProfile"
@
click=
"removeSoraProfile(profile.profile_id)"
>
{{
t
(
'
common.delete
'
)
}}
</button>
</div>
</td>
</tr>
<tr
v-if=
"soraS3Profiles.length === 0"
>
<td
colspan=
"7"
class=
"py-6 text-center text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.settings.soraS3.empty
'
)
}}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<Teleport
to=
"body"
>
<Transition
name=
"dm-drawer-mask"
>
<div
v-if=
"soraProfileDrawerOpen"
class=
"fixed inset-0 z-[54] bg-black/40 backdrop-blur-sm"
@
click=
"closeSoraProfileDrawer"
></div>
</Transition>
<Transition
name=
"dm-drawer-panel"
>
<div
v-if=
"soraProfileDrawerOpen"
class=
"fixed inset-y-0 right-0 z-[55] flex h-full w-full max-w-2xl flex-col border-l border-gray-200 bg-white shadow-2xl dark:border-dark-700 dark:bg-dark-900"
>
<div
class=
"flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-dark-700"
>
<h4
class=
"text-sm font-semibold text-gray-900 dark:text-white"
>
{{
creatingSoraProfile
?
t
(
'
admin.settings.soraS3.createTitle
'
)
:
t
(
'
admin.settings.soraS3.editTitle
'
)
}}
</h4>
<button
type=
"button"
class=
"rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-dark-800 dark:hover:text-gray-200"
@
click=
"closeSoraProfileDrawer"
>
✕
</button>
</div>
<div
class=
"flex-1 overflow-y-auto p-4"
>
<div
class=
"grid grid-cols-1 gap-3 md:grid-cols-2"
>
<input
v-model=
"soraProfileForm.profile_id"
class=
"input w-full"
:placeholder=
"t('admin.settings.soraS3.profileID')"
:disabled=
"!creatingSoraProfile"
/>
<input
v-model=
"soraProfileForm.name"
class=
"input w-full"
:placeholder=
"t('admin.settings.soraS3.profileName')"
/>
<label
class=
"inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 md:col-span-2"
>
<input
v-model=
"soraProfileForm.enabled"
type=
"checkbox"
/>
<span>
{{
t
(
'
admin.settings.soraS3.enabled
'
)
}}
</span>
</label>
<input
v-model=
"soraProfileForm.endpoint"
class=
"input w-full"
:placeholder=
"t('admin.settings.soraS3.endpoint')"
/>
<input
v-model=
"soraProfileForm.region"
class=
"input w-full"
:placeholder=
"t('admin.settings.soraS3.region')"
/>
<input
v-model=
"soraProfileForm.bucket"
class=
"input w-full"
:placeholder=
"t('admin.settings.soraS3.bucket')"
/>
<input
v-model=
"soraProfileForm.prefix"
class=
"input w-full"
:placeholder=
"t('admin.settings.soraS3.prefix')"
/>
<input
v-model=
"soraProfileForm.access_key_id"
class=
"input w-full"
:placeholder=
"t('admin.settings.soraS3.accessKeyId')"
/>
<input
v-model=
"soraProfileForm.secret_access_key"
type=
"password"
class=
"input w-full"
:placeholder=
"soraProfileForm.secret_access_key_configured ? t('admin.settings.soraS3.secretConfigured') : t('admin.settings.soraS3.secretAccessKey')"
/>
<input
v-model=
"soraProfileForm.cdn_url"
class=
"input w-full"
:placeholder=
"t('admin.settings.soraS3.cdnUrl')"
/>
<div>
<input
v-model.number=
"soraProfileForm.default_storage_quota_gb"
type=
"number"
min=
"0"
step=
"0.1"
class=
"input w-full"
:placeholder=
"t('admin.settings.soraS3.defaultQuota')"
/>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.settings.soraS3.defaultQuotaHint
'
)
}}
</p>
</div>
<label
class=
"inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300"
>
<input
v-model=
"soraProfileForm.force_path_style"
type=
"checkbox"
/>
<span>
{{
t
(
'
admin.settings.soraS3.forcePathStyle
'
)
}}
</span>
</label>
<label
v-if=
"creatingSoraProfile"
class=
"inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 md:col-span-2"
>
<input
v-model=
"soraProfileForm.set_active"
type=
"checkbox"
/>
<span>
{{
t
(
'
admin.settings.soraS3.setActive
'
)
}}
</span>
</label>
</div>
</div>
<div
class=
"flex flex-wrap justify-end gap-2 border-t border-gray-200 p-4 dark:border-dark-700"
>
<button
type=
"button"
class=
"btn btn-secondary btn-sm"
@
click=
"closeSoraProfileDrawer"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"button"
class=
"btn btn-secondary btn-sm"
:disabled=
"testingSoraProfile || !soraProfileForm.enabled"
@
click=
"testSoraProfileConnection"
>
{{
testingSoraProfile
?
t
(
'
common.loading
'
)
:
t
(
'
admin.settings.soraS3.testConnection
'
)
}}
</button>
<button
type=
"button"
class=
"btn btn-primary btn-sm"
:disabled=
"savingSoraProfile"
@
click=
"saveSoraProfile"
>
{{
savingSoraProfile
?
t
(
'
common.loading
'
)
:
t
(
'
admin.settings.soraS3.saveProfile
'
)
}}
</button>
</div>
</div>
</Transition>
</Teleport>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
onMounted
,
ref
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
type
{
SoraS3Profile
}
from
'
@/api/admin/settings
'
import
{
adminAPI
}
from
'
@/api
'
import
{
useAppStore
}
from
'
@/stores
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
loadingSoraProfiles
=
ref
(
false
)
const
savingSoraProfile
=
ref
(
false
)
const
testingSoraProfile
=
ref
(
false
)
const
activatingSoraProfile
=
ref
(
false
)
const
deletingSoraProfile
=
ref
(
false
)
const
creatingSoraProfile
=
ref
(
false
)
const
soraProfileDrawerOpen
=
ref
(
false
)
const
soraS3Profiles
=
ref
<
SoraS3Profile
[]
>
([])
const
selectedSoraProfileID
=
ref
(
''
)
type
SoraS3ProfileForm
=
{
profile_id
:
string
name
:
string
set_active
:
boolean
enabled
:
boolean
endpoint
:
string
region
:
string
bucket
:
string
access_key_id
:
string
secret_access_key
:
string
secret_access_key_configured
:
boolean
prefix
:
string
force_path_style
:
boolean
cdn_url
:
string
default_storage_quota_gb
:
number
}
const
soraProfileForm
=
ref
<
SoraS3ProfileForm
>
(
newDefaultSoraS3ProfileForm
())
async
function
loadSoraS3Profiles
()
{
loadingSoraProfiles
.
value
=
true
try
{
const
result
=
await
adminAPI
.
settings
.
listSoraS3Profiles
()
soraS3Profiles
.
value
=
result
.
items
||
[]
if
(
!
creatingSoraProfile
.
value
)
{
const
stillExists
=
selectedSoraProfileID
.
value
?
soraS3Profiles
.
value
.
some
((
item
)
=>
item
.
profile_id
===
selectedSoraProfileID
.
value
)
:
false
if
(
!
stillExists
)
{
selectedSoraProfileID
.
value
=
pickPreferredSoraProfileID
()
}
syncSoraProfileFormWithSelection
()
}
}
catch
(
error
)
{
appStore
.
showError
((
error
as
{
message
?:
string
})?.
message
||
t
(
'
errors.networkError
'
))
}
finally
{
loadingSoraProfiles
.
value
=
false
}
}
function
startCreateSoraProfile
()
{
creatingSoraProfile
.
value
=
true
selectedSoraProfileID
.
value
=
''
soraProfileForm
.
value
=
newDefaultSoraS3ProfileForm
()
soraProfileDrawerOpen
.
value
=
true
}
function
editSoraProfile
(
profileID
:
string
)
{
selectedSoraProfileID
.
value
=
profileID
creatingSoraProfile
.
value
=
false
syncSoraProfileFormWithSelection
()
soraProfileDrawerOpen
.
value
=
true
}
function
closeSoraProfileDrawer
()
{
soraProfileDrawerOpen
.
value
=
false
if
(
creatingSoraProfile
.
value
)
{
creatingSoraProfile
.
value
=
false
selectedSoraProfileID
.
value
=
pickPreferredSoraProfileID
()
syncSoraProfileFormWithSelection
()
}
}
async
function
saveSoraProfile
()
{
if
(
!
soraProfileForm
.
value
.
name
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.settings.soraS3.profileNameRequired
'
))
return
}
if
(
creatingSoraProfile
.
value
&&
!
soraProfileForm
.
value
.
profile_id
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.settings.soraS3.profileIDRequired
'
))
return
}
if
(
!
creatingSoraProfile
.
value
&&
!
selectedSoraProfileID
.
value
)
{
appStore
.
showError
(
t
(
'
admin.settings.soraS3.profileSelectRequired
'
))
return
}
if
(
soraProfileForm
.
value
.
enabled
)
{
if
(
!
soraProfileForm
.
value
.
endpoint
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.settings.soraS3.endpointRequired
'
))
return
}
if
(
!
soraProfileForm
.
value
.
bucket
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.settings.soraS3.bucketRequired
'
))
return
}
if
(
!
soraProfileForm
.
value
.
access_key_id
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.settings.soraS3.accessKeyRequired
'
))
return
}
}
savingSoraProfile
.
value
=
true
try
{
if
(
creatingSoraProfile
.
value
)
{
const
created
=
await
adminAPI
.
settings
.
createSoraS3Profile
({
profile_id
:
soraProfileForm
.
value
.
profile_id
.
trim
(),
name
:
soraProfileForm
.
value
.
name
.
trim
(),
set_active
:
soraProfileForm
.
value
.
set_active
,
enabled
:
soraProfileForm
.
value
.
enabled
,
endpoint
:
soraProfileForm
.
value
.
endpoint
,
region
:
soraProfileForm
.
value
.
region
,
bucket
:
soraProfileForm
.
value
.
bucket
,
access_key_id
:
soraProfileForm
.
value
.
access_key_id
,
secret_access_key
:
soraProfileForm
.
value
.
secret_access_key
||
undefined
,
prefix
:
soraProfileForm
.
value
.
prefix
,
force_path_style
:
soraProfileForm
.
value
.
force_path_style
,
cdn_url
:
soraProfileForm
.
value
.
cdn_url
,
default_storage_quota_bytes
:
Math
.
round
((
soraProfileForm
.
value
.
default_storage_quota_gb
||
0
)
*
1024
*
1024
*
1024
)
})
selectedSoraProfileID
.
value
=
created
.
profile_id
creatingSoraProfile
.
value
=
false
soraProfileDrawerOpen
.
value
=
false
appStore
.
showSuccess
(
t
(
'
admin.settings.soraS3.profileCreated
'
))
}
else
{
await
adminAPI
.
settings
.
updateSoraS3Profile
(
selectedSoraProfileID
.
value
,
{
name
:
soraProfileForm
.
value
.
name
.
trim
(),
enabled
:
soraProfileForm
.
value
.
enabled
,
endpoint
:
soraProfileForm
.
value
.
endpoint
,
region
:
soraProfileForm
.
value
.
region
,
bucket
:
soraProfileForm
.
value
.
bucket
,
access_key_id
:
soraProfileForm
.
value
.
access_key_id
,
secret_access_key
:
soraProfileForm
.
value
.
secret_access_key
||
undefined
,
prefix
:
soraProfileForm
.
value
.
prefix
,
force_path_style
:
soraProfileForm
.
value
.
force_path_style
,
cdn_url
:
soraProfileForm
.
value
.
cdn_url
,
default_storage_quota_bytes
:
Math
.
round
((
soraProfileForm
.
value
.
default_storage_quota_gb
||
0
)
*
1024
*
1024
*
1024
)
})
soraProfileDrawerOpen
.
value
=
false
appStore
.
showSuccess
(
t
(
'
admin.settings.soraS3.profileSaved
'
))
}
await
loadSoraS3Profiles
()
}
catch
(
error
)
{
appStore
.
showError
((
error
as
{
message
?:
string
})?.
message
||
t
(
'
errors.networkError
'
))
}
finally
{
savingSoraProfile
.
value
=
false
}
}
async
function
testSoraProfileConnection
()
{
testingSoraProfile
.
value
=
true
try
{
const
result
=
await
adminAPI
.
settings
.
testSoraS3Connection
({
profile_id
:
creatingSoraProfile
.
value
?
undefined
:
selectedSoraProfileID
.
value
,
enabled
:
soraProfileForm
.
value
.
enabled
,
endpoint
:
soraProfileForm
.
value
.
endpoint
,
region
:
soraProfileForm
.
value
.
region
,
bucket
:
soraProfileForm
.
value
.
bucket
,
access_key_id
:
soraProfileForm
.
value
.
access_key_id
,
secret_access_key
:
soraProfileForm
.
value
.
secret_access_key
||
undefined
,
prefix
:
soraProfileForm
.
value
.
prefix
,
force_path_style
:
soraProfileForm
.
value
.
force_path_style
,
cdn_url
:
soraProfileForm
.
value
.
cdn_url
,
default_storage_quota_bytes
:
Math
.
round
((
soraProfileForm
.
value
.
default_storage_quota_gb
||
0
)
*
1024
*
1024
*
1024
)
})
appStore
.
showSuccess
(
result
.
message
||
t
(
'
admin.settings.soraS3.testSuccess
'
))
}
catch
(
error
)
{
appStore
.
showError
((
error
as
{
message
?:
string
})?.
message
||
t
(
'
errors.networkError
'
))
}
finally
{
testingSoraProfile
.
value
=
false
}
}
async
function
activateSoraProfile
(
profileID
:
string
)
{
activatingSoraProfile
.
value
=
true
try
{
await
adminAPI
.
settings
.
setActiveSoraS3Profile
(
profileID
)
appStore
.
showSuccess
(
t
(
'
admin.settings.soraS3.profileActivated
'
))
await
loadSoraS3Profiles
()
}
catch
(
error
)
{
appStore
.
showError
((
error
as
{
message
?:
string
})?.
message
||
t
(
'
errors.networkError
'
))
}
finally
{
activatingSoraProfile
.
value
=
false
}
}
async
function
removeSoraProfile
(
profileID
:
string
)
{
if
(
!
window
.
confirm
(
t
(
'
admin.settings.soraS3.deleteConfirm
'
,
{
profileID
})))
{
return
}
deletingSoraProfile
.
value
=
true
try
{
await
adminAPI
.
settings
.
deleteSoraS3Profile
(
profileID
)
if
(
selectedSoraProfileID
.
value
===
profileID
)
{
selectedSoraProfileID
.
value
=
''
}
appStore
.
showSuccess
(
t
(
'
admin.settings.soraS3.profileDeleted
'
))
await
loadSoraS3Profiles
()
}
catch
(
error
)
{
appStore
.
showError
((
error
as
{
message
?:
string
})?.
message
||
t
(
'
errors.networkError
'
))
}
finally
{
deletingSoraProfile
.
value
=
false
}
}
function
formatDate
(
value
?:
string
):
string
{
if
(
!
value
)
{
return
'
-
'
}
const
date
=
new
Date
(
value
)
if
(
Number
.
isNaN
(
date
.
getTime
()))
{
return
value
}
return
date
.
toLocaleString
()
}
function
formatStorageQuotaGB
(
bytes
:
number
):
string
{
if
(
!
bytes
||
bytes
<=
0
)
{
return
'
0 GB
'
}
const
gb
=
bytes
/
(
1024
*
1024
*
1024
)
return
`
${
gb
.
toFixed
(
gb
>=
10
?
0
:
1
)}
GB`
}
function
pickPreferredSoraProfileID
():
string
{
const
active
=
soraS3Profiles
.
value
.
find
((
item
)
=>
item
.
is_active
)
if
(
active
)
{
return
active
.
profile_id
}
return
soraS3Profiles
.
value
[
0
]?.
profile_id
||
''
}
function
syncSoraProfileFormWithSelection
()
{
const
profile
=
soraS3Profiles
.
value
.
find
((
item
)
=>
item
.
profile_id
===
selectedSoraProfileID
.
value
)
soraProfileForm
.
value
=
newDefaultSoraS3ProfileForm
(
profile
)
}
function
newDefaultSoraS3ProfileForm
(
profile
?:
SoraS3Profile
):
SoraS3ProfileForm
{
if
(
!
profile
)
{
return
{
profile_id
:
''
,
name
:
''
,
set_active
:
false
,
enabled
:
false
,
endpoint
:
''
,
region
:
''
,
bucket
:
''
,
access_key_id
:
''
,
secret_access_key
:
''
,
secret_access_key_configured
:
false
,
prefix
:
'
sora/
'
,
force_path_style
:
false
,
cdn_url
:
''
,
default_storage_quota_gb
:
0
}
}
const
quotaBytes
=
profile
.
default_storage_quota_bytes
||
0
return
{
profile_id
:
profile
.
profile_id
,
name
:
profile
.
name
,
set_active
:
false
,
enabled
:
profile
.
enabled
,
endpoint
:
profile
.
endpoint
||
''
,
region
:
profile
.
region
||
''
,
bucket
:
profile
.
bucket
||
''
,
access_key_id
:
profile
.
access_key_id
||
''
,
secret_access_key
:
''
,
secret_access_key_configured
:
Boolean
(
profile
.
secret_access_key_configured
),
prefix
:
profile
.
prefix
||
''
,
force_path_style
:
Boolean
(
profile
.
force_path_style
),
cdn_url
:
profile
.
cdn_url
||
''
,
default_storage_quota_gb
:
Number
((
quotaBytes
/
(
1024
*
1024
*
1024
)).
toFixed
(
2
))
}
}
onMounted
(
async
()
=>
{
await
loadSoraS3Profiles
()
})
</
script
>
<
style
scoped
>
.dm-drawer-mask-enter-active
,
.dm-drawer-mask-leave-active
{
transition
:
opacity
0.2s
ease
;
}
.dm-drawer-mask-enter-from
,
.dm-drawer-mask-leave-to
{
opacity
:
0
;
}
.dm-drawer-panel-enter-active
,
.dm-drawer-panel-leave-active
{
transition
:
transform
0.24s
cubic-bezier
(
0.22
,
1
,
0.36
,
1
),
opacity
0.2s
ease
;
}
.dm-drawer-panel-enter-from
,
.dm-drawer-panel-leave-to
{
opacity
:
0.96
;
transform
:
translateX
(
100%
);
}
@media
(
prefers-reduced-motion
:
reduce
)
{
.dm-drawer-mask-enter-active
,
.dm-drawer-mask-leave-active
,
.dm-drawer-panel-enter-active
,
.dm-drawer-panel-leave-active
{
transition-duration
:
0s
;
}
}
</
style
>
frontend/src/views/admin/GroupsView.vue
View file @
eb2dce92
...
...
@@ -522,80 +522,7 @@
</div>
</div>
<!-- Sora 按次计费配置 -->
<div
v-if=
"createForm.platform === 'sora'"
class=
"border-t pt-4"
>
<label
class=
"block mb-2 font-medium text-gray-700 dark:text-gray-300"
>
{{ t('admin.groups.soraPricing.title') }}
</label>
<p
class=
"text-xs text-gray-500 dark:text-gray-400 mb-3"
>
{{ t('admin.groups.soraPricing.description') }}
</p>
<div
class=
"grid grid-cols-2 gap-3 mb-4"
>
<div>
<label
class=
"input-label"
>
{{ t('admin.groups.soraPricing.image360') }}
</label>
<input
v-model.number=
"createForm.sora_image_price_360"
type=
"number"
step=
"0.001"
min=
"0"
class=
"input"
placeholder=
"0.05"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.groups.soraPricing.image540') }}
</label>
<input
v-model.number=
"createForm.sora_image_price_540"
type=
"number"
step=
"0.001"
min=
"0"
class=
"input"
placeholder=
"0.08"
/>
</div>
</div>
<div
class=
"grid grid-cols-2 gap-3"
>
<div>
<label
class=
"input-label"
>
{{ t('admin.groups.soraPricing.video') }}
</label>
<input
v-model.number=
"createForm.sora_video_price_per_request"
type=
"number"
step=
"0.001"
min=
"0"
class=
"input"
placeholder=
"0.5"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.groups.soraPricing.videoHd') }}
</label>
<input
v-model.number=
"createForm.sora_video_price_per_request_hd"
type=
"number"
step=
"0.001"
min=
"0"
class=
"input"
placeholder=
"0.8"
/>
</div>
</div>
<div
class=
"mt-3"
>
<label
class=
"input-label"
>
{{ t('admin.groups.soraPricing.storageQuota') }}
</label>
<div
class=
"flex items-center gap-2"
>
<input
v-model.number=
"createForm.sora_storage_quota_gb"
type=
"number"
step=
"0.1"
min=
"0"
class=
"input"
placeholder=
"10"
/>
<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.groups.soraPricing.storageQuotaHint') }}
</p>
</div>
</div>
<!-- 支持的模型系列(仅 antigravity 平台) -->
<div
v-if=
"createForm.platform === 'antigravity'"
class=
"border-t pt-4"
>
...
...
@@ -1312,80 +1239,7 @@
</div>
</div>
<!-- Sora 按次计费配置 -->
<div
v-if=
"editForm.platform === 'sora'"
class=
"border-t pt-4"
>
<label
class=
"block mb-2 font-medium text-gray-700 dark:text-gray-300"
>
{{ t('admin.groups.soraPricing.title') }}
</label>
<p
class=
"text-xs text-gray-500 dark:text-gray-400 mb-3"
>
{{ t('admin.groups.soraPricing.description') }}
</p>
<div
class=
"grid grid-cols-2 gap-3 mb-4"
>
<div>
<label
class=
"input-label"
>
{{ t('admin.groups.soraPricing.image360') }}
</label>
<input
v-model.number=
"editForm.sora_image_price_360"
type=
"number"
step=
"0.001"
min=
"0"
class=
"input"
placeholder=
"0.05"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.groups.soraPricing.image540') }}
</label>
<input
v-model.number=
"editForm.sora_image_price_540"
type=
"number"
step=
"0.001"
min=
"0"
class=
"input"
placeholder=
"0.08"
/>
</div>
</div>
<div
class=
"grid grid-cols-2 gap-3"
>
<div>
<label
class=
"input-label"
>
{{ t('admin.groups.soraPricing.video') }}
</label>
<input
v-model.number=
"editForm.sora_video_price_per_request"
type=
"number"
step=
"0.001"
min=
"0"
class=
"input"
placeholder=
"0.5"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.groups.soraPricing.videoHd') }}
</label>
<input
v-model.number=
"editForm.sora_video_price_per_request_hd"
type=
"number"
step=
"0.001"
min=
"0"
class=
"input"
placeholder=
"0.8"
/>
</div>
</div>
<div
class=
"mt-3"
>
<label
class=
"input-label"
>
{{ t('admin.groups.soraPricing.storageQuota') }}
</label>
<div
class=
"flex items-center gap-2"
>
<input
v-model.number=
"editForm.sora_storage_quota_gb"
type=
"number"
step=
"0.1"
min=
"0"
class=
"input"
placeholder=
"10"
/>
<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.groups.soraPricing.storageQuotaHint') }}
</p>
</div>
</div>
<!-- 支持的模型系列(仅 antigravity 平台) -->
<div
v-if=
"editForm.platform === 'antigravity'"
class=
"border-t pt-4"
>
...
...
@@ -2001,8 +1855,7 @@ const platformOptions = computed(() => [
{
value
:
'
anthropic
'
,
label
:
'
Anthropic
'
},
{
value
:
'
openai
'
,
label
:
'
OpenAI
'
},
{
value
:
'
gemini
'
,
label
:
'
Gemini
'
},
{
value
:
'
antigravity
'
,
label
:
'
Antigravity
'
},
{
value
:
'
sora
'
,
label
:
'
Sora
'
}
{
value
:
'
antigravity
'
,
label
:
'
Antigravity
'
}
])
const
platformFilterOptions
=
computed
(()
=>
[
...
...
@@ -2010,8 +1863,7 @@ const platformFilterOptions = computed(() => [
{
value
:
'
anthropic
'
,
label
:
'
Anthropic
'
},
{
value
:
'
openai
'
,
label
:
'
OpenAI
'
},
{
value
:
'
gemini
'
,
label
:
'
Gemini
'
},
{
value
:
'
antigravity
'
,
label
:
'
Antigravity
'
},
{
value
:
'
sora
'
,
label
:
'
Sora
'
}
{
value
:
'
antigravity
'
,
label
:
'
Antigravity
'
}
])
const
editStatusOptions
=
computed
(()
=>
[
...
...
@@ -2160,12 +2012,6 @@ const createForm = reactive({
image_price_1k
:
null
as
number
|
null
,
image_price_2k
:
null
as
number
|
null
,
image_price_4k
:
null
as
number
|
null
,
// Sora 按次计费配置
sora_image_price_360
:
null
as
number
|
null
,
sora_image_price_540
:
null
as
number
|
null
,
sora_video_price_per_request
:
null
as
number
|
null
,
sora_video_price_per_request_hd
:
null
as
number
|
null
,
sora_storage_quota_gb
:
null
as
number
|
null
,
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only
:
false
,
fallback_group_id
:
null
as
number
|
null
,
...
...
@@ -2407,12 +2253,6 @@ const editForm = reactive({
image_price_1k
:
null
as
number
|
null
,
image_price_2k
:
null
as
number
|
null
,
image_price_4k
:
null
as
number
|
null
,
// Sora 按次计费配置
sora_image_price_360
:
null
as
number
|
null
,
sora_image_price_540
:
null
as
number
|
null
,
sora_video_price_per_request
:
null
as
number
|
null
,
sora_video_price_per_request_hd
:
null
as
number
|
null
,
sora_storage_quota_gb
:
null
as
number
|
null
,
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only
:
false
,
fallback_group_id
:
null
as
number
|
null
,
...
...
@@ -2559,11 +2399,6 @@ const closeCreateModal = () => {
createForm
.
image_price_1k
=
null
createForm
.
image_price_2k
=
null
createForm
.
image_price_4k
=
null
createForm
.
sora_image_price_360
=
null
createForm
.
sora_image_price_540
=
null
createForm
.
sora_video_price_per_request
=
null
createForm
.
sora_video_price_per_request_hd
=
null
createForm
.
sora_storage_quota_gb
=
null
createForm
.
claude_code_only
=
false
createForm
.
fallback_group_id
=
null
createForm
.
fallback_group_id_on_invalid_request
=
null
...
...
@@ -2602,13 +2437,11 @@ const handleCreateGroup = async () => {
submitting
.
value
=
true
try
{
// 构建请求数据,包含模型路由配置
const
{
sora_storage_quota_gb
:
createQuotaGb
,
...
createRest
}
=
createForm
const
requestData
=
{
...
create
Rest
,
...
create
Form
,
daily_limit_usd
:
normalizeOptionalLimit
(
createForm
.
daily_limit_usd
as
number
|
string
|
null
),
weekly_limit_usd
:
normalizeOptionalLimit
(
createForm
.
weekly_limit_usd
as
number
|
string
|
null
),
monthly_limit_usd
:
normalizeOptionalLimit
(
createForm
.
monthly_limit_usd
as
number
|
string
|
null
),
sora_storage_quota_bytes
:
createQuotaGb
?
Math
.
round
(
createQuotaGb
*
1024
*
1024
*
1024
)
:
0
,
model_routing
:
convertRoutingRulesToApiFormat
(
createModelRoutingRules
.
value
)
}
// v-model.number 清空输入框时产生 "",转为 null 让后端设为无限制
...
...
@@ -2648,11 +2481,6 @@ const handleEdit = async (group: AdminGroup) => {
editForm
.
image_price_1k
=
group
.
image_price_1k
editForm
.
image_price_2k
=
group
.
image_price_2k
editForm
.
image_price_4k
=
group
.
image_price_4k
editForm
.
sora_image_price_360
=
group
.
sora_image_price_360
editForm
.
sora_image_price_540
=
group
.
sora_image_price_540
editForm
.
sora_video_price_per_request
=
group
.
sora_video_price_per_request
editForm
.
sora_video_price_per_request_hd
=
group
.
sora_video_price_per_request_hd
editForm
.
sora_storage_quota_gb
=
group
.
sora_storage_quota_bytes
?
Number
((
group
.
sora_storage_quota_bytes
/
(
1024
*
1024
*
1024
)).
toFixed
(
2
))
:
null
editForm
.
claude_code_only
=
group
.
claude_code_only
||
false
editForm
.
fallback_group_id
=
group
.
fallback_group_id
editForm
.
fallback_group_id_on_invalid_request
=
group
.
fallback_group_id_on_invalid_request
...
...
@@ -2690,13 +2518,11 @@ const handleUpdateGroup = async () => {
submitting
.
value
=
true
try
{
// 转换 fallback_group_id: null -> 0 (后端使用 0 表示清除)
const
{
sora_storage_quota_gb
:
editQuotaGb
,
...
editRest
}
=
editForm
const
payload
=
{
...
edit
Rest
,
...
edit
Form
,
daily_limit_usd
:
normalizeOptionalLimit
(
editForm
.
daily_limit_usd
as
number
|
string
|
null
),
weekly_limit_usd
:
normalizeOptionalLimit
(
editForm
.
weekly_limit_usd
as
number
|
string
|
null
),
monthly_limit_usd
:
normalizeOptionalLimit
(
editForm
.
monthly_limit_usd
as
number
|
string
|
null
),
sora_storage_quota_bytes
:
editQuotaGb
?
Math
.
round
(
editQuotaGb
*
1024
*
1024
*
1024
)
:
0
,
fallback_group_id
:
editForm
.
fallback_group_id
===
null
?
0
:
editForm
.
fallback_group_id
,
fallback_group_id_on_invalid_request
:
editForm
.
fallback_group_id_on_invalid_request
===
null
...
...
frontend/src/views/admin/ProxiesView.vue
View file @
eb2dce92
...
...
@@ -1563,8 +1563,6 @@ const qualityTargetLabel = (target: string) => {
return
'
Anthropic
'
case
'
gemini
'
:
return
'
Gemini
'
case
'
sora
'
:
return
'
Sora
'
default
:
return
target
}
...
...
frontend/src/views/admin/SettingsView.vue
View file @
eb2dce92
...
...
@@ -1577,31 +1577,6 @@
<
/div
>
<
/div
>
<!--
Sora
Client
Toggle
-->
<
div
class
=
"
card
"
>
<
div
class
=
"
border-b border-gray-100 px-6 py-4 dark:border-dark-700
"
>
<
h2
class
=
"
text-lg font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.settings.soraClient.title
'
)
}}
<
/h2
>
<
p
class
=
"
mt-1 text-sm text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.soraClient.description
'
)
}}
<
/p
>
<
/div
>
<
div
class
=
"
space-y-6 p-6
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.settings.soraClient.enabled
'
)
}}
<
/label
>
<
p
class
=
"
text-sm text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.soraClient.enabledHint
'
)
}}
<
/p
>
<
/div
>
<
Toggle
v
-
model
=
"
form.sora_client_enabled
"
/>
<
/div
>
<
/div
>
<
/div
>
<!--
Custom
Menu
Items
-->
<
div
class
=
"
card
"
>
<
div
class
=
"
border-b border-gray-100 px-6 py-4 dark:border-dark-700
"
>
...
...
@@ -1956,13 +1931,8 @@
<
BackupSettings
/>
<
/div
>
<!--
Tab
:
Data
Management
-->
<
div
v
-
show
=
"
activeTab === 'data'
"
>
<
DataManagementSettings
/>
<
/div
>
<!--
Save
Button
-->
<
div
v
-
show
=
"
activeTab !== 'backup'
&& activeTab !== 'data'
"
class
=
"
flex justify-end
"
>
<
div
v
-
show
=
"
activeTab !== 'backup'
"
class
=
"
flex justify-end
"
>
<
button
type
=
"
submit
"
:
disabled
=
"
saving || loadFailed
"
class
=
"
btn btn-primary
"
>
<
svg
v
-
if
=
"
saving
"
class
=
"
h-4 w-4 animate-spin
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
>
<
circle
...
...
@@ -2005,7 +1975,6 @@ import GroupOptionItem from '@/components/common/GroupOptionItem.vue'
import
Toggle
from
'
@/components/common/Toggle.vue
'
import
ImageUpload
from
'
@/components/common/ImageUpload.vue
'
import
BackupSettings
from
'
@/views/admin/BackupView.vue
'
import
DataManagementSettings
from
'
@/views/admin/DataManagementView.vue
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
{
useAppStore
}
from
'
@/stores
'
import
{
useAdminSettingsStore
}
from
'
@/stores/adminSettings
'
...
...
@@ -2020,7 +1989,7 @@ const { t } = useI18n()
const
appStore
=
useAppStore
()
const
adminSettingsStore
=
useAdminSettingsStore
()
type
SettingsTab
=
'
general
'
|
'
security
'
|
'
users
'
|
'
gateway
'
|
'
email
'
|
'
backup
'
|
'
data
'
type
SettingsTab
=
'
general
'
|
'
security
'
|
'
users
'
|
'
gateway
'
|
'
email
'
|
'
backup
'
const
activeTab
=
ref
<
SettingsTab
>
(
'
general
'
)
const
settingsTabs
=
[
{
key
:
'
general
'
as
SettingsTab
,
icon
:
'
home
'
as
const
}
,
...
...
@@ -2029,7 +1998,6 @@ const settingsTabs = [
{
key
:
'
gateway
'
as
SettingsTab
,
icon
:
'
server
'
as
const
}
,
{
key
:
'
email
'
as
SettingsTab
,
icon
:
'
mail
'
as
const
}
,
{
key
:
'
backup
'
as
SettingsTab
,
icon
:
'
database
'
as
const
}
,
{
key
:
'
data
'
as
SettingsTab
,
icon
:
'
cube
'
as
const
}
,
]
const
{
copyToClipboard
}
=
useClipboard
()
...
...
@@ -2132,7 +2100,6 @@ const form = reactive<SettingsForm>({
hide_ccs_import_button
:
false
,
purchase_subscription_enabled
:
false
,
purchase_subscription_url
:
''
,
sora_client_enabled
:
false
,
custom_menu_items
:
[]
as
Array
<
{
id
:
string
;
label
:
string
;
icon_svg
:
string
;
url
:
string
;
visibility
:
'
user
'
|
'
admin
'
;
sort_order
:
number
}
>
,
custom_endpoints
:
[]
as
Array
<
{
name
:
string
;
endpoint
:
string
;
description
:
string
}
>
,
frontend_url
:
''
,
...
...
@@ -2456,7 +2423,6 @@ async function saveSettings() {
hide_ccs_import_button
:
form
.
hide_ccs_import_button
,
purchase_subscription_enabled
:
form
.
purchase_subscription_enabled
,
purchase_subscription_url
:
form
.
purchase_subscription_url
,
sora_client_enabled
:
form
.
sora_client_enabled
,
custom_menu_items
:
form
.
custom_menu_items
,
custom_endpoints
:
form
.
custom_endpoints
,
frontend_url
:
form
.
frontend_url
,
...
...
frontend/src/views/admin/SubscriptionsView.vue
View file @
eb2dce92
...
...
@@ -965,8 +965,7 @@ const platformFilterOptions = computed(() => [
{
value
:
'
anthropic
'
,
label
:
'
Anthropic
'
}
,
{
value
:
'
openai
'
,
label
:
'
OpenAI
'
}
,
{
value
:
'
gemini
'
,
label
:
'
Gemini
'
}
,
{
value
:
'
antigravity
'
,
label
:
'
Antigravity
'
}
,
{
value
:
'
sora
'
,
label
:
'
Sora
'
}
{
value
:
'
antigravity
'
,
label
:
'
Antigravity
'
}
])
// Group options for assign (only subscription type groups)
...
...
frontend/src/views/user/SoraView.vue
deleted
100644 → 0
View file @
7b83d6e7
<
template
>
<div
class=
"sora-root"
>
<!-- Sora 页面内容 -->
<div
class=
"sora-page"
>
<!-- 功能未启用提示 -->
<div
v-if=
"!soraEnabled"
class=
"sora-not-enabled"
>
<svg
class=
"sora-not-enabled-icon"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"1.5"
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.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z"
/>
</svg>
<h2
class=
"sora-not-enabled-title"
>
{{
t
(
'
sora.notEnabled
'
)
}}
</h2>
<p
class=
"sora-not-enabled-desc"
>
{{
t
(
'
sora.notEnabledDesc
'
)
}}
</p>
</div>
<!-- Sora 主界面 -->
<template
v-else
>
<!-- 自定义 Sora 头部 -->
<header
class=
"sora-header"
>
<div
class=
"sora-header-left"
>
<!-- 返回主页按钮 -->
<router-link
:to=
"dashboardPath"
class=
"sora-back-btn"
:title=
"t('common.back')"
>
<svg
width=
"20"
height=
"20"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
d=
"M15 19l-7-7 7-7"
/>
</svg>
</router-link>
<nav
class=
"sora-nav-tabs"
>
<button
v-for=
"tab in tabs"
:key=
"tab.key"
:class=
"['sora-nav-tab', activeTab === tab.key && 'active']"
@
click=
"activeTab = tab.key"
>
{{
tab
.
label
}}
</button>
</nav>
</div>
<div
class=
"sora-header-right"
>
<SoraQuotaBar
v-if=
"quota"
:quota=
"quota"
/>
<div
v-if=
"activeTaskCount > 0"
class=
"sora-queue-indicator"
>
<span
class=
"sora-queue-dot"
:class=
"
{ busy: hasGeneratingTask }">
</span>
<span>
{{
activeTaskCount
}}
{{
t
(
'
sora.queueTasks
'
)
}}
</span>
</div>
</div>
</header>
<!-- 内容区域 -->
<main
class=
"sora-main"
>
<SoraGeneratePage
v-show=
"activeTab === 'generate'"
@
task-count-change=
"onTaskCountChange"
/>
<SoraLibraryPage
v-show=
"activeTab === 'library'"
@
switch-to-generate=
"activeTab = 'generate'"
/>
</main>
</
template
>
</div>
</div>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
onMounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
,
useAuthStore
}
from
'
@/stores
'
import
SoraQuotaBar
from
'
@/components/sora/SoraQuotaBar.vue
'
import
SoraGeneratePage
from
'
@/components/sora/SoraGeneratePage.vue
'
import
SoraLibraryPage
from
'
@/components/sora/SoraLibraryPage.vue
'
import
soraAPI
,
{
type
QuotaInfo
}
from
'
@/api/sora
'
const
{
t
}
=
useI18n
()
const
authStore
=
useAuthStore
()
const
appStore
=
useAppStore
()
const
soraEnabled
=
computed
(()
=>
appStore
.
cachedPublicSettings
?.
sora_client_enabled
??
false
)
const
activeTab
=
ref
<
'
generate
'
|
'
library
'
>
(
'
generate
'
)
const
quota
=
ref
<
QuotaInfo
|
null
>
(
null
)
const
activeTaskCount
=
ref
(
0
)
const
hasGeneratingTask
=
ref
(
false
)
const
dashboardPath
=
computed
(()
=>
(
authStore
.
isAdmin
?
'
/admin/dashboard
'
:
'
/dashboard
'
))
const
tabs
=
computed
(()
=>
[
{
key
:
'
generate
'
as
const
,
label
:
t
(
'
sora.tabGenerate
'
)
},
{
key
:
'
library
'
as
const
,
label
:
t
(
'
sora.tabLibrary
'
)
}
])
function
onTaskCountChange
(
counts
:
{
active
:
number
;
generating
:
boolean
})
{
activeTaskCount
.
value
=
counts
.
active
hasGeneratingTask
.
value
=
counts
.
generating
}
onMounted
(
async
()
=>
{
if
(
!
soraEnabled
.
value
)
return
try
{
quota
.
value
=
await
soraAPI
.
getQuota
()
}
catch
{
// 配额查询失败不阻塞页面
}
})
</
script
>
<
style
scoped
>
/* ============================================================
Sora 主题 CSS 变量 — 亮色模式(跟随应用主题)
============================================================ */
.sora-root
{
--sora-bg-primary
:
#F9FAFB
;
--sora-bg-secondary
:
#FFFFFF
;
--sora-bg-tertiary
:
#F3F4F6
;
--sora-bg-elevated
:
#FFFFFF
;
--sora-bg-hover
:
#E5E7EB
;
--sora-bg-input
:
#FFFFFF
;
--sora-text-primary
:
#111827
;
--sora-text-secondary
:
#6B7280
;
--sora-text-tertiary
:
#9CA3AF
;
--sora-text-muted
:
#D1D5DB
;
--sora-accent-primary
:
#14b8a6
;
--sora-accent-secondary
:
#0d9488
;
--sora-accent-gradient
:
linear-gradient
(
135deg
,
#14b8a6
0%
,
#0d9488
100%
);
--sora-accent-gradient-hover
:
linear-gradient
(
135deg
,
#2dd4bf
0%
,
#14b8a6
100%
);
--sora-success
:
#10B981
;
--sora-warning
:
#F59E0B
;
--sora-error
:
#EF4444
;
--sora-info
:
#3B82F6
;
--sora-border-color
:
#E5E7EB
;
--sora-border-subtle
:
#F3F4F6
;
--sora-radius-sm
:
8px
;
--sora-radius-md
:
12px
;
--sora-radius-lg
:
16px
;
--sora-radius-xl
:
20px
;
--sora-radius-full
:
9999px
;
--sora-shadow-sm
:
0
1px
2px
rgba
(
0
,
0
,
0
,
0.05
);
--sora-shadow-md
:
0
4px
12px
rgba
(
0
,
0
,
0
,
0.08
);
--sora-shadow-lg
:
0
8px
32px
rgba
(
0
,
0
,
0
,
0.12
);
--sora-shadow-glow
:
0
0
20px
rgba
(
20
,
184
,
166
,
0.25
);
--sora-transition-fast
:
150ms
ease
;
--sora-transition-normal
:
250ms
ease
;
--sora-header-height
:
56px
;
--sora-header-bg
:
rgba
(
249
,
250
,
251
,
0.85
);
--sora-placeholder-gradient
:
linear-gradient
(
135deg
,
#e0e7ff
0%
,
#dbeafe
50%
,
#cffafe
100%
);
--sora-modal-backdrop
:
rgba
(
0
,
0
,
0
,
0.4
);
min-height
:
100vh
;
background
:
var
(
--sora-bg-primary
);
color
:
var
(
--sora-text-primary
);
font-family
:
-apple-system
,
BlinkMacSystemFont
,
"SF Pro Display"
,
"Segoe UI"
,
"PingFang SC"
,
"Noto Sans SC"
,
sans-serif
;
-webkit-font-smoothing
:
antialiased
;
-moz-osx-font-smoothing
:
grayscale
;
}
/* ============================================================
页面布局
============================================================ */
.sora-page
{
width
:
100%
;
}
/* ============================================================
头部导航栏
============================================================ */
.sora-header
{
position
:
sticky
;
top
:
0
;
z-index
:
30
;
height
:
var
(
--sora-header-height
);
background
:
var
(
--sora-header-bg
);
backdrop-filter
:
blur
(
20px
);
-webkit-backdrop-filter
:
blur
(
20px
);
border-bottom
:
1px
solid
var
(
--sora-border-subtle
);
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
padding
:
0
24px
;
}
.sora-header-left
{
display
:
flex
;
align-items
:
center
;
gap
:
24px
;
}
.sora-header-right
{
display
:
flex
;
align-items
:
center
;
gap
:
16px
;
}
/* 返回按钮 */
.sora-back-btn
{
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
width
:
36px
;
height
:
36px
;
border-radius
:
var
(
--sora-radius-sm
);
color
:
var
(
--sora-text-secondary
);
text-decoration
:
none
;
transition
:
all
var
(
--sora-transition-fast
);
}
.sora-back-btn
:hover
{
background
:
var
(
--sora-bg-tertiary
);
color
:
var
(
--sora-text-primary
);
}
/* Tab 导航 */
.sora-nav-tabs
{
display
:
flex
;
gap
:
4px
;
background
:
var
(
--sora-bg-secondary
);
border-radius
:
var
(
--sora-radius-full
);
padding
:
3px
;
}
.sora-nav-tab
{
padding
:
6px
20px
;
border-radius
:
var
(
--sora-radius-full
);
font-size
:
13px
;
font-weight
:
500
;
color
:
var
(
--sora-text-secondary
);
background
:
none
;
border
:
none
;
cursor
:
pointer
;
transition
:
all
var
(
--sora-transition-fast
);
user-select
:
none
;
}
.sora-nav-tab
:hover
{
color
:
var
(
--sora-text-primary
);
}
.sora-nav-tab.active
{
background
:
var
(
--sora-bg-tertiary
);
color
:
var
(
--sora-text-primary
);
}
/* 队列指示器 */
.sora-queue-indicator
{
display
:
flex
;
align-items
:
center
;
gap
:
6px
;
padding
:
6px
12px
;
background
:
var
(
--sora-bg-secondary
);
border-radius
:
var
(
--sora-radius-full
);
font-size
:
12px
;
color
:
var
(
--sora-text-secondary
);
}
.sora-queue-dot
{
width
:
8px
;
height
:
8px
;
border-radius
:
50%
;
background
:
var
(
--sora-success
);
animation
:
sora-pulse-dot
2s
ease-in-out
infinite
;
}
.sora-queue-dot.busy
{
background
:
var
(
--sora-warning
);
}
@keyframes
sora-pulse-dot
{
0
%,
100
%
{
opacity
:
1
;
}
50
%
{
opacity
:
0.4
;
}
}
/* ============================================================
主内容区
============================================================ */
.sora-main
{
min-height
:
calc
(
100vh
-
var
(
--sora-header-height
));
}
/* ============================================================
功能未启用
============================================================ */
.sora-not-enabled
{
display
:
flex
;
flex-direction
:
column
;
align-items
:
center
;
justify-content
:
center
;
min-height
:
100vh
;
text-align
:
center
;
padding
:
40px
;
}
.sora-not-enabled-icon
{
width
:
64px
;
height
:
64px
;
color
:
var
(
--sora-text-tertiary
);
margin-bottom
:
16px
;
}
.sora-not-enabled-title
{
font-size
:
20px
;
font-weight
:
600
;
color
:
var
(
--sora-text-secondary
);
margin-bottom
:
8px
;
}
.sora-not-enabled-desc
{
font-size
:
14px
;
color
:
var
(
--sora-text-tertiary
);
max-width
:
400px
;
}
/* ============================================================
响应式
============================================================ */
@media
(
max-width
:
900px
)
{
.sora-header
{
padding
:
0
16px
;
}
.sora-header-left
{
gap
:
12px
;
}
}
@media
(
max-width
:
600px
)
{
.sora-nav-tab
{
padding
:
5px
14px
;
font-size
:
12px
;
}
}
/* 滚动条 */
.sora-root
::-webkit-scrollbar
{
width
:
6px
;
height
:
6px
;
}
.sora-root
::-webkit-scrollbar-track
{
background
:
transparent
;
}
.sora-root
::-webkit-scrollbar-thumb
{
background
:
var
(
--sora-bg-hover
);
border-radius
:
3px
;
}
.sora-root
::-webkit-scrollbar-thumb:hover
{
background
:
var
(
--sora-text-tertiary
);
}
</
style
>
<
style
>
/* 暗色模式:必须明确命中 .sora-root,避免被 scoped 编译后的变量覆盖问题 */
html
.dark
.sora-root
{
--sora-bg-primary
:
#020617
;
--sora-bg-secondary
:
#0f172a
;
--sora-bg-tertiary
:
#1e293b
;
--sora-bg-elevated
:
#1e293b
;
--sora-bg-hover
:
#334155
;
--sora-bg-input
:
#0f172a
;
--sora-text-primary
:
#f1f5f9
;
--sora-text-secondary
:
#94a3b8
;
--sora-text-tertiary
:
#64748b
;
--sora-text-muted
:
#475569
;
--sora-border-color
:
#334155
;
--sora-border-subtle
:
#1e293b
;
--sora-shadow-sm
:
0
1px
2px
rgba
(
0
,
0
,
0
,
0.3
);
--sora-shadow-md
:
0
4px
12px
rgba
(
0
,
0
,
0
,
0.4
);
--sora-shadow-lg
:
0
8px
32px
rgba
(
0
,
0
,
0
,
0.5
);
--sora-shadow-glow
:
0
0
20px
rgba
(
20
,
184
,
166
,
0.3
);
--sora-header-bg
:
rgba
(
2
,
6
,
23
,
0.85
);
--sora-placeholder-gradient
:
linear-gradient
(
135deg
,
#1e293b
0%
,
#0f172a
50%
,
#020617
100%
);
--sora-modal-backdrop
:
rgba
(
0
,
0
,
0
,
0.7
);
}
</
style
>
Prev
1
…
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