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
1de18b89
Commit
1de18b89
authored
Mar 19, 2026
by
Wang Lvyuan
Browse files
merge: sync upstream/main before PR
parents
882518c1
9f6ab6b8
Changes
107
Hide whitespace changes
Inline
Side-by-side
frontend/src/i18n/locales/zh.ts
View file @
1de18b89
...
...
@@ -218,7 +218,7 @@ export default {
email
:
'
邮箱
'
,
password
:
'
密码
'
,
confirmPassword
:
'
确认密码
'
,
passwordPlaceholder
:
'
至少
6
个字符
'
,
passwordPlaceholder
:
'
至少
8
个字符
'
,
confirmPasswordPlaceholder
:
'
确认密码
'
,
passwordMismatch
:
'
密码不匹配
'
},
...
...
@@ -723,11 +723,14 @@ export default {
exporting
:
'
导出中...
'
,
preparingExport
:
'
正在准备导出...
'
,
model
:
'
模型
'
,
requestedModel
:
'
请求
'
,
upstreamModel
:
'
上游
'
,
reasoningEffort
:
'
推理强度
'
,
endpoint
:
'
端点
'
,
endpointDistribution
:
'
端点分布
'
,
inbound
:
'
入站
'
,
upstream
:
'
上游
'
,
mapping
:
'
映射
'
,
path
:
'
路径
'
,
inboundEndpoint
:
'
入站端点
'
,
upstreamEndpoint
:
'
上游端点
'
,
...
...
@@ -1561,6 +1564,8 @@ export default {
priority
:
'
优先级
'
,
apiKeys
:
'
API 密钥数
'
,
accounts
:
'
账号数
'
,
capacity
:
'
容量
'
,
usage
:
'
用量
'
,
status
:
'
状态
'
,
actions
:
'
操作
'
,
billingType
:
'
计费类型
'
,
...
...
@@ -1569,6 +1574,12 @@ export default {
userNotes
:
'
备注
'
,
userStatus
:
'
状态
'
},
usageToday
:
'
今日
'
,
usageTotal
:
'
累计
'
,
accountsAvailable
:
'
可用:
'
,
accountsRateLimited
:
'
限流:
'
,
accountsTotal
:
'
总量:
'
,
accountsUnit
:
'
个账号
'
,
form
:
{
name
:
'
名称
'
,
description
:
'
描述
'
,
...
...
@@ -1774,6 +1785,7 @@ export default {
revokeSubscription
:
'
撤销订阅
'
,
allStatus
:
'
全部状态
'
,
allGroups
:
'
全部分组
'
,
allPlatforms
:
'
全部平台
'
,
daily
:
'
每日
'
,
weekly
:
'
每周
'
,
monthly
:
'
每月
'
,
...
...
@@ -1838,7 +1850,37 @@ export default {
pleaseSelectUser
:
'
请选择用户
'
,
pleaseSelectGroup
:
'
请选择分组
'
,
validityDaysRequired
:
'
请输入有效的天数(至少1天)
'
,
revokeConfirm
:
"
确定要撤销 '{user}' 的订阅吗?此操作无法撤销。
"
revokeConfirm
:
"
确定要撤销 '{user}' 的订阅吗?此操作无法撤销。
"
,
guide
:
{
title
:
'
订阅管理教程
'
,
subtitle
:
'
订阅模式允许你按时间周期为用户分配使用额度,支持日/周/月配额限制。按照以下步骤即可完成配置。
'
,
showGuide
:
'
使用指南
'
,
step1
:
{
title
:
'
创建订阅分组
'
,
line1
:
'
前往「分组管理」页面,点击「创建分组」
'
,
line2
:
'
将计费类型设为「订阅」,配置日/周/月额度限制
'
,
line3
:
'
保存分组,确保状态为「正常」
'
,
link
:
'
前往分组管理
'
},
step2
:
{
title
:
'
分配订阅给用户
'
,
line1
:
'
点击本页右上角「分配订阅」按钮
'
,
line2
:
'
在弹窗中搜索用户邮箱并选择目标用户
'
,
line3
:
'
选择订阅分组、设置有效期天数,点击「分配」
'
},
step3
:
{
title
:
'
管理已有订阅
'
},
actions
:
{
adjust
:
'
调整
'
,
adjustDesc
:
'
延长或缩短订阅有效期
'
,
resetQuota
:
'
重置配额
'
,
resetQuotaDesc
:
'
将日/周/月用量归零,重新开始计算
'
,
revoke
:
'
撤销
'
,
revokeDesc
:
'
立即终止该用户的订阅,不可恢复
'
},
tip
:
'
提示:订阅分组下拉列表中只会显示计费类型为「订阅」且状态为「正常」的分组。如果没有可选项,请先到分组管理中创建。
'
}
},
// Accounts Management
...
...
@@ -4485,6 +4527,16 @@ export default {
testFailed
:
'
Google Drive 存储测试失败
'
}
},
overloadCooldown
:
{
title
:
'
529 过载冷却
'
,
description
:
'
配置上游返回 529(过载)时的账号调度暂停策略
'
,
enabled
:
'
启用过载冷却
'
,
enabledHint
:
'
收到 529 错误时暂停该账号的调度,冷却后自动恢复
'
,
cooldownMinutes
:
'
冷却时长(分钟)
'
,
cooldownMinutesHint
:
'
账号暂停调度的持续时间(1-120 分钟)
'
,
saved
:
'
过载冷却设置保存成功
'
,
saveFailed
:
'
保存过载冷却设置失败
'
},
streamTimeout
:
{
title
:
'
流超时处理
'
,
description
:
'
配置上游响应超时时的账户处理策略,避免问题账户持续被选中
'
,
...
...
frontend/src/types/index.ts
View file @
1de18b89
...
...
@@ -411,6 +411,8 @@ export interface AdminGroup extends Group {
// 分组下账号数量(仅管理员可见)
account_count
?:
number
active_account_count
?:
number
rate_limited_account_count
?:
number
// OpenAI Messages 调度配置(仅 openai 平台使用)
default_mapped_model
?:
string
...
...
@@ -975,6 +977,7 @@ export interface UsageLog {
account_id
:
number
|
null
request_id
:
string
model
:
string
upstream_model
?:
string
|
null
service_tier
?:
string
|
null
reasoning_effort
?:
string
|
null
inbound_endpoint
?:
string
|
null
...
...
frontend/src/views/admin/GroupsView.vue
View file @
1de18b89
...
...
@@ -158,12 +158,51 @@
</span>
</
template
>
<
template
#cell-account_count=
"{ value }"
>
<span
class=
"inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
>
{{
t
(
'
admin.groups.accountsCount
'
,
{
count
:
value
||
0
}
)
}}
<
/span
>
<
template
#cell-account_count=
"{ row }"
>
<div
class=
"space-y-0.5 text-xs"
>
<div>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.groups.accountsAvailable
'
)
}}
</span>
<span
class=
"ml-1 font-medium text-emerald-600 dark:text-emerald-400"
>
{{
(
row
.
active_account_count
||
0
)
-
(
row
.
rate_limited_account_count
||
0
)
}}
</span>
<span
class=
"ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
>
{{
t
(
'
admin.groups.accountsUnit
'
)
}}
</span>
</div>
<div
v-if=
"row.rate_limited_account_count"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.groups.accountsRateLimited
'
)
}}
</span>
<span
class=
"ml-1 font-medium text-amber-600 dark:text-amber-400"
>
{{
row
.
rate_limited_account_count
}}
</span>
<span
class=
"ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
>
{{
t
(
'
admin.groups.accountsUnit
'
)
}}
</span>
</div>
<div>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.groups.accountsTotal
'
)
}}
</span>
<span
class=
"ml-1 font-medium text-gray-700 dark:text-gray-300"
>
{{
row
.
account_count
||
0
}}
</span>
<span
class=
"ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
>
{{
t
(
'
admin.groups.accountsUnit
'
)
}}
</span>
</div>
</div>
</
template
>
<
template
#cell-capacity=
"{ row }"
>
<GroupCapacityBadge
v-if=
"capacityMap.get(row.id)"
:concurrency-used=
"capacityMap.get(row.id)!.concurrencyUsed"
:concurrency-max=
"capacityMap.get(row.id)!.concurrencyMax"
:sessions-used=
"capacityMap.get(row.id)!.sessionsUsed"
:sessions-max=
"capacityMap.get(row.id)!.sessionsMax"
:rpm-used=
"capacityMap.get(row.id)!.rpmUsed"
:rpm-max=
"capacityMap.get(row.id)!.rpmMax"
/>
<span
v-else
class=
"text-xs text-gray-400"
>
—
</span>
</
template
>
<
template
#cell-usage=
"{ row }"
>
<div
v-if=
"usageLoading"
class=
"text-xs text-gray-400"
>
—
</div>
<div
v-else
class=
"space-y-0.5 text-xs"
>
<div
class=
"text-gray-500 dark:text-gray-400"
>
<span
class=
"text-gray-400 dark:text-gray-500"
>
{{
t
(
'
admin.groups.usageToday
'
)
}}
</span>
<span
class=
"ml-1 font-medium text-gray-700 dark:text-gray-300"
>
$
{{
formatCost
(
usageMap
.
get
(
row
.
id
)?.
today_cost
??
0
)
}}
</span>
</div>
<div
class=
"text-gray-500 dark:text-gray-400"
>
<span
class=
"text-gray-400 dark:text-gray-500"
>
{{
t
(
'
admin.groups.usageTotal
'
)
}}
</span>
<span
class=
"ml-1 font-medium text-gray-700 dark:text-gray-300"
>
$
{{
formatCost
(
usageMap
.
get
(
row
.
id
)?.
total_cost
??
0
)
}}
</span>
</div>
</div>
</
template
>
<
template
#cell-status=
"{ value }"
>
...
...
@@ -1812,6 +1851,7 @@ import Select from '@/components/common/Select.vue'
import
PlatformIcon
from
'
@/components/common/PlatformIcon.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
GroupRateMultipliersModal
from
'
@/components/admin/group/GroupRateMultipliersModal.vue
'
import
GroupCapacityBadge
from
'
@/components/common/GroupCapacityBadge.vue
'
import
{
VueDraggable
}
from
'
vue-draggable-plus
'
import
{
createStableObjectKeyResolver
}
from
'
@/utils/stableObjectKey
'
import
{
useKeyedDebouncedSearch
}
from
'
@/composables/useKeyedDebouncedSearch
'
...
...
@@ -1827,6 +1867,8 @@ const columns = computed<Column[]>(() => [
{
key
:
'
rate_multiplier
'
,
label
:
t
(
'
admin.groups.columns.rateMultiplier
'
),
sortable
:
true
},
{
key
:
'
is_exclusive
'
,
label
:
t
(
'
admin.groups.columns.type
'
),
sortable
:
true
},
{
key
:
'
account_count
'
,
label
:
t
(
'
admin.groups.columns.accounts
'
),
sortable
:
true
},
{
key
:
'
capacity
'
,
label
:
t
(
'
admin.groups.columns.capacity
'
),
sortable
:
false
},
{
key
:
'
usage
'
,
label
:
t
(
'
admin.groups.columns.usage
'
),
sortable
:
false
},
{
key
:
'
status
'
,
label
:
t
(
'
admin.groups.columns.status
'
),
sortable
:
true
},
{
key
:
'
actions
'
,
label
:
t
(
'
admin.groups.columns.actions
'
),
sortable
:
false
}
])
...
...
@@ -1963,6 +2005,9 @@ const copyAccountsGroupOptionsForEdit = computed(() => {
const
groups
=
ref
<
AdminGroup
[]
>
([])
const
loading
=
ref
(
false
)
const
usageMap
=
ref
<
Map
<
number
,
{
today_cost
:
number
;
total_cost
:
number
}
>>
(
new
Map
())
const
usageLoading
=
ref
(
false
)
const
capacityMap
=
ref
<
Map
<
number
,
{
concurrencyUsed
:
number
;
concurrencyMax
:
number
;
sessionsUsed
:
number
;
sessionsMax
:
number
;
rpmUsed
:
number
;
rpmMax
:
number
}
>>
(
new
Map
())
const
searchQuery
=
ref
(
''
)
const
filters
=
reactive
({
platform
:
''
,
...
...
@@ -2301,6 +2346,8 @@ const loadGroups = async () => {
groups
.
value
=
response
.
items
pagination
.
total
=
response
.
total
pagination
.
pages
=
response
.
pages
loadUsageSummary
()
loadCapacitySummary
()
}
catch
(
error
:
any
)
{
if
(
signal
.
aborted
||
error
?.
name
===
'
AbortError
'
||
error
?.
code
===
'
ERR_CANCELED
'
)
{
return
...
...
@@ -2314,6 +2361,49 @@ const loadGroups = async () => {
}
}
const
formatCost
=
(
cost
:
number
):
string
=>
{
if
(
cost
>=
1000
)
return
cost
.
toFixed
(
0
)
if
(
cost
>=
100
)
return
cost
.
toFixed
(
1
)
return
cost
.
toFixed
(
2
)
}
const
loadUsageSummary
=
async
()
=>
{
usageLoading
.
value
=
true
try
{
const
tz
=
Intl
.
DateTimeFormat
().
resolvedOptions
().
timeZone
const
data
=
await
adminAPI
.
groups
.
getUsageSummary
(
tz
)
const
map
=
new
Map
<
number
,
{
today_cost
:
number
;
total_cost
:
number
}
>
()
for
(
const
item
of
data
)
{
map
.
set
(
item
.
group_id
,
{
today_cost
:
item
.
today_cost
,
total_cost
:
item
.
total_cost
})
}
usageMap
.
value
=
map
}
catch
(
error
)
{
console
.
error
(
'
Error loading group usage summary:
'
,
error
)
}
finally
{
usageLoading
.
value
=
false
}
}
const
loadCapacitySummary
=
async
()
=>
{
try
{
const
data
=
await
adminAPI
.
groups
.
getCapacitySummary
()
const
map
=
new
Map
<
number
,
{
concurrencyUsed
:
number
;
concurrencyMax
:
number
;
sessionsUsed
:
number
;
sessionsMax
:
number
;
rpmUsed
:
number
;
rpmMax
:
number
}
>
()
for
(
const
item
of
data
)
{
map
.
set
(
item
.
group_id
,
{
concurrencyUsed
:
item
.
concurrency_used
,
concurrencyMax
:
item
.
concurrency_max
,
sessionsUsed
:
item
.
sessions_used
,
sessionsMax
:
item
.
sessions_max
,
rpmUsed
:
item
.
rpm_used
,
rpmMax
:
item
.
rpm_max
})
}
capacityMap
.
value
=
map
}
catch
(
error
)
{
console
.
error
(
'
Error loading group capacity summary:
'
,
error
)
}
}
let
searchTimeout
:
ReturnType
<
typeof
setTimeout
>
const
handleSearch
=
()
=>
{
clearTimeout
(
searchTimeout
)
...
...
frontend/src/views/admin/SettingsView.vue
View file @
1de18b89
...
...
@@ -168,8 +168,93 @@
<
/div
>
<
/div><!-- /
Tab
:
Security
—
Admin
API
Key
-->
<!--
Tab
:
Gateway
—
Stream
Timeout
-->
<!--
Tab
:
Gateway
-->
<
div
v
-
show
=
"
activeTab === 'gateway'
"
class
=
"
space-y-6
"
>
<!--
Overload
Cooldown
(
529
)
Settings
-->
<
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.overloadCooldown.title
'
)
}}
<
/h2
>
<
p
class
=
"
mt-1 text-sm text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.overloadCooldown.description
'
)
}}
<
/p
>
<
/div
>
<
div
class
=
"
space-y-5 p-6
"
>
<
div
v
-
if
=
"
overloadCooldownLoading
"
class
=
"
flex items-center gap-2 text-gray-500
"
>
<
div
class
=
"
h-4 w-4 animate-spin rounded-full border-b-2 border-primary-600
"
><
/div
>
{{
t
(
'
common.loading
'
)
}}
<
/div
>
<
template
v
-
else
>
<
div
class
=
"
flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.settings.overloadCooldown.enabled
'
)
}}
<
/label
>
<
p
class
=
"
text-sm text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.overloadCooldown.enabledHint
'
)
}}
<
/p
>
<
/div
>
<
Toggle
v
-
model
=
"
overloadCooldownForm.enabled
"
/>
<
/div
>
<
div
v
-
if
=
"
overloadCooldownForm.enabled
"
class
=
"
space-y-4 border-t border-gray-100 pt-4 dark:border-dark-700
"
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.settings.overloadCooldown.cooldownMinutes
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
overloadCooldownForm.cooldown_minutes
"
type
=
"
number
"
min
=
"
1
"
max
=
"
120
"
class
=
"
input w-32
"
/>
<
p
class
=
"
mt-1.5 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.overloadCooldown.cooldownMinutesHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
div
class
=
"
flex justify-end border-t border-gray-100 pt-4 dark:border-dark-700
"
>
<
button
type
=
"
button
"
@
click
=
"
saveOverloadCooldownSettings
"
:
disabled
=
"
overloadCooldownSaving
"
class
=
"
btn btn-primary btn-sm
"
>
<
svg
v
-
if
=
"
overloadCooldownSaving
"
class
=
"
mr-1 h-4 w-4 animate-spin
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
>
<
circle
class
=
"
opacity-25
"
cx
=
"
12
"
cy
=
"
12
"
r
=
"
10
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
4
"
><
/circle
>
<
path
class
=
"
opacity-75
"
fill
=
"
currentColor
"
d
=
"
M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z
"
><
/path
>
<
/svg
>
{{
overloadCooldownSaving
?
t
(
'
common.saving
'
)
:
t
(
'
common.save
'
)
}}
<
/button
>
<
/div
>
<
/template
>
<
/div
>
<
/div
>
<!--
Stream
Timeout
Settings
-->
<
div
class
=
"
card
"
>
<
div
class
=
"
border-b border-gray-100 px-6 py-4 dark:border-dark-700
"
>
...
...
@@ -1765,6 +1850,14 @@ const adminApiKeyOperating = ref(false)
const
newAdminApiKey
=
ref
(
''
)
const
subscriptionGroups
=
ref
<
AdminGroup
[]
>
([])
// Overload Cooldown (529) 状态
const
overloadCooldownLoading
=
ref
(
true
)
const
overloadCooldownSaving
=
ref
(
false
)
const
overloadCooldownForm
=
reactive
({
enabled
:
true
,
cooldown_minutes
:
10
}
)
// Stream Timeout 状态
const
streamTimeoutLoading
=
ref
(
true
)
const
streamTimeoutSaving
=
ref
(
false
)
...
...
@@ -2274,6 +2367,37 @@ function copyNewKey() {
}
)
}
// Overload Cooldown 方法
async
function
loadOverloadCooldownSettings
()
{
overloadCooldownLoading
.
value
=
true
try
{
const
settings
=
await
adminAPI
.
settings
.
getOverloadCooldownSettings
()
Object
.
assign
(
overloadCooldownForm
,
settings
)
}
catch
(
error
:
any
)
{
console
.
error
(
'
Failed to load overload cooldown settings:
'
,
error
)
}
finally
{
overloadCooldownLoading
.
value
=
false
}
}
async
function
saveOverloadCooldownSettings
()
{
overloadCooldownSaving
.
value
=
true
try
{
const
updated
=
await
adminAPI
.
settings
.
updateOverloadCooldownSettings
({
enabled
:
overloadCooldownForm
.
enabled
,
cooldown_minutes
:
overloadCooldownForm
.
cooldown_minutes
}
)
Object
.
assign
(
overloadCooldownForm
,
updated
)
appStore
.
showSuccess
(
t
(
'
admin.settings.overloadCooldown.saved
'
))
}
catch
(
error
:
any
)
{
appStore
.
showError
(
t
(
'
admin.settings.overloadCooldown.saveFailed
'
)
+
'
:
'
+
(
error
.
message
||
t
(
'
common.unknownError
'
))
)
}
finally
{
overloadCooldownSaving
.
value
=
false
}
}
// Stream Timeout 方法
async
function
loadStreamTimeoutSettings
()
{
streamTimeoutLoading
.
value
=
true
...
...
@@ -2396,6 +2520,7 @@ onMounted(() => {
loadSettings
()
loadSubscriptionGroups
()
loadAdminApiKey
()
loadOverloadCooldownSettings
()
loadStreamTimeoutSettings
()
loadRectifierSettings
()
loadBetaPolicySettings
()
...
...
frontend/src/views/admin/SubscriptionsView.vue
View file @
1de18b89
...
...
@@ -81,6 +81,14 @@
@
change=
"applyFilters"
/>
</div>
<div
class=
"w-full sm:w-40"
>
<Select
v-model=
"filters.platform"
:options=
"platformFilterOptions"
:placeholder=
"t('admin.subscriptions.allPlatforms')"
@
change=
"applyFilters"
/>
</div>
</div>
<!-- Right: Actions -->
...
...
@@ -144,6 +152,13 @@
</div>
</div>
</div>
<button
@
click=
"showGuideModal = true"
class=
"btn btn-secondary"
:title=
"t('admin.subscriptions.guide.showGuide')"
>
<Icon
name=
"questionCircle"
size=
"md"
/>
</button>
<button
@
click=
"showAssignModal = true"
class=
"btn btn-primary"
>
<Icon
name=
"plus"
size=
"md"
class=
"mr-2"
/>
{{
t
(
'
admin.subscriptions.assignSubscription
'
)
}}
...
...
@@ -638,6 +653,85 @@
@
confirm
=
"
confirmResetQuota
"
@
cancel
=
"
showResetQuotaConfirm = false
"
/>
<!--
Subscription
Guide
Modal
-->
<
teleport
to
=
"
body
"
>
<
transition
name
=
"
modal
"
>
<
div
v
-
if
=
"
showGuideModal
"
class
=
"
fixed inset-0 z-50 flex items-center justify-center p-4
"
@
mousedown
.
self
=
"
showGuideModal = false
"
>
<
div
class
=
"
fixed inset-0 bg-black/50
"
@
click
=
"
showGuideModal = false
"
><
/div
>
<
div
class
=
"
relative max-h-[85vh] w-full max-w-2xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl dark:bg-dark-800
"
>
<
button
type
=
"
button
"
class
=
"
absolute right-4 top-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200
"
@
click
=
"
showGuideModal = false
"
>
<
svg
class
=
"
h-5 w-5
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
2
"
><
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M6 18L18 6M6 6l12 12
"
/><
/svg
>
<
/button
>
<
h2
class
=
"
mb-4 text-lg font-bold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.subscriptions.guide.title
'
)
}}
<
/h2
>
<
p
class
=
"
mb-5 text-sm text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.subscriptions.guide.subtitle
'
)
}}
<
/p
>
<!--
Step
1
-->
<
div
class
=
"
mb-5
"
>
<
h3
class
=
"
mb-2 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white
"
>
<
span
class
=
"
flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/40 dark:text-primary-300
"
>
1
<
/span
>
{{
t
(
'
admin.subscriptions.guide.step1.title
'
)
}}
<
/h3
>
<
ol
class
=
"
ml-8 list-decimal space-y-1 text-sm text-gray-600 dark:text-gray-300
"
>
<
li
>
{{
t
(
'
admin.subscriptions.guide.step1.line1
'
)
}}
<
/li
>
<
li
>
{{
t
(
'
admin.subscriptions.guide.step1.line2
'
)
}}
<
/li
>
<
li
>
{{
t
(
'
admin.subscriptions.guide.step1.line3
'
)
}}
<
/li
>
<
/ol
>
<
div
class
=
"
ml-8 mt-2
"
>
<
router
-
link
to
=
"
/admin/groups
"
@
click
=
"
showGuideModal = false
"
class
=
"
inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300
"
>
{{
t
(
'
admin.subscriptions.guide.step1.link
'
)
}}
<
Icon
name
=
"
arrowRight
"
size
=
"
xs
"
/>
<
/router-link
>
<
/div
>
<
/div
>
<!--
Step
2
-->
<
div
class
=
"
mb-5
"
>
<
h3
class
=
"
mb-2 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white
"
>
<
span
class
=
"
flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/40 dark:text-primary-300
"
>
2
<
/span
>
{{
t
(
'
admin.subscriptions.guide.step2.title
'
)
}}
<
/h3
>
<
ol
class
=
"
ml-8 list-decimal space-y-1 text-sm text-gray-600 dark:text-gray-300
"
>
<
li
>
{{
t
(
'
admin.subscriptions.guide.step2.line1
'
)
}}
<
/li
>
<
li
>
{{
t
(
'
admin.subscriptions.guide.step2.line2
'
)
}}
<
/li
>
<
li
>
{{
t
(
'
admin.subscriptions.guide.step2.line3
'
)
}}
<
/li
>
<
/ol
>
<
/div
>
<!--
Step
3
-->
<
div
class
=
"
mb-5
"
>
<
h3
class
=
"
mb-2 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white
"
>
<
span
class
=
"
flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/40 dark:text-primary-300
"
>
3
<
/span
>
{{
t
(
'
admin.subscriptions.guide.step3.title
'
)
}}
<
/h3
>
<
div
class
=
"
ml-8 overflow-hidden rounded-lg border border-gray-200 dark:border-dark-600
"
>
<
table
class
=
"
w-full text-sm
"
>
<
tbody
>
<
tr
v
-
for
=
"
(row, i) in guideActionRows
"
:
key
=
"
i
"
class
=
"
border-b border-gray-100 dark:border-dark-700 last:border-0
"
>
<
td
class
=
"
whitespace-nowrap bg-gray-50 px-3 py-2 font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-300
"
>
{{
row
.
action
}}
<
/td
>
<
td
class
=
"
px-3 py-2 text-gray-600 dark:text-gray-400
"
>
{{
row
.
desc
}}
<
/td
>
<
/tr
>
<
/tbody
>
<
/table
>
<
/div
>
<
/div
>
<!--
Tip
-->
<
div
class
=
"
rounded-lg bg-blue-50 p-3 text-xs text-blue-700 dark:bg-blue-900/20 dark:text-blue-300
"
>
{{
t
(
'
admin.subscriptions.guide.tip
'
)
}}
<
/div
>
<
div
class
=
"
mt-4 text-right
"
>
<
button
type
=
"
button
"
class
=
"
btn btn-primary btn-sm
"
@
click
=
"
showGuideModal = false
"
>
{{
t
(
'
common.close
'
)
}}
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<
/transition
>
<
/teleport
>
<
/AppLayout
>
<
/template
>
...
...
@@ -674,6 +768,15 @@ interface GroupOption {
rate
:
number
}
// Guide modal state
const
showGuideModal
=
ref
(
false
)
const
guideActionRows
=
computed
(()
=>
[
{
action
:
t
(
'
admin.subscriptions.guide.actions.adjust
'
),
desc
:
t
(
'
admin.subscriptions.guide.actions.adjustDesc
'
)
}
,
{
action
:
t
(
'
admin.subscriptions.guide.actions.resetQuota
'
),
desc
:
t
(
'
admin.subscriptions.guide.actions.resetQuotaDesc
'
)
}
,
{
action
:
t
(
'
admin.subscriptions.guide.actions.revoke
'
),
desc
:
t
(
'
admin.subscriptions.guide.actions.revokeDesc
'
)
}
])
// User column display mode: 'email' or 'username'
const
userColumnMode
=
ref
<
'
email
'
|
'
username
'
>
(
'
email
'
)
const
USER_COLUMN_MODE_KEY
=
'
subscription-user-column-mode
'
...
...
@@ -813,6 +916,7 @@ let userSearchTimeout: ReturnType<typeof setTimeout> | null = null
const
filters
=
reactive
({
status
:
'
active
'
,
group_id
:
''
,
platform
:
''
,
user_id
:
null
as
number
|
null
}
)
...
...
@@ -855,6 +959,15 @@ const groupOptions = computed(() => [
...
groups
.
value
.
map
((
g
)
=>
({
value
:
g
.
id
.
toString
(),
label
:
g
.
name
}
))
])
const
platformFilterOptions
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.subscriptions.allPlatforms
'
)
}
,
{
value
:
'
anthropic
'
,
label
:
'
Anthropic
'
}
,
{
value
:
'
openai
'
,
label
:
'
OpenAI
'
}
,
{
value
:
'
gemini
'
,
label
:
'
Gemini
'
}
,
{
value
:
'
antigravity
'
,
label
:
'
Antigravity
'
}
,
{
value
:
'
sora
'
,
label
:
'
Sora
'
}
])
// Group options for assign (only subscription type groups)
const
subscriptionGroupOptions
=
computed
(()
=>
groups
.
value
...
...
@@ -890,6 +1003,7 @@ const loadSubscriptions = async () => {
{
status
:
(
filters
.
status
as
any
)
||
undefined
,
group_id
:
filters
.
group_id
?
parseInt
(
filters
.
group_id
)
:
undefined
,
platform
:
filters
.
platform
||
undefined
,
user_id
:
filters
.
user_id
||
undefined
,
sort_by
:
sortState
.
sort_by
,
sort_order
:
sortState
.
sort_order
...
...
frontend/src/views/admin/UsageView.vue
View file @
1de18b89
...
...
@@ -24,9 +24,13 @@
</div>
<div
class=
"grid grid-cols-1 gap-6 lg:grid-cols-2"
>
<ModelDistributionChart
v-model:source=
"modelDistributionSource"
v-model:metric=
"modelDistributionMetric"
:model-stats=
"modelStats"
:loading=
"chartsLoading"
:model-stats=
"requestedModelStats"
:upstream-model-stats=
"upstreamModelStats"
:mapping-model-stats=
"mappingModelStats"
:loading=
"modelStatsLoading"
:show-source-toggle=
"true"
:show-metric-toggle=
"true"
:start-date=
"startDate"
:end-date=
"endDate"
...
...
@@ -115,7 +119,7 @@
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
saveAs
}
from
'
file-saver
'
import
{
useRoute
}
from
'
vue-router
'
...
...
@@ -136,10 +140,17 @@ const { t } = useI18n()
const
appStore
=
useAppStore
()
type
DistributionMetric
=
'
tokens
'
|
'
actual_cost
'
type
EndpointSource
=
'
inbound
'
|
'
upstream
'
|
'
path
'
type
ModelDistributionSource
=
'
requested
'
|
'
upstream
'
|
'
mapping
'
const
route
=
useRoute
()
const
usageStats
=
ref
<
AdminUsageStatsResponse
|
null
>
(
null
);
const
usageLogs
=
ref
<
AdminUsageLog
[]
>
([]);
const
loading
=
ref
(
false
);
const
exporting
=
ref
(
false
)
const
trendData
=
ref
<
TrendDataPoint
[]
>
([]);
const
m
odelStats
=
ref
<
ModelStat
[]
>
([]);
const
groupStats
=
ref
<
GroupStat
[]
>
([]);
const
chartsLoading
=
ref
(
false
);
const
granularity
=
ref
<
'
day
'
|
'
hour
'
>
(
'
hour
'
)
const
trendData
=
ref
<
TrendDataPoint
[]
>
([]);
const
requestedModelStats
=
ref
<
ModelStat
[]
>
([]);
const
upstreamModelStats
=
ref
<
ModelStat
[]
>
([]);
const
mappingM
odelStats
=
ref
<
ModelStat
[]
>
([]);
const
groupStats
=
ref
<
GroupStat
[]
>
([]);
const
chartsLoading
=
ref
(
false
);
const
modelStatsLoading
=
ref
(
false
);
const
granularity
=
ref
<
'
day
'
|
'
hour
'
>
(
'
hour
'
)
const
modelDistributionMetric
=
ref
<
DistributionMetric
>
(
'
tokens
'
)
const
modelDistributionSource
=
ref
<
ModelDistributionSource
>
(
'
requested
'
)
const
loadedModelSources
=
reactive
<
Record
<
ModelDistributionSource
,
boolean
>>
({
requested
:
false
,
upstream
:
false
,
mapping
:
false
,
})
const
groupDistributionMetric
=
ref
<
DistributionMetric
>
(
'
tokens
'
)
const
endpointDistributionMetric
=
ref
<
DistributionMetric
>
(
'
tokens
'
)
const
endpointDistributionSource
=
ref
<
EndpointSource
>
(
'
inbound
'
)
...
...
@@ -150,6 +161,7 @@ const endpointStatsLoading = ref(false)
let
abortController
:
AbortController
|
null
=
null
;
let
exportAbortController
:
AbortController
|
null
=
null
let
chartReqSeq
=
0
let
statsReqSeq
=
0
let
modelStatsReqSeq
=
0
const
exportProgress
=
reactive
({
show
:
false
,
progress
:
0
,
current
:
0
,
total
:
0
,
estimatedTime
:
''
})
const
cleanupDialogVisible
=
ref
(
false
)
// Balance history modal state
...
...
@@ -269,6 +281,68 @@ const loadStats = async () => {
if
(
seq
===
statsReqSeq
)
endpointStatsLoading
.
value
=
false
}
}
const
resetModelStatsCache
=
()
=>
{
requestedModelStats
.
value
=
[]
upstreamModelStats
.
value
=
[]
mappingModelStats
.
value
=
[]
loadedModelSources
.
requested
=
false
loadedModelSources
.
upstream
=
false
loadedModelSources
.
mapping
=
false
}
const
loadModelStats
=
async
(
source
:
ModelDistributionSource
,
force
=
false
)
=>
{
if
(
!
force
&&
loadedModelSources
[
source
])
{
return
}
const
seq
=
++
modelStatsReqSeq
modelStatsLoading
.
value
=
true
try
{
const
requestType
=
filters
.
value
.
request_type
const
legacyStream
=
requestType
?
requestTypeToLegacyStream
(
requestType
)
:
filters
.
value
.
stream
const
baseParams
=
{
start_date
:
filters
.
value
.
start_date
||
startDate
.
value
,
end_date
:
filters
.
value
.
end_date
||
endDate
.
value
,
user_id
:
filters
.
value
.
user_id
,
model
:
filters
.
value
.
model
,
api_key_id
:
filters
.
value
.
api_key_id
,
account_id
:
filters
.
value
.
account_id
,
group_id
:
filters
.
value
.
group_id
,
request_type
:
requestType
,
stream
:
legacyStream
===
null
?
undefined
:
legacyStream
,
billing_type
:
filters
.
value
.
billing_type
,
}
const
response
=
await
adminAPI
.
dashboard
.
getModelStats
({
...
baseParams
,
model_source
:
source
})
if
(
seq
!==
modelStatsReqSeq
)
return
const
models
=
response
.
models
||
[]
if
(
source
===
'
requested
'
)
{
requestedModelStats
.
value
=
models
}
else
if
(
source
===
'
upstream
'
)
{
upstreamModelStats
.
value
=
models
}
else
{
mappingModelStats
.
value
=
models
}
loadedModelSources
[
source
]
=
true
}
catch
(
error
)
{
if
(
seq
!==
modelStatsReqSeq
)
return
console
.
error
(
'
Failed to load model stats:
'
,
error
)
if
(
source
===
'
requested
'
)
{
requestedModelStats
.
value
=
[]
}
else
if
(
source
===
'
upstream
'
)
{
upstreamModelStats
.
value
=
[]
}
else
{
mappingModelStats
.
value
=
[]
}
loadedModelSources
[
source
]
=
false
}
finally
{
if
(
seq
===
modelStatsReqSeq
)
modelStatsLoading
.
value
=
false
}
}
const
loadChartData
=
async
()
=>
{
const
seq
=
++
chartReqSeq
chartsLoading
.
value
=
true
...
...
@@ -289,18 +363,30 @@ const loadChartData = async () => {
billing_type
:
filters
.
value
.
billing_type
,
include_stats
:
false
,
include_trend
:
true
,
include_model_stats
:
tru
e
,
include_model_stats
:
fals
e
,
include_group_stats
:
true
,
include_users_trend
:
false
})
if
(
seq
!==
chartReqSeq
)
return
trendData
.
value
=
snapshot
.
trend
||
[]
modelStats
.
value
=
snapshot
.
models
||
[]
groupStats
.
value
=
snapshot
.
groups
||
[]
}
catch
(
error
)
{
console
.
error
(
'
Failed to load chart data:
'
,
error
)
}
finally
{
if
(
seq
===
chartReqSeq
)
chartsLoading
.
value
=
false
}
}
const
applyFilters
=
()
=>
{
pagination
.
page
=
1
;
loadLogs
();
loadStats
();
loadChartData
()
}
const
refreshData
=
()
=>
{
loadLogs
();
loadStats
();
loadChartData
()
}
const
applyFilters
=
()
=>
{
pagination
.
page
=
1
resetModelStatsCache
()
loadLogs
()
loadStats
()
loadModelStats
(
modelDistributionSource
.
value
,
true
)
loadChartData
()
}
const
refreshData
=
()
=>
{
resetModelStatsCache
()
loadLogs
()
loadStats
()
loadModelStats
(
modelDistributionSource
.
value
,
true
)
loadChartData
()
}
const
resetFilters
=
()
=>
{
const
range
=
getLast24HoursRangeDates
()
startDate
.
value
=
range
.
start
...
...
@@ -329,7 +415,7 @@ const exportToExcel = async () => {
const
XLSX
=
await
import
(
'
xlsx
'
)
const
headers
=
[
t
(
'
usage.time
'
),
t
(
'
admin.usage.user
'
),
t
(
'
usage.apiKeyFilter
'
),
t
(
'
admin.usage.account
'
),
t
(
'
usage.model
'
),
t
(
'
usage.reasoningEffort
'
),
t
(
'
admin.usage.group
'
),
t
(
'
admin.usage.account
'
),
t
(
'
usage.model
'
),
t
(
'
usage.upstreamModel
'
),
t
(
'
usage.reasoningEffort
'
),
t
(
'
admin.usage.group
'
),
t
(
'
usage.inboundEndpoint
'
),
t
(
'
usage.upstreamEndpoint
'
),
t
(
'
usage.type
'
),
t
(
'
admin.usage.inputTokens
'
),
t
(
'
admin.usage.outputTokens
'
),
...
...
@@ -348,7 +434,7 @@ const exportToExcel = async () => {
if
(
c
.
signal
.
aborted
)
break
;
if
(
p
===
1
)
{
total
=
res
.
total
;
exportProgress
.
total
=
total
}
const
rows
=
(
res
.
items
||
[]).
map
((
log
:
AdminUsageLog
)
=>
[
log
.
created_at
,
log
.
user
?.
email
||
''
,
log
.
api_key
?.
name
||
''
,
log
.
account
?.
name
||
''
,
log
.
model
,
formatReasoningEffort
(
log
.
reasoning_effort
),
log
.
group
?.
name
||
''
,
log
.
upstream_model
||
''
,
formatReasoningEffort
(
log
.
reasoning_effort
),
log
.
group
?.
name
||
''
,
log
.
inbound_endpoint
||
''
,
log
.
upstream_endpoint
||
''
,
getRequestTypeLabel
(
log
),
log
.
input_tokens
,
log
.
output_tokens
,
log
.
cache_read_tokens
,
log
.
cache_creation_tokens
,
log
.
input_cost
?.
toFixed
(
6
)
||
'
0.000000
'
,
log
.
output_cost
?.
toFixed
(
6
)
||
'
0.000000
'
,
...
...
@@ -458,6 +544,7 @@ onMounted(() => {
applyRouteQueryFilters
()
loadLogs
()
loadStats
()
loadModelStats
(
modelDistributionSource
.
value
,
true
)
window
.
setTimeout
(()
=>
{
void
loadChartData
()
},
120
)
...
...
@@ -465,4 +552,8 @@ onMounted(() => {
document
.
addEventListener
(
'
click
'
,
handleColumnClickOutside
)
})
onUnmounted
(()
=>
{
abortController
?.
abort
();
exportAbortController
?.
abort
();
document
.
removeEventListener
(
'
click
'
,
handleColumnClickOutside
)
})
watch
(
modelDistributionSource
,
(
source
)
=>
{
void
loadModelStats
(
source
)
})
</
script
>
frontend/src/views/setup/SetupWizardView.vue
View file @
1de18b89
...
...
@@ -565,7 +565,7 @@ const canProceed = computed(() => {
case
2
:
return
(
formData
.
admin
.
email
&&
formData
.
admin
.
password
.
length
>=
6
&&
formData
.
admin
.
password
.
length
>=
8
&&
formData
.
admin
.
password
===
confirmPassword
.
value
)
default
:
...
...
@@ -582,8 +582,9 @@ async function testDatabaseConnection() {
await
testDatabase
(
formData
.
database
)
dbConnected
.
value
=
true
}
catch
(
error
:
unknown
)
{
const
err
=
error
as
{
response
?:
{
data
?:
{
detail
?:
string
}
};
message
?:
string
}
errorMessage
.
value
=
err
.
response
?.
data
?.
detail
||
err
.
message
||
'
Connection failed
'
const
err
=
error
as
{
response
?:
{
data
?:
{
detail
?:
string
;
message
?:
string
}
};
message
?:
string
}
errorMessage
.
value
=
err
.
response
?.
data
?.
detail
||
err
.
response
?.
data
?.
message
||
err
.
message
||
'
Connection failed
'
}
finally
{
testingDb
.
value
=
false
}
...
...
@@ -598,8 +599,9 @@ async function testRedisConnection() {
await
testRedis
(
formData
.
redis
)
redisConnected
.
value
=
true
}
catch
(
error
:
unknown
)
{
const
err
=
error
as
{
response
?:
{
data
?:
{
detail
?:
string
}
};
message
?:
string
}
errorMessage
.
value
=
err
.
response
?.
data
?.
detail
||
err
.
message
||
'
Connection failed
'
const
err
=
error
as
{
response
?:
{
data
?:
{
detail
?:
string
;
message
?:
string
}
};
message
?:
string
}
errorMessage
.
value
=
err
.
response
?.
data
?.
detail
||
err
.
response
?.
data
?.
message
||
err
.
message
||
'
Connection failed
'
}
finally
{
testingRedis
.
value
=
false
}
...
...
@@ -622,8 +624,9 @@ async function performInstall() {
// Start polling for service restart
waitForServiceRestart
()
}
catch
(
error
:
unknown
)
{
const
err
=
error
as
{
response
?:
{
data
?:
{
detail
?:
string
}
};
message
?:
string
}
errorMessage
.
value
=
err
.
response
?.
data
?.
detail
||
err
.
message
||
'
Installation failed
'
const
err
=
error
as
{
response
?:
{
data
?:
{
detail
?:
string
;
message
?:
string
}
};
message
?:
string
}
errorMessage
.
value
=
err
.
response
?.
data
?.
detail
||
err
.
response
?.
data
?.
message
||
err
.
message
||
'
Installation failed
'
}
finally
{
installing
.
value
=
false
}
...
...
Prev
1
2
3
4
5
6
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