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
f6bff97d
Commit
f6bff97d
authored
Feb 14, 2026
by
yangjianbo
Browse files
fix(frontend): 修复前端审计问题并补充回归测试
parent
d04b47b3
Changes
27
Show whitespace changes
Inline
Side-by-side
frontend/src/router/__tests__/title.spec.ts
0 → 100644
View file @
f6bff97d
import
{
describe
,
expect
,
it
}
from
'
vitest
'
import
{
resolveDocumentTitle
}
from
'
@/router/title
'
describe
(
'
resolveDocumentTitle
'
,
()
=>
{
it
(
'
路由存在标题时,使用“路由标题 - 站点名”格式
'
,
()
=>
{
expect
(
resolveDocumentTitle
(
'
Usage Records
'
,
'
My Site
'
)).
toBe
(
'
Usage Records - My Site
'
)
})
it
(
'
路由无标题时,回退到站点名
'
,
()
=>
{
expect
(
resolveDocumentTitle
(
undefined
,
'
My Site
'
)).
toBe
(
'
My Site
'
)
})
it
(
'
站点名为空时,回退默认站点名
'
,
()
=>
{
expect
(
resolveDocumentTitle
(
'
Dashboard
'
,
''
)).
toBe
(
'
Dashboard - Sub2API
'
)
expect
(
resolveDocumentTitle
(
undefined
,
'
'
)).
toBe
(
'
Sub2API
'
)
})
it
(
'
站点名变更时仅影响后续路由标题计算
'
,
()
=>
{
const
before
=
resolveDocumentTitle
(
'
Admin Dashboard
'
,
'
Alpha
'
)
const
after
=
resolveDocumentTitle
(
'
Admin Dashboard
'
,
'
Beta
'
)
expect
(
before
).
toBe
(
'
Admin Dashboard - Alpha
'
)
expect
(
after
).
toBe
(
'
Admin Dashboard - Beta
'
)
})
})
frontend/src/router/index.ts
View file @
f6bff97d
...
@@ -8,6 +8,7 @@ import { useAuthStore } from '@/stores/auth'
...
@@ -8,6 +8,7 @@ import { useAuthStore } from '@/stores/auth'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useNavigationLoadingState
}
from
'
@/composables/useNavigationLoading
'
import
{
useNavigationLoadingState
}
from
'
@/composables/useNavigationLoading
'
import
{
useRoutePrefetch
}
from
'
@/composables/useRoutePrefetch
'
import
{
useRoutePrefetch
}
from
'
@/composables/useRoutePrefetch
'
import
{
resolveDocumentTitle
}
from
'
./title
'
/**
/**
* Route definitions with lazy loading
* Route definitions with lazy loading
...
@@ -389,12 +390,7 @@ router.beforeEach((to, _from, next) => {
...
@@ -389,12 +390,7 @@ router.beforeEach((to, _from, next) => {
// Set page title
// Set page title
const
appStore
=
useAppStore
()
const
appStore
=
useAppStore
()
const
siteName
=
appStore
.
siteName
||
'
Sub2API
'
document
.
title
=
resolveDocumentTitle
(
to
.
meta
.
title
,
appStore
.
siteName
)
if
(
to
.
meta
.
title
)
{
document
.
title
=
`
${
to
.
meta
.
title
}
-
${
siteName
}
`
}
else
{
document
.
title
=
siteName
}
// Check if route requires authentication
// Check if route requires authentication
const
requiresAuth
=
to
.
meta
.
requiresAuth
!==
false
// Default to true
const
requiresAuth
=
to
.
meta
.
requiresAuth
!==
false
// Default to true
...
...
frontend/src/router/title.ts
0 → 100644
View file @
f6bff97d
/**
* 统一生成页面标题,避免多处写入 document.title 产生覆盖冲突。
*/
export
function
resolveDocumentTitle
(
routeTitle
:
unknown
,
siteName
?:
string
):
string
{
const
normalizedSiteName
=
typeof
siteName
===
'
string
'
&&
siteName
.
trim
()
?
siteName
.
trim
()
:
'
Sub2API
'
if
(
typeof
routeTitle
===
'
string
'
&&
routeTitle
.
trim
())
{
return
`
${
routeTitle
.
trim
()}
-
${
normalizedSiteName
}
`
}
return
normalizedSiteName
}
frontend/src/utils/__tests__/stableObjectKey.spec.ts
0 → 100644
View file @
f6bff97d
import
{
describe
,
expect
,
it
}
from
'
vitest
'
import
{
createStableObjectKeyResolver
}
from
'
@/utils/stableObjectKey
'
describe
(
'
createStableObjectKeyResolver
'
,
()
=>
{
it
(
'
对同一对象返回稳定 key
'
,
()
=>
{
const
resolve
=
createStableObjectKeyResolver
<
{
value
:
string
}
>
(
'
rule
'
)
const
obj
=
{
value
:
'
a
'
}
const
key1
=
resolve
(
obj
)
const
key2
=
resolve
(
obj
)
expect
(
key1
).
toBe
(
key2
)
expect
(
key1
.
startsWith
(
'
rule-
'
)).
toBe
(
true
)
})
it
(
'
不同对象返回不同 key
'
,
()
=>
{
const
resolve
=
createStableObjectKeyResolver
<
{
value
:
string
}
>
(
'
rule
'
)
const
key1
=
resolve
({
value
:
'
a
'
})
const
key2
=
resolve
({
value
:
'
a
'
})
expect
(
key1
).
not
.
toBe
(
key2
)
})
it
(
'
不同 resolver 互不影响
'
,
()
=>
{
const
resolveA
=
createStableObjectKeyResolver
<
{
id
:
number
}
>
(
'
a
'
)
const
resolveB
=
createStableObjectKeyResolver
<
{
id
:
number
}
>
(
'
b
'
)
const
obj
=
{
id
:
1
}
const
keyA
=
resolveA
(
obj
)
const
keyB
=
resolveB
(
obj
)
expect
(
keyA
).
not
.
toBe
(
keyB
)
expect
(
keyA
.
startsWith
(
'
a-
'
)).
toBe
(
true
)
expect
(
keyB
.
startsWith
(
'
b-
'
)).
toBe
(
true
)
})
})
frontend/src/utils/stableObjectKey.ts
0 → 100644
View file @
f6bff97d
let
globalStableObjectKeySeed
=
0
/**
* 为对象实例生成稳定 key(基于 WeakMap,不污染业务对象)
*/
export
function
createStableObjectKeyResolver
<
T
extends
object
>
(
prefix
=
'
item
'
)
{
const
keyMap
=
new
WeakMap
<
T
,
string
>
()
return
(
item
:
T
):
string
=>
{
const
cached
=
keyMap
.
get
(
item
)
if
(
cached
)
{
return
cached
}
const
key
=
`
${
prefix
}
-
${
++
globalStableObjectKeySeed
}
`
keyMap
.
set
(
item
,
key
)
return
key
}
}
frontend/src/views/admin/GroupsView.vue
View file @
f6bff97d
...
@@ -759,8 +759,8 @@
...
@@ -759,8 +759,8 @@
<!--
路由规则列表
(
仅在启用时显示
)
-->
<!--
路由规则列表
(
仅在启用时显示
)
-->
<
div
v
-
if
=
"
createForm.model_routing_enabled
"
class
=
"
space-y-3
"
>
<
div
v
-
if
=
"
createForm.model_routing_enabled
"
class
=
"
space-y-3
"
>
<
div
<
div
v
-
for
=
"
(
rule
, index)
in createModelRoutingRules
"
v
-
for
=
"
rule in createModelRoutingRules
"
:
key
=
"
index
"
:
key
=
"
getCreateRuleRenderKey(rule)
"
class
=
"
rounded-lg border border-gray-200 p-3 dark:border-dark-600
"
class
=
"
rounded-lg border border-gray-200 p-3 dark:border-dark-600
"
>
>
<
div
class
=
"
flex items-start gap-3
"
>
<
div
class
=
"
flex items-start gap-3
"
>
...
@@ -786,7 +786,7 @@
...
@@ -786,7 +786,7 @@
{{
account
.
name
}}
{{
account
.
name
}}
<
button
<
button
type
=
"
button
"
type
=
"
button
"
@
click
=
"
removeSelectedAccount(
index
, account.id
, false
)
"
@
click
=
"
removeSelectedAccount(
rule
, account.id)
"
class
=
"
ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200
"
class
=
"
ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200
"
>
>
<
Icon
name
=
"
x
"
size
=
"
xs
"
/>
<
Icon
name
=
"
x
"
size
=
"
xs
"
/>
...
@@ -796,23 +796,23 @@
...
@@ -796,23 +796,23 @@
<!--
账号搜索输入框
-->
<!--
账号搜索输入框
-->
<
div
class
=
"
relative account-search-container
"
>
<
div
class
=
"
relative account-search-container
"
>
<
input
<
input
v
-
model
=
"
accountSearchKeyword[
`c
reate
-${index
}
`
]
"
v
-
model
=
"
accountSearchKeyword[
getC
reate
RuleSearchKey(rule)
]
"
type
=
"
text
"
type
=
"
text
"
class
=
"
input text-sm
"
class
=
"
input text-sm
"
:
placeholder
=
"
t('admin.groups.modelRouting.searchAccountPlaceholder')
"
:
placeholder
=
"
t('admin.groups.modelRouting.searchAccountPlaceholder')
"
@
input
=
"
searchAccounts
(`create-${index
}
`
)
"
@
input
=
"
searchAccounts
ByRule(rule
)
"
@
focus
=
"
onAccountSearchFocus(
index, fals
e)
"
@
focus
=
"
onAccountSearchFocus(
rul
e)
"
/>
/>
<!--
搜索结果下拉框
-->
<!--
搜索结果下拉框
-->
<
div
<
div
v
-
if
=
"
showAccountDropdown[
`c
reate
-${index
}
`
] && accountSearchResults[
`c
reate
-${index
}
`
]?.length > 0
"
v
-
if
=
"
showAccountDropdown[
getC
reate
RuleSearchKey(rule)
] && accountSearchResults[
getC
reate
RuleSearchKey(rule)
]?.length > 0
"
class
=
"
absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800
"
class
=
"
absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800
"
>
>
<
button
<
button
v
-
for
=
"
account in accountSearchResults[
`c
reate
-${index
}
`
]
"
v
-
for
=
"
account in accountSearchResults[
getC
reate
RuleSearchKey(rule)
]
"
:
key
=
"
account.id
"
:
key
=
"
account.id
"
type
=
"
button
"
type
=
"
button
"
@
click
=
"
selectAccount(
index
, account
, false
)
"
@
click
=
"
selectAccount(
rule
, account)
"
class
=
"
w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-700
"
class
=
"
w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-700
"
:
class
=
"
{ 'opacity-50': rule.accounts.some(a => a.id === account.id)
}
"
:
class
=
"
{ 'opacity-50': rule.accounts.some(a => a.id === account.id)
}
"
:
disabled
=
"
rule.accounts.some(a => a.id === account.id)
"
:
disabled
=
"
rule.accounts.some(a => a.id === account.id)
"
...
@@ -827,7 +827,7 @@
...
@@ -827,7 +827,7 @@
<
/div
>
<
/div
>
<
button
<
button
type
=
"
button
"
type
=
"
button
"
@
click
=
"
removeCreateRoutingRule(
index
)
"
@
click
=
"
removeCreateRoutingRule(
rule
)
"
class
=
"
mt-5 p-1.5 text-gray-400 hover:text-red-500 transition-colors
"
class
=
"
mt-5 p-1.5 text-gray-400 hover:text-red-500 transition-colors
"
:
title
=
"
t('admin.groups.modelRouting.removeRule')
"
:
title
=
"
t('admin.groups.modelRouting.removeRule')
"
>
>
...
@@ -1439,8 +1439,8 @@
...
@@ -1439,8 +1439,8 @@
<!--
路由规则列表
(
仅在启用时显示
)
-->
<!--
路由规则列表
(
仅在启用时显示
)
-->
<
div
v
-
if
=
"
editForm.model_routing_enabled
"
class
=
"
space-y-3
"
>
<
div
v
-
if
=
"
editForm.model_routing_enabled
"
class
=
"
space-y-3
"
>
<
div
<
div
v
-
for
=
"
(
rule
, index)
in editModelRoutingRules
"
v
-
for
=
"
rule in editModelRoutingRules
"
:
key
=
"
index
"
:
key
=
"
getEditRuleRenderKey(rule)
"
class
=
"
rounded-lg border border-gray-200 p-3 dark:border-dark-600
"
class
=
"
rounded-lg border border-gray-200 p-3 dark:border-dark-600
"
>
>
<
div
class
=
"
flex items-start gap-3
"
>
<
div
class
=
"
flex items-start gap-3
"
>
...
@@ -1466,7 +1466,7 @@
...
@@ -1466,7 +1466,7 @@
{{
account
.
name
}}
{{
account
.
name
}}
<
button
<
button
type
=
"
button
"
type
=
"
button
"
@
click
=
"
removeSelectedAccount(
index
, account.id, true)
"
@
click
=
"
removeSelectedAccount(
rule
, account.id, true)
"
class
=
"
ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200
"
class
=
"
ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200
"
>
>
<
Icon
name
=
"
x
"
size
=
"
xs
"
/>
<
Icon
name
=
"
x
"
size
=
"
xs
"
/>
...
@@ -1476,23 +1476,23 @@
...
@@ -1476,23 +1476,23 @@
<!--
账号搜索输入框
-->
<!--
账号搜索输入框
-->
<
div
class
=
"
relative account-search-container
"
>
<
div
class
=
"
relative account-search-container
"
>
<
input
<
input
v
-
model
=
"
accountSearchKeyword[
`edit-${index
}
`
]
"
v
-
model
=
"
accountSearchKeyword[
getEditRuleSearchKey(rule)
]
"
type
=
"
text
"
type
=
"
text
"
class
=
"
input text-sm
"
class
=
"
input text-sm
"
:
placeholder
=
"
t('admin.groups.modelRouting.searchAccountPlaceholder')
"
:
placeholder
=
"
t('admin.groups.modelRouting.searchAccountPlaceholder')
"
@
input
=
"
searchAccounts
(`edit-${index
}
`
)
"
@
input
=
"
searchAccounts
ByRule(rule, true
)
"
@
focus
=
"
onAccountSearchFocus(
index
, true)
"
@
focus
=
"
onAccountSearchFocus(
rule
, true)
"
/>
/>
<!--
搜索结果下拉框
-->
<!--
搜索结果下拉框
-->
<
div
<
div
v
-
if
=
"
showAccountDropdown[
`edit-${index
}
`] && accountSearchResults[`edit-${index
}
`
]?.length > 0
"
v
-
if
=
"
showAccountDropdown[
getEditRuleSearchKey(rule)] && accountSearchResults[getEditRuleSearchKey(rule)
]?.length > 0
"
class
=
"
absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800
"
class
=
"
absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800
"
>
>
<
button
<
button
v
-
for
=
"
account in accountSearchResults[
`edit-${index
}
`
]
"
v
-
for
=
"
account in accountSearchResults[
getEditRuleSearchKey(rule)
]
"
:
key
=
"
account.id
"
:
key
=
"
account.id
"
type
=
"
button
"
type
=
"
button
"
@
click
=
"
selectAccount(
index
, account, true)
"
@
click
=
"
selectAccount(
rule
, account, true)
"
class
=
"
w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-700
"
class
=
"
w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-700
"
:
class
=
"
{ 'opacity-50': rule.accounts.some(a => a.id === account.id)
}
"
:
class
=
"
{ 'opacity-50': rule.accounts.some(a => a.id === account.id)
}
"
:
disabled
=
"
rule.accounts.some(a => a.id === account.id)
"
:
disabled
=
"
rule.accounts.some(a => a.id === account.id)
"
...
@@ -1507,7 +1507,7 @@
...
@@ -1507,7 +1507,7 @@
<
/div
>
<
/div
>
<
button
<
button
type
=
"
button
"
type
=
"
button
"
@
click
=
"
removeEditRoutingRule(
index
)
"
@
click
=
"
removeEditRoutingRule(
rule
)
"
class
=
"
mt-5 p-1.5 text-gray-400 hover:text-red-500 transition-colors
"
class
=
"
mt-5 p-1.5 text-gray-400 hover:text-red-500 transition-colors
"
:
title
=
"
t('admin.groups.modelRouting.removeRule')
"
:
title
=
"
t('admin.groups.modelRouting.removeRule')
"
>
>
...
@@ -1687,6 +1687,8 @@ import Select from '@/components/common/Select.vue'
...
@@ -1687,6 +1687,8 @@ import Select from '@/components/common/Select.vue'
import
PlatformIcon
from
'
@/components/common/PlatformIcon.vue
'
import
PlatformIcon
from
'
@/components/common/PlatformIcon.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
VueDraggable
}
from
'
vue-draggable-plus
'
import
{
VueDraggable
}
from
'
vue-draggable-plus
'
import
{
createStableObjectKeyResolver
}
from
'
@/utils/stableObjectKey
'
import
{
useKeyedDebouncedSearch
}
from
'
@/composables/useKeyedDebouncedSearch
'
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
appStore
=
useAppStore
()
...
@@ -1911,33 +1913,70 @@ const createModelRoutingRules = ref<ModelRoutingRule[]>([])
...
@@ -1911,33 +1913,70 @@ const createModelRoutingRules = ref<ModelRoutingRule[]>([])
// 编辑表单的模型路由规则
// 编辑表单的模型路由规则
const
editModelRoutingRules
=
ref
<
ModelRoutingRule
[]
>
([])
const
editModelRoutingRules
=
ref
<
ModelRoutingRule
[]
>
([])
// 规则对象稳定 key(避免使用 index 导致状态错位)
const
resolveCreateRuleKey
=
createStableObjectKeyResolver
<
ModelRoutingRule
>
(
'
create-rule
'
)
const
resolveEditRuleKey
=
createStableObjectKeyResolver
<
ModelRoutingRule
>
(
'
edit-rule
'
)
const
getCreateRuleRenderKey
=
(
rule
:
ModelRoutingRule
)
=>
resolveCreateRuleKey
(
rule
)
const
getEditRuleRenderKey
=
(
rule
:
ModelRoutingRule
)
=>
resolveEditRuleKey
(
rule
)
const
getCreateRuleSearchKey
=
(
rule
:
ModelRoutingRule
)
=>
`create-${resolveCreateRuleKey(rule)
}
`
const
getEditRuleSearchKey
=
(
rule
:
ModelRoutingRule
)
=>
`edit-${resolveEditRuleKey(rule)
}
`
const
getRuleSearchKey
=
(
rule
:
ModelRoutingRule
,
isEdit
:
boolean
=
false
)
=>
{
return
isEdit
?
getEditRuleSearchKey
(
rule
)
:
getCreateRuleSearchKey
(
rule
)
}
// 账号搜索相关状态
// 账号搜索相关状态
const
accountSearchKeyword
=
ref
<
Record
<
string
,
string
>>
({
}
)
// 每个规则的搜索关键词 (key: "create-0" 或 "edit-0")
const
accountSearchKeyword
=
ref
<
Record
<
string
,
string
>>
({
}
)
const
accountSearchResults
=
ref
<
Record
<
string
,
SimpleAccount
[]
>>
({
}
)
// 每个规则的搜索结果
const
accountSearchResults
=
ref
<
Record
<
string
,
SimpleAccount
[]
>>
({
}
)
const
showAccountDropdown
=
ref
<
Record
<
string
,
boolean
>>
({
}
)
// 每个规则的下拉框显示状态
const
showAccountDropdown
=
ref
<
Record
<
string
,
boolean
>>
({
}
)
let
accountSearchTimeout
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
// 搜索账号(仅限 anthropic 平台)
const
clearAccountSearchStateByKey
=
(
key
:
string
)
=>
{
const
searchAccounts
=
async
(
key
:
string
)
=>
{
delete
accountSearchKeyword
.
value
[
key
]
if
(
accountSearchTimeout
)
clearTimeout
(
accountSearchTimeout
)
delete
accountSearchResults
.
value
[
key
]
accountSearchTimeout
=
setTimeout
(
async
()
=>
{
delete
showAccountDropdown
.
value
[
key
]
const
keyword
=
accountSearchKeyword
.
value
[
key
]
||
''
}
try
{
const
res
=
await
adminAPI
.
accounts
.
list
(
1
,
20
,
{
const
clearAllAccountSearchState
=
()
=>
{
accountSearchKeyword
.
value
=
{
}
accountSearchResults
.
value
=
{
}
showAccountDropdown
.
value
=
{
}
}
const
accountSearchRunner
=
useKeyedDebouncedSearch
<
SimpleAccount
[]
>
({
delay
:
300
,
search
:
async
(
keyword
,
{
signal
}
)
=>
{
const
res
=
await
adminAPI
.
accounts
.
list
(
1
,
20
,
{
search
:
keyword
,
search
:
keyword
,
platform
:
'
anthropic
'
platform
:
'
anthropic
'
}
)
}
,
accountSearchResults
.
value
[
key
]
=
res
.
items
.
map
((
a
)
=>
({
id
:
a
.
id
,
name
:
a
.
name
}
))
{
signal
}
}
catch
{
)
return
res
.
items
.
map
((
account
)
=>
({
id
:
account
.
id
,
name
:
account
.
name
}
))
}
,
onSuccess
:
(
key
,
result
)
=>
{
accountSearchResults
.
value
[
key
]
=
result
}
,
onError
:
(
key
)
=>
{
accountSearchResults
.
value
[
key
]
=
[]
accountSearchResults
.
value
[
key
]
=
[]
}
}
}
,
300
)
}
)
// 搜索账号(仅限 anthropic 平台)
const
searchAccounts
=
(
key
:
string
)
=>
{
accountSearchRunner
.
trigger
(
key
,
accountSearchKeyword
.
value
[
key
]
||
''
)
}
const
searchAccountsByRule
=
(
rule
:
ModelRoutingRule
,
isEdit
:
boolean
=
false
)
=>
{
searchAccounts
(
getRuleSearchKey
(
rule
,
isEdit
))
}
}
// 选择账号
// 选择账号
const
selectAccount
=
(
ruleIndex
:
number
,
account
:
SimpleAccount
,
isEdit
:
boolean
=
false
)
=>
{
const
selectAccount
=
(
rule
:
ModelRoutingRule
,
account
:
SimpleAccount
,
isEdit
:
boolean
=
false
)
=>
{
const
rules
=
isEdit
?
editModelRoutingRules
.
value
:
createModelRoutingRules
.
value
const
rule
=
rules
[
ruleIndex
]
if
(
!
rule
)
return
if
(
!
rule
)
return
// 检查是否已选择
// 检查是否已选择
...
@@ -1946,15 +1985,13 @@ const selectAccount = (ruleIndex: number, account: SimpleAccount, isEdit: boolea
...
@@ -1946,15 +1985,13 @@ const selectAccount = (ruleIndex: number, account: SimpleAccount, isEdit: boolea
}
}
// 清空搜索
// 清空搜索
const
key
=
`${isEdit ? 'edit' : 'create'
}
-${ruleIndex
}
`
const
key
=
getRuleSearchKey
(
rule
,
isEdit
)
accountSearchKeyword
.
value
[
key
]
=
''
accountSearchKeyword
.
value
[
key
]
=
''
showAccountDropdown
.
value
[
key
]
=
false
showAccountDropdown
.
value
[
key
]
=
false
}
}
// 移除已选账号
// 移除已选账号
const
removeSelectedAccount
=
(
ruleIndex
:
number
,
accountId
:
number
,
isEdit
:
boolean
=
false
)
=>
{
const
removeSelectedAccount
=
(
rule
:
ModelRoutingRule
,
accountId
:
number
,
_isEdit
:
boolean
=
false
)
=>
{
const
rules
=
isEdit
?
editModelRoutingRules
.
value
:
createModelRoutingRules
.
value
const
rule
=
rules
[
ruleIndex
]
if
(
!
rule
)
return
if
(
!
rule
)
return
rule
.
accounts
=
rule
.
accounts
.
filter
(
a
=>
a
.
id
!==
accountId
)
rule
.
accounts
=
rule
.
accounts
.
filter
(
a
=>
a
.
id
!==
accountId
)
...
@@ -1981,8 +2018,8 @@ const toggleEditScope = (scope: string) => {
...
@@ -1981,8 +2018,8 @@ const toggleEditScope = (scope: string) => {
}
}
// 处理账号搜索输入框聚焦
// 处理账号搜索输入框聚焦
const
onAccountSearchFocus
=
(
rule
Index
:
number
,
isEdit
:
boolean
=
false
)
=>
{
const
onAccountSearchFocus
=
(
rule
:
ModelRoutingRule
,
isEdit
:
boolean
=
false
)
=>
{
const
key
=
`${isEdit ? 'edit' : 'create'
}
-${ruleIndex
}
`
const
key
=
getRuleSearchKey
(
rule
,
isEdit
)
showAccountDropdown
.
value
[
key
]
=
true
showAccountDropdown
.
value
[
key
]
=
true
// 如果没有搜索结果,触发一次搜索
// 如果没有搜索结果,触发一次搜索
if
(
!
accountSearchResults
.
value
[
key
]?.
length
)
{
if
(
!
accountSearchResults
.
value
[
key
]?.
length
)
{
...
@@ -1996,13 +2033,14 @@ const addCreateRoutingRule = () => {
...
@@ -1996,13 +2033,14 @@ const addCreateRoutingRule = () => {
}
}
// 删除创建表单的路由规则
// 删除创建表单的路由规则
const
removeCreateRoutingRule
=
(
index
:
number
)
=>
{
const
removeCreateRoutingRule
=
(
rule
:
ModelRoutingRule
)
=>
{
const
index
=
createModelRoutingRules
.
value
.
indexOf
(
rule
)
if
(
index
===
-
1
)
return
const
key
=
getCreateRuleSearchKey
(
rule
)
accountSearchRunner
.
clearKey
(
key
)
clearAccountSearchStateByKey
(
key
)
createModelRoutingRules
.
value
.
splice
(
index
,
1
)
createModelRoutingRules
.
value
.
splice
(
index
,
1
)
// 清理相关的搜索状态
const
key
=
`create-${index
}
`
delete
accountSearchKeyword
.
value
[
key
]
delete
accountSearchResults
.
value
[
key
]
delete
showAccountDropdown
.
value
[
key
]
}
}
// 添加编辑表单的路由规则
// 添加编辑表单的路由规则
...
@@ -2011,13 +2049,14 @@ const addEditRoutingRule = () => {
...
@@ -2011,13 +2049,14 @@ const addEditRoutingRule = () => {
}
}
// 删除编辑表单的路由规则
// 删除编辑表单的路由规则
const
removeEditRoutingRule
=
(
index
:
number
)
=>
{
const
removeEditRoutingRule
=
(
rule
:
ModelRoutingRule
)
=>
{
const
index
=
editModelRoutingRules
.
value
.
indexOf
(
rule
)
if
(
index
===
-
1
)
return
const
key
=
getEditRuleSearchKey
(
rule
)
accountSearchRunner
.
clearKey
(
key
)
clearAccountSearchStateByKey
(
key
)
editModelRoutingRules
.
value
.
splice
(
index
,
1
)
editModelRoutingRules
.
value
.
splice
(
index
,
1
)
// 清理相关的搜索状态
const
key
=
`edit-${index
}
`
delete
accountSearchKeyword
.
value
[
key
]
delete
accountSearchResults
.
value
[
key
]
delete
showAccountDropdown
.
value
[
key
]
}
}
// 将 UI 格式的路由规则转换为 API 格式
// 将 UI 格式的路由规则转换为 API 格式
...
@@ -2161,6 +2200,10 @@ const handlePageSizeChange = (pageSize: number) => {
...
@@ -2161,6 +2200,10 @@ const handlePageSizeChange = (pageSize: number) => {
const
closeCreateModal
=
()
=>
{
const
closeCreateModal
=
()
=>
{
showCreateModal
.
value
=
false
showCreateModal
.
value
=
false
createModelRoutingRules
.
value
.
forEach
((
rule
)
=>
{
accountSearchRunner
.
clearKey
(
getCreateRuleSearchKey
(
rule
))
}
)
clearAllAccountSearchState
()
createForm
.
name
=
''
createForm
.
name
=
''
createForm
.
description
=
''
createForm
.
description
=
''
createForm
.
platform
=
'
anthropic
'
createForm
.
platform
=
'
anthropic
'
...
@@ -2247,6 +2290,10 @@ const handleEdit = async (group: AdminGroup) => {
...
@@ -2247,6 +2290,10 @@ const handleEdit = async (group: AdminGroup) => {
}
}
const
closeEditModal
=
()
=>
{
const
closeEditModal
=
()
=>
{
editModelRoutingRules
.
value
.
forEach
((
rule
)
=>
{
accountSearchRunner
.
clearKey
(
getEditRuleSearchKey
(
rule
))
}
)
clearAllAccountSearchState
()
showEditModal
.
value
=
false
showEditModal
.
value
=
false
editingGroup
.
value
=
null
editingGroup
.
value
=
null
editModelRoutingRules
.
value
=
[]
editModelRoutingRules
.
value
=
[]
...
@@ -2382,5 +2429,7 @@ onMounted(() => {
...
@@ -2382,5 +2429,7 @@ onMounted(() => {
onUnmounted
(()
=>
{
onUnmounted
(()
=>
{
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
accountSearchRunner
.
clearAll
()
clearAllAccountSearchState
()
}
)
}
)
<
/script
>
<
/script
>
frontend/src/views/admin/UsageView.vue
View file @
f6bff97d
...
@@ -94,15 +94,7 @@ const exportToExcel = async () => {
...
@@ -94,15 +94,7 @@ const exportToExcel = async () => {
if
(
exporting
.
value
)
return
;
exporting
.
value
=
true
;
exportProgress
.
show
=
true
if
(
exporting
.
value
)
return
;
exporting
.
value
=
true
;
exportProgress
.
show
=
true
const
c
=
new
AbortController
();
exportAbortController
=
c
const
c
=
new
AbortController
();
exportAbortController
=
c
try
{
try
{
const
all
:
AdminUsageLog
[]
=
[];
let
p
=
1
;
let
total
=
pagination
.
total
let
p
=
1
;
let
total
=
pagination
.
total
;
let
exportedCount
=
0
while
(
true
)
{
const
res
=
await
adminUsageAPI
.
list
({
page
:
p
,
page_size
:
100
,
...
filters
.
value
},
{
signal
:
c
.
signal
})
if
(
c
.
signal
.
aborted
)
break
;
if
(
p
===
1
)
{
total
=
res
.
total
;
exportProgress
.
total
=
total
}
if
(
res
.
items
?.
length
)
all
.
push
(...
res
.
items
)
exportProgress
.
current
=
all
.
length
;
exportProgress
.
progress
=
total
>
0
?
Math
.
min
(
100
,
Math
.
round
(
all
.
length
/
total
*
100
))
:
0
if
(
all
.
length
>=
total
||
res
.
items
.
length
<
100
)
break
;
p
++
}
if
(
!
c
.
signal
.
aborted
)
{
const
XLSX
=
await
import
(
'
xlsx
'
)
const
XLSX
=
await
import
(
'
xlsx
'
)
const
headers
=
[
const
headers
=
[
t
(
'
usage.time
'
),
t
(
'
admin.usage.user
'
),
t
(
'
usage.apiKeyFilter
'
),
t
(
'
usage.time
'
),
t
(
'
admin.usage.user
'
),
t
(
'
usage.apiKeyFilter
'
),
...
@@ -116,35 +108,30 @@ const exportToExcel = async () => {
...
@@ -116,35 +108,30 @@ const exportToExcel = async () => {
t
(
'
usage.firstToken
'
),
t
(
'
usage.duration
'
),
t
(
'
usage.firstToken
'
),
t
(
'
usage.duration
'
),
t
(
'
admin.usage.requestId
'
),
t
(
'
usage.userAgent
'
),
t
(
'
admin.usage.ipAddress
'
)
t
(
'
admin.usage.requestId
'
),
t
(
'
usage.userAgent
'
),
t
(
'
admin.usage.ipAddress
'
)
]
]
const
rows
=
all
.
map
(
log
=>
[
const
ws
=
XLSX
.
utils
.
aoa_to_sheet
([
headers
])
log
.
created_at
,
while
(
true
)
{
log
.
user
?.
email
||
''
,
const
res
=
await
adminUsageAPI
.
list
({
page
:
p
,
page_size
:
100
,
...
filters
.
value
},
{
signal
:
c
.
signal
})
log
.
api_key
?.
name
||
''
,
if
(
c
.
signal
.
aborted
)
break
;
if
(
p
===
1
)
{
total
=
res
.
total
;
exportProgress
.
total
=
total
}
log
.
account
?.
name
||
''
,
const
rows
=
(
res
.
items
||
[]).
map
((
log
:
AdminUsageLog
)
=>
[
log
.
model
,
log
.
created_at
,
log
.
user
?.
email
||
''
,
log
.
api_key
?.
name
||
''
,
log
.
account
?.
name
||
''
,
log
.
model
,
formatReasoningEffort
(
log
.
reasoning_effort
),
formatReasoningEffort
(
log
.
reasoning_effort
),
log
.
group
?.
name
||
''
,
log
.
stream
?
t
(
'
usage.stream
'
)
:
t
(
'
usage.sync
'
),
log
.
group
?.
name
||
''
,
log
.
input_tokens
,
log
.
output_tokens
,
log
.
cache_read_tokens
,
log
.
cache_creation_tokens
,
log
.
stream
?
t
(
'
usage.stream
'
)
:
t
(
'
usage.sync
'
),
log
.
input_cost
?.
toFixed
(
6
)
||
'
0.000000
'
,
log
.
output_cost
?.
toFixed
(
6
)
||
'
0.000000
'
,
log
.
input_tokens
,
log
.
cache_read_cost
?.
toFixed
(
6
)
||
'
0.000000
'
,
log
.
cache_creation_cost
?.
toFixed
(
6
)
||
'
0.000000
'
,
log
.
output_tokens
,
log
.
rate_multiplier
?.
toFixed
(
2
)
||
'
1.00
'
,
(
log
.
account_rate_multiplier
??
1
).
toFixed
(
2
),
log
.
cache_read_tokens
,
log
.
total_cost
?.
toFixed
(
6
)
||
'
0.000000
'
,
log
.
actual_cost
?.
toFixed
(
6
)
||
'
0.000000
'
,
log
.
cache_creation_tokens
,
(
log
.
total_cost
*
(
log
.
account_rate_multiplier
??
1
)).
toFixed
(
6
),
log
.
first_token_ms
??
''
,
log
.
duration_ms
,
log
.
input_cost
?.
toFixed
(
6
)
||
'
0.000000
'
,
log
.
request_id
||
''
,
log
.
user_agent
||
''
,
log
.
ip_address
||
''
log
.
output_cost
?.
toFixed
(
6
)
||
'
0.000000
'
,
log
.
cache_read_cost
?.
toFixed
(
6
)
||
'
0.000000
'
,
log
.
cache_creation_cost
?.
toFixed
(
6
)
||
'
0.000000
'
,
log
.
rate_multiplier
?.
toFixed
(
2
)
||
'
1.00
'
,
(
log
.
account_rate_multiplier
??
1
).
toFixed
(
2
),
log
.
total_cost
?.
toFixed
(
6
)
||
'
0.000000
'
,
log
.
actual_cost
?.
toFixed
(
6
)
||
'
0.000000
'
,
(
log
.
total_cost
*
(
log
.
account_rate_multiplier
??
1
)).
toFixed
(
6
),
log
.
first_token_ms
??
''
,
log
.
duration_ms
,
log
.
request_id
||
''
,
log
.
user_agent
||
''
,
log
.
ip_address
||
''
])
])
const
ws
=
XLSX
.
utils
.
aoa_to_sheet
([
headers
,
...
rows
])
if
(
rows
.
length
)
{
XLSX
.
utils
.
sheet_add_aoa
(
ws
,
rows
,
{
origin
:
-
1
})
}
exportedCount
+=
rows
.
length
exportProgress
.
current
=
exportedCount
exportProgress
.
progress
=
total
>
0
?
Math
.
min
(
100
,
Math
.
round
(
exportedCount
/
total
*
100
))
:
0
if
(
exportedCount
>=
total
||
res
.
items
.
length
<
100
)
break
;
p
++
}
if
(
!
c
.
signal
.
aborted
)
{
const
wb
=
XLSX
.
utils
.
book_new
()
const
wb
=
XLSX
.
utils
.
book_new
()
XLSX
.
utils
.
book_append_sheet
(
wb
,
ws
,
'
Usage
'
)
XLSX
.
utils
.
book_append_sheet
(
wb
,
ws
,
'
Usage
'
)
saveAs
(
new
Blob
([
XLSX
.
write
(
wb
,
{
bookType
:
'
xlsx
'
,
type
:
'
array
'
})],
{
type
:
'
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
'
}),
`usage_
${
filters
.
value
.
start_date
}
_to_
${
filters
.
value
.
end_date
}
.xlsx`
)
saveAs
(
new
Blob
([
XLSX
.
write
(
wb
,
{
bookType
:
'
xlsx
'
,
type
:
'
array
'
})],
{
type
:
'
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
'
}),
`usage_
${
filters
.
value
.
start_date
}
_to_
${
filters
.
value
.
end_date
}
.xlsx`
)
...
...
Prev
1
2
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