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
Hide whitespace changes
Inline
Side-by-side
frontend/src/App.vue
View file @
f6bff97d
...
...
@@ -39,16 +39,6 @@ watch(
{
immediate
:
true
}
)
watch
(
()
=>
appStore
.
siteName
,
(
newName
)
=>
{
if
(
newName
)
{
document
.
title
=
`
${
newName
}
- AI API Gateway`
}
},
{
immediate
:
true
}
)
// Watch for authentication state and manage subscription data
watch
(
()
=>
authStore
.
isAuthenticated
,
...
...
frontend/src/__tests__/integration/data-import.spec.ts
View file @
f6bff97d
...
...
@@ -58,12 +58,16 @@ describe('ImportDataModal', () => {
const
input
=
wrapper
.
find
(
'
input[type="file"]
'
)
const
file
=
new
File
([
'
invalid json
'
],
'
data.json
'
,
{
type
:
'
application/json
'
})
Object
.
defineProperty
(
file
,
'
text
'
,
{
value
:
()
=>
Promise
.
resolve
(
'
invalid json
'
)
})
Object
.
defineProperty
(
input
.
element
,
'
files
'
,
{
value
:
[
file
]
})
await
input
.
trigger
(
'
change
'
)
await
wrapper
.
find
(
'
form
'
).
trigger
(
'
submit
'
)
await
Promise
.
resolve
()
expect
(
showError
).
toHaveBeenCalledWith
(
'
admin.accounts.dataImportParseFailed
'
)
})
...
...
frontend/src/__tests__/integration/proxy-data-import.spec.ts
View file @
f6bff97d
...
...
@@ -58,12 +58,16 @@ describe('Proxy ImportDataModal', () => {
const
input
=
wrapper
.
find
(
'
input[type="file"]
'
)
const
file
=
new
File
([
'
invalid json
'
],
'
data.json
'
,
{
type
:
'
application/json
'
})
Object
.
defineProperty
(
file
,
'
text
'
,
{
value
:
()
=>
Promise
.
resolve
(
'
invalid json
'
)
})
Object
.
defineProperty
(
input
.
element
,
'
files
'
,
{
value
:
[
file
]
})
await
input
.
trigger
(
'
change
'
)
await
wrapper
.
find
(
'
form
'
).
trigger
(
'
submit
'
)
await
Promise
.
resolve
()
expect
(
showError
).
toHaveBeenCalledWith
(
'
admin.proxies.dataImportParseFailed
'
)
})
...
...
frontend/src/components/account/BulkEditAccountModal.vue
View file @
f6bff97d
...
...
@@ -209,7 +209,7 @@
<
div
v
-
if
=
"
modelMappings.length > 0
"
class
=
"
mb-3 space-y-2
"
>
<
div
v
-
for
=
"
(mapping, index) in modelMappings
"
:
key
=
"
index
"
:
key
=
"
getModelMappingKey(mapping)
"
class
=
"
flex items-center gap-2
"
>
<
input
...
...
@@ -654,6 +654,7 @@ import Select from '@/components/common/Select.vue'
import
ProxySelector
from
'
@/components/common/ProxySelector.vue
'
import
GroupSelector
from
'
@/components/common/GroupSelector.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
createStableObjectKeyResolver
}
from
'
@/utils/stableObjectKey
'
interface
Props
{
show
:
boolean
...
...
@@ -695,6 +696,7 @@ const baseUrl = ref('')
const
modelRestrictionMode
=
ref
<
'
whitelist
'
|
'
mapping
'
>
(
'
whitelist
'
)
const
allowedModels
=
ref
<
string
[]
>
([])
const
modelMappings
=
ref
<
ModelMapping
[]
>
([])
const
getModelMappingKey
=
createStableObjectKeyResolver
<
ModelMapping
>
(
'
bulk-model-mapping
'
)
const
selectedErrorCodes
=
ref
<
number
[]
>
([])
const
customErrorCodeInput
=
ref
<
number
|
null
>
(
null
)
const
interceptWarmupRequests
=
ref
(
false
)
...
...
frontend/src/components/account/CreateAccountModal.vue
View file @
f6bff97d
...
...
@@ -714,7 +714,7 @@
<div
v-if=
"antigravityModelMappings.length > 0"
class=
"mb-3 space-y-2"
>
<div
v-for=
"(mapping, index) in antigravityModelMappings"
:key=
"
index
"
:key=
"
getAntigravityModelMappingKey(mapping)
"
class=
"space-y-1"
>
<div
class=
"flex items-center gap-2"
>
...
...
@@ -966,7 +966,7 @@
<
div
v
-
if
=
"
modelMappings.length > 0
"
class
=
"
mb-3 space-y-2
"
>
<
div
v
-
for
=
"
(mapping, index) in modelMappings
"
:
key
=
"
index
"
:
key
=
"
getModelMappingKey(mapping)
"
class
=
"
flex items-center gap-2
"
>
<
input
...
...
@@ -1225,7 +1225,7 @@
<
div
v
-
if
=
"
tempUnschedRules.length > 0
"
class
=
"
space-y-3
"
>
<
div
v
-
for
=
"
(rule, index) in tempUnschedRules
"
:
key
=
"
index
"
:
key
=
"
getTempUnschedRuleKey(rule)
"
class
=
"
rounded-lg border border-gray-200 p-3 dark:border-dark-600
"
>
<
div
class
=
"
mb-2 flex items-center justify-between
"
>
...
...
@@ -2097,6 +2097,7 @@ import ProxySelector from '@/components/common/ProxySelector.vue'
import
GroupSelector
from
'
@/components/common/GroupSelector.vue
'
import
ModelWhitelistSelector
from
'
@/components/account/ModelWhitelistSelector.vue
'
import
{
formatDateTimeLocalInput
,
parseDateTimeLocalInput
}
from
'
@/utils/format
'
import
{
createStableObjectKeyResolver
}
from
'
@/utils/stableObjectKey
'
import
OAuthAuthorizationFlow
from
'
./OAuthAuthorizationFlow.vue
'
// Type for exposed OAuthAuthorizationFlow component
...
...
@@ -2227,6 +2228,9 @@ const antigravityModelMappings = ref<ModelMapping[]>([])
const
antigravityPresetMappings
=
computed
(()
=>
getPresetMappingsByPlatform
(
'
antigravity
'
))
const
tempUnschedEnabled
=
ref
(
false
)
const
tempUnschedRules
=
ref
<
TempUnschedRuleForm
[]
>
([])
const
getModelMappingKey
=
createStableObjectKeyResolver
<
ModelMapping
>
(
'
create-model-mapping
'
)
const
getAntigravityModelMappingKey
=
createStableObjectKeyResolver
<
ModelMapping
>
(
'
create-antigravity-model-mapping
'
)
const
getTempUnschedRuleKey
=
createStableObjectKeyResolver
<
TempUnschedRuleForm
>
(
'
create-temp-unsched-rule
'
)
const
geminiOAuthType
=
ref
<
'
code_assist
'
|
'
google_one
'
|
'
ai_studio
'
>
(
'
google_one
'
)
const
geminiAIStudioOAuthEnabled
=
ref
(
false
)
...
...
frontend/src/components/account/EditAccountModal.vue
View file @
f6bff97d
...
...
@@ -169,7 +169,7 @@
<
div
v
-
if
=
"
modelMappings.length > 0
"
class
=
"
mb-3 space-y-2
"
>
<
div
v
-
for
=
"
(mapping, index) in modelMappings
"
:
key
=
"
index
"
:
key
=
"
getModelMappingKey(mapping)
"
class
=
"
flex items-center gap-2
"
>
<
input
...
...
@@ -417,7 +417,7 @@
<
div
v
-
if
=
"
antigravityModelMappings.length > 0
"
class
=
"
mb-3 space-y-2
"
>
<
div
v
-
for
=
"
(mapping, index) in antigravityModelMappings
"
:
key
=
"
index
"
:
key
=
"
getAntigravityModelMappingKey(mapping)
"
class
=
"
space-y-1
"
>
<
div
class
=
"
flex items-center gap-2
"
>
...
...
@@ -542,7 +542,7 @@
<
div
v
-
if
=
"
tempUnschedRules.length > 0
"
class
=
"
space-y-3
"
>
<
div
v
-
for
=
"
(rule, index) in tempUnschedRules
"
:
key
=
"
index
"
:
key
=
"
getTempUnschedRuleKey(rule)
"
class
=
"
rounded-lg border border-gray-200 p-3 dark:border-dark-600
"
>
<
div
class
=
"
mb-2 flex items-center justify-between
"
>
...
...
@@ -1093,6 +1093,7 @@ import ProxySelector from '@/components/common/ProxySelector.vue'
import
GroupSelector
from
'
@/components/common/GroupSelector.vue
'
import
ModelWhitelistSelector
from
'
@/components/account/ModelWhitelistSelector.vue
'
import
{
formatDateTimeLocalInput
,
parseDateTimeLocalInput
}
from
'
@/utils/format
'
import
{
createStableObjectKeyResolver
}
from
'
@/utils/stableObjectKey
'
import
{
getPresetMappingsByPlatform
,
commonErrorCodes
,
...
...
@@ -1158,6 +1159,9 @@ const antigravityWhitelistModels = ref<string[]>([])
const
antigravityModelMappings
=
ref
<
ModelMapping
[]
>
([])
const
tempUnschedEnabled
=
ref
(
false
)
const
tempUnschedRules
=
ref
<
TempUnschedRuleForm
[]
>
([])
const
getModelMappingKey
=
createStableObjectKeyResolver
<
ModelMapping
>
(
'
edit-model-mapping
'
)
const
getAntigravityModelMappingKey
=
createStableObjectKeyResolver
<
ModelMapping
>
(
'
edit-antigravity-model-mapping
'
)
const
getTempUnschedRuleKey
=
createStableObjectKeyResolver
<
TempUnschedRuleForm
>
(
'
edit-temp-unsched-rule
'
)
// Mixed channel warning dialog state
const
showMixedChannelWarning
=
ref
(
false
)
...
...
frontend/src/components/admin/account/ImportDataModal.vue
View file @
f6bff97d
...
...
@@ -143,6 +143,24 @@ const handleClose = () => {
emit
(
'
close
'
)
}
const
readFileAsText
=
async
(
sourceFile
:
File
):
Promise
<
string
>
=>
{
if
(
typeof
sourceFile
.
text
===
'
function
'
)
{
return
sourceFile
.
text
()
}
if
(
typeof
sourceFile
.
arrayBuffer
===
'
function
'
)
{
const
buffer
=
await
sourceFile
.
arrayBuffer
()
return
new
TextDecoder
().
decode
(
buffer
)
}
return
await
new
Promise
<
string
>
((
resolve
,
reject
)
=>
{
const
reader
=
new
FileReader
()
reader
.
onload
=
()
=>
resolve
(
String
(
reader
.
result
??
''
))
reader
.
onerror
=
()
=>
reject
(
reader
.
error
||
new
Error
(
'
Failed to read file
'
))
reader
.
readAsText
(
sourceFile
)
})
}
const
handleImport
=
async
()
=>
{
if
(
!
file
.
value
)
{
appStore
.
showError
(
t
(
'
admin.accounts.dataImportSelectFile
'
))
...
...
@@ -151,7 +169,7 @@ const handleImport = async () => {
importing
.
value
=
true
try
{
const
text
=
await
file
.
value
.
text
(
)
const
text
=
await
readFileAsText
(
file
.
value
)
const
dataPayload
=
JSON
.
parse
(
text
)
const
res
=
await
adminAPI
.
accounts
.
importData
({
...
...
frontend/src/components/admin/proxy/ImportDataModal.vue
View file @
f6bff97d
...
...
@@ -143,6 +143,24 @@ const handleClose = () => {
emit
(
'
close
'
)
}
const
readFileAsText
=
async
(
sourceFile
:
File
):
Promise
<
string
>
=>
{
if
(
typeof
sourceFile
.
text
===
'
function
'
)
{
return
sourceFile
.
text
()
}
if
(
typeof
sourceFile
.
arrayBuffer
===
'
function
'
)
{
const
buffer
=
await
sourceFile
.
arrayBuffer
()
return
new
TextDecoder
().
decode
(
buffer
)
}
return
await
new
Promise
<
string
>
((
resolve
,
reject
)
=>
{
const
reader
=
new
FileReader
()
reader
.
onload
=
()
=>
resolve
(
String
(
reader
.
result
??
''
))
reader
.
onerror
=
()
=>
reject
(
reader
.
error
||
new
Error
(
'
Failed to read file
'
))
reader
.
readAsText
(
sourceFile
)
})
}
const
handleImport
=
async
()
=>
{
if
(
!
file
.
value
)
{
appStore
.
showError
(
t
(
'
admin.proxies.dataImportSelectFile
'
))
...
...
@@ -151,7 +169,7 @@ const handleImport = async () => {
importing
.
value
=
true
try
{
const
text
=
await
file
.
value
.
text
(
)
const
text
=
await
readFileAsText
(
file
.
value
)
const
dataPayload
=
JSON
.
parse
(
text
)
const
res
=
await
adminAPI
.
proxies
.
importData
({
data
:
dataPayload
})
...
...
frontend/src/components/common/DataTable.vue
View file @
f6bff97d
...
...
@@ -3,7 +3,7 @@
<template
v-if=
"loading"
>
<div
v-for=
"i in 5"
:key=
"i"
class=
"rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900"
>
<div
class=
"space-y-3"
>
<div
v-for=
"column in
c
olumns
.filter(c => c.key !== 'actions')
"
:key=
"column.key"
class=
"flex justify-between"
>
<div
v-for=
"column in
dataC
olumns"
:key=
"column.key"
class=
"flex justify-between"
>
<div
class=
"h-4 w-20 animate-pulse rounded bg-gray-200 dark:bg-dark-700"
></div>
<div
class=
"h-4 w-32 animate-pulse rounded bg-gray-200 dark:bg-dark-700"
></div>
</div>
...
...
@@ -39,7 +39,7 @@
>
<div
class=
"space-y-3"
>
<div
v-for=
"column in
c
olumns
.filter(c => c.key !== 'actions')
"
v-for=
"column in
dataC
olumns"
:key=
"column.key"
class=
"flex items-start justify-between gap-4"
>
...
...
@@ -439,10 +439,15 @@ const resolveRowKey = (row: any, index: number) => {
return
key
??
index
}
const
dataColumns
=
computed
(()
=>
props
.
columns
.
filter
((
column
)
=>
column
.
key
!==
'
actions
'
))
const
columnsSignature
=
computed
(()
=>
props
.
columns
.
map
((
column
)
=>
`
${
column
.
key
}
:
${
column
.
sortable
?
'
1
'
:
'
0
'
}
`
).
join
(
'
|
'
)
)
// 数据/列变化时重新检查滚动状态
// 注意:不能监听 actionsExpanded,因为 checkActionsColumnWidth 会临时修改它,会导致无限循环
watch
(
[()
=>
props
.
data
.
length
,
()
=>
props
.
columns
],
[()
=>
props
.
data
.
length
,
columnsSignature
],
async
()
=>
{
await
nextTick
()
checkScrollable
()
...
...
@@ -555,7 +560,7 @@ onMounted(() => {
})
watch
(
()
=>
props
.
columns
,
columnsSignature
,
()
=>
{
// If current sort key is no longer sortable/visible, fall back to default/persisted.
const
normalized
=
normalizeSortKey
(
sortKey
.
value
)
...
...
@@ -575,7 +580,7 @@ watch(
}
}
},
{
deep
:
true
}
{
flush
:
'
post
'
}
)
watch
(
...
...
frontend/src/components/common/LocaleSwitcher.vue
View file @
f6bff97d
...
...
@@ -2,6 +2,7 @@
<div
class=
"relative"
ref=
"dropdownRef"
>
<button
@
click=
"toggleDropdown"
:disabled=
"switching"
class=
"flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
:title=
"currentLocale?.name"
>
...
...
@@ -23,6 +24,7 @@
<button
v-for=
"locale in availableLocales"
:key=
"locale.code"
:disabled=
"switching"
@
click=
"selectLocale(locale.code)"
class=
"flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-dark-700"
:class=
"
{
...
...
@@ -49,6 +51,7 @@ const { locale } = useI18n()
const
isOpen
=
ref
(
false
)
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
switching
=
ref
(
false
)
const
currentLocaleCode
=
computed
(()
=>
locale
.
value
)
const
currentLocale
=
computed
(()
=>
availableLocales
.
find
((
l
)
=>
l
.
code
===
locale
.
value
))
...
...
@@ -57,9 +60,18 @@ function toggleDropdown() {
isOpen
.
value
=
!
isOpen
.
value
}
function
selectLocale
(
code
:
string
)
{
setLocale
(
code
)
isOpen
.
value
=
false
async
function
selectLocale
(
code
:
string
)
{
if
(
switching
.
value
||
code
===
currentLocaleCode
.
value
)
{
isOpen
.
value
=
false
return
}
switching
.
value
=
true
try
{
await
setLocale
(
code
)
isOpen
.
value
=
false
}
finally
{
switching
.
value
=
false
}
}
function
handleClickOutside
(
event
:
MouseEvent
)
{
...
...
frontend/src/components/common/Pagination.vue
View file @
f6bff97d
...
...
@@ -84,8 +84,8 @@
<!--
Page
numbers
-->
<
button
v
-
for
=
"
pageNum in visiblePages
"
:
key
=
"
pageNum
"
v
-
for
=
"
(
pageNum
, index)
in visiblePages
"
:
key
=
"
`${
pageNum
}
-${index
}
`
"
@
click
=
"
typeof pageNum === 'number' && goToPage(pageNum)
"
:
disabled
=
"
typeof pageNum !== 'number'
"
:
class
=
"
[
...
...
frontend/src/components/common/Toast.vue
View file @
f6bff97d
...
...
@@ -66,8 +66,8 @@
<!-- Progress bar -->
<div
v-if=
"toast.duration"
class=
"h-1 bg-gray-100 dark:bg-dark-700"
>
<div
:class=
"['h-full t
ransition-all
', getProgressBarColor(toast.type)]"
:style=
"
{
width: `${getProgress(toast)}%
` }"
:class=
"['h-full t
oast-progress
', getProgressBarColor(toast.type)]"
:style=
"
{
animationDuration: `${toast.duration}ms
` }"
>
</div>
</div>
</div>
...
...
@@ -77,7 +77,7 @@
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
computed
}
from
'
vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
useAppStore
}
from
'
@/stores/app
'
...
...
@@ -129,36 +129,25 @@ const getProgressBarColor = (type: string): string => {
return
colors
[
type
]
||
colors
.
info
}
const
getProgress
=
(
toast
:
any
):
number
=>
{
if
(
!
toast
.
duration
||
!
toast
.
startTime
)
return
100
const
elapsed
=
Date
.
now
()
-
toast
.
startTime
const
progress
=
Math
.
max
(
0
,
100
-
(
elapsed
/
toast
.
duration
)
*
100
)
return
progress
}
const
removeToast
=
(
id
:
string
)
=>
{
appStore
.
hideToast
(
id
)
}
</
script
>
let
intervalId
:
number
|
undefined
onMounted
(()
=>
{
// Check for expired toasts every 100ms
intervalId
=
window
.
setInterval
(()
=>
{
const
now
=
Date
.
now
()
toasts
.
value
.
forEach
((
toast
)
=>
{
if
(
toast
.
duration
&&
toast
.
startTime
)
{
if
(
now
-
toast
.
startTime
>=
toast
.
duration
)
{
removeToast
(
toast
.
id
)
}
}
})
},
100
)
})
<
style
scoped
>
.toast-progress
{
width
:
100%
;
animation-name
:
toast-progress-shrink
;
animation-timing-function
:
linear
;
animation-fill-mode
:
forwards
;
}
onUnmounted
(()
=>
{
if
(
intervalId
!==
undefined
)
{
clearInterval
(
intervalId
)
@keyframes
toast-progress-shrink
{
from
{
width
:
100%
;
}
})
</
script
>
to
{
width
:
0%
;
}
}
</
style
>
frontend/src/components/user/UserAttributesConfigModal.vue
View file @
f6bff97d
...
...
@@ -143,7 +143,7 @@
<!--
Options
(
for
select
/
multi_select
)
-->
<
div
v
-
if
=
"
form.type === 'select' || form.type === 'multi_select'
"
class
=
"
space-y-2
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.users.attributes.options
'
)
}}
<
/label
>
<
div
v
-
for
=
"
(option, index) in form.options
"
:
key
=
"
index
"
class
=
"
flex items-center gap-2
"
>
<
div
v
-
for
=
"
(option, index) in form.options
"
:
key
=
"
getOptionKey(option)
"
class
=
"
flex items-center gap-2
"
>
<
input
v
-
model
=
"
option.value
"
type
=
"
text
"
...
...
@@ -246,6 +246,7 @@ import BaseDialog from '@/components/common/BaseDialog.vue'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
{
createStableObjectKeyResolver
}
from
'
@/utils/stableObjectKey
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
...
...
@@ -270,6 +271,7 @@ const showEditModal = ref(false)
const
showDeleteDialog
=
ref
(
false
)
const
editingAttribute
=
ref
<
UserAttributeDefinition
|
null
>
(
null
)
const
deletingAttribute
=
ref
<
UserAttributeDefinition
|
null
>
(
null
)
const
getOptionKey
=
createStableObjectKeyResolver
<
UserAttributeOption
>
(
'
user-attr-option
'
)
const
form
=
reactive
({
key
:
''
,
...
...
@@ -315,7 +317,7 @@ const openEditModal = (attr: UserAttributeDefinition) => {
form
.
placeholder
=
attr
.
placeholder
||
''
form
.
required
=
attr
.
required
form
.
enabled
=
attr
.
enabled
form
.
options
=
attr
.
options
?
[...
attr
.
options
]
:
[]
form
.
options
=
attr
.
options
?
attr
.
options
.
map
((
opt
)
=>
({
...
opt
}
))
:
[]
showEditModal
.
value
=
true
}
...
...
frontend/src/components/user/profile/TotpDisableDialog.vue
View file @
f6bff97d
...
...
@@ -88,7 +88,7 @@
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
onMounted
,
computed
}
from
'
vue
'
import
{
ref
,
onMounted
,
onUnmounted
,
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
totpAPI
}
from
'
@/api
'
...
...
@@ -107,6 +107,7 @@ const loading = ref(false)
const
error
=
ref
(
''
)
const
sendingCode
=
ref
(
false
)
const
codeCooldown
=
ref
(
0
)
const
cooldownTimer
=
ref
<
ReturnType
<
typeof
setInterval
>
|
null
>
(
null
)
const
form
=
ref
({
emailCode
:
''
,
password
:
''
...
...
@@ -139,10 +140,17 @@ const handleSendCode = async () => {
appStore
.
showSuccess
(
t
(
'
profile.totp.codeSent
'
))
// Start cooldown
codeCooldown
.
value
=
60
const
timer
=
setInterval
(()
=>
{
if
(
cooldownTimer
.
value
)
{
clearInterval
(
cooldownTimer
.
value
)
cooldownTimer
.
value
=
null
}
cooldownTimer
.
value
=
setInterval
(()
=>
{
codeCooldown
.
value
--
if
(
codeCooldown
.
value
<=
0
)
{
clearInterval
(
timer
)
if
(
cooldownTimer
.
value
)
{
clearInterval
(
cooldownTimer
.
value
)
cooldownTimer
.
value
=
null
}
}
}
,
1000
)
}
catch
(
err
:
any
)
{
...
...
@@ -176,4 +184,11 @@ const handleDisable = async () => {
onMounted
(()
=>
{
loadVerificationMethod
()
}
)
onUnmounted
(()
=>
{
if
(
cooldownTimer
.
value
)
{
clearInterval
(
cooldownTimer
.
value
)
cooldownTimer
.
value
=
null
}
}
)
<
/script
>
frontend/src/components/user/profile/TotpSetupModal.vue
View file @
f6bff97d
...
...
@@ -175,7 +175,7 @@
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
onMounted
,
nextTick
,
watch
,
computed
}
from
'
vue
'
import
{
ref
,
onMounted
,
onUnmounted
,
nextTick
,
watch
,
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
totpAPI
}
from
'
@/api
'
...
...
@@ -198,6 +198,7 @@ const verifyForm = ref({ emailCode: '', password: '' })
const
verifyError
=
ref
(
''
)
const
sendingCode
=
ref
(
false
)
const
codeCooldown
=
ref
(
0
)
const
cooldownTimer
=
ref
<
ReturnType
<
typeof
setInterval
>
|
null
>
(
null
)
const
setupLoading
=
ref
(
false
)
const
setupData
=
ref
<
TotpSetupResponse
|
null
>
(
null
)
...
...
@@ -338,10 +339,17 @@ const handleSendCode = async () => {
appStore
.
showSuccess
(
t
(
'
profile.totp.codeSent
'
))
// Start cooldown
codeCooldown
.
value
=
60
const
timer
=
setInterval
(()
=>
{
if
(
cooldownTimer
.
value
)
{
clearInterval
(
cooldownTimer
.
value
)
cooldownTimer
.
value
=
null
}
cooldownTimer
.
value
=
setInterval
(()
=>
{
codeCooldown
.
value
--
if
(
codeCooldown
.
value
<=
0
)
{
clearInterval
(
timer
)
if
(
cooldownTimer
.
value
)
{
clearInterval
(
cooldownTimer
.
value
)
cooldownTimer
.
value
=
null
}
}
}
,
1000
)
}
catch
(
err
:
any
)
{
...
...
@@ -397,4 +405,11 @@ const handleVerify = async () => {
onMounted
(()
=>
{
loadVerificationMethod
()
}
)
onUnmounted
(()
=>
{
if
(
cooldownTimer
.
value
)
{
clearInterval
(
cooldownTimer
.
value
)
cooldownTimer
.
value
=
null
}
}
)
<
/script
>
frontend/src/components/user/profile/__tests__/totp-timer-cleanup.spec.ts
0 → 100644
View file @
f6bff97d
import
{
beforeEach
,
afterEach
,
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
import
{
mount
}
from
'
@vue/test-utils
'
import
TotpSetupModal
from
'
@/components/user/profile/TotpSetupModal.vue
'
import
TotpDisableDialog
from
'
@/components/user/profile/TotpDisableDialog.vue
'
const
mocks
=
vi
.
hoisted
(()
=>
({
showSuccess
:
vi
.
fn
(),
showError
:
vi
.
fn
(),
getVerificationMethod
:
vi
.
fn
(),
sendVerifyCode
:
vi
.
fn
()
}))
vi
.
mock
(
'
vue-i18n
'
,
()
=>
({
useI18n
:
()
=>
({
t
:
(
key
:
string
)
=>
key
})
}))
vi
.
mock
(
'
@/stores/app
'
,
()
=>
({
useAppStore
:
()
=>
({
showSuccess
:
mocks
.
showSuccess
,
showError
:
mocks
.
showError
})
}))
vi
.
mock
(
'
@/api
'
,
()
=>
({
totpAPI
:
{
getVerificationMethod
:
mocks
.
getVerificationMethod
,
sendVerifyCode
:
mocks
.
sendVerifyCode
,
initiateSetup
:
vi
.
fn
(),
enable
:
vi
.
fn
(),
disable
:
vi
.
fn
()
}
}))
const
flushPromises
=
async
()
=>
{
await
Promise
.
resolve
()
await
Promise
.
resolve
()
}
describe
(
'
TOTP 弹窗定时器清理
'
,
()
=>
{
let
intervalSeed
=
1000
let
setIntervalSpy
:
ReturnType
<
typeof
vi
.
spyOn
>
let
clearIntervalSpy
:
ReturnType
<
typeof
vi
.
spyOn
>
beforeEach
(()
=>
{
intervalSeed
=
1000
mocks
.
showSuccess
.
mockReset
()
mocks
.
showError
.
mockReset
()
mocks
.
getVerificationMethod
.
mockReset
()
mocks
.
sendVerifyCode
.
mockReset
()
mocks
.
getVerificationMethod
.
mockResolvedValue
({
method
:
'
email
'
})
mocks
.
sendVerifyCode
.
mockResolvedValue
({
success
:
true
})
setIntervalSpy
=
vi
.
spyOn
(
window
,
'
setInterval
'
).
mockImplementation
(((
handler
:
TimerHandler
)
=>
{
void
handler
intervalSeed
+=
1
return
intervalSeed
as
unknown
as
number
})
as
typeof
window
.
setInterval
)
clearIntervalSpy
=
vi
.
spyOn
(
window
,
'
clearInterval
'
)
})
afterEach
(()
=>
{
setIntervalSpy
.
mockRestore
()
clearIntervalSpy
.
mockRestore
()
})
it
(
'
TotpSetupModal 卸载时清理倒计时定时器
'
,
async
()
=>
{
const
wrapper
=
mount
(
TotpSetupModal
)
await
flushPromises
()
const
sendButton
=
wrapper
.
findAll
(
'
button
'
)
.
find
((
button
)
=>
button
.
text
().
includes
(
'
profile.totp.sendCode
'
))
expect
(
sendButton
).
toBeTruthy
()
await
sendButton
!
.
trigger
(
'
click
'
)
await
flushPromises
()
expect
(
setIntervalSpy
).
toHaveBeenCalledTimes
(
1
)
const
timerId
=
setIntervalSpy
.
mock
.
results
[
0
]?.
value
wrapper
.
unmount
()
expect
(
clearIntervalSpy
).
toHaveBeenCalledWith
(
timerId
)
})
it
(
'
TotpDisableDialog 卸载时清理倒计时定时器
'
,
async
()
=>
{
const
wrapper
=
mount
(
TotpDisableDialog
)
await
flushPromises
()
const
sendButton
=
wrapper
.
findAll
(
'
button
'
)
.
find
((
button
)
=>
button
.
text
().
includes
(
'
profile.totp.sendCode
'
))
expect
(
sendButton
).
toBeTruthy
()
await
sendButton
!
.
trigger
(
'
click
'
)
await
flushPromises
()
expect
(
setIntervalSpy
).
toHaveBeenCalledTimes
(
1
)
const
timerId
=
setIntervalSpy
.
mock
.
results
[
0
]?.
value
wrapper
.
unmount
()
expect
(
clearIntervalSpy
).
toHaveBeenCalledWith
(
timerId
)
})
})
frontend/src/composables/__tests__/useKeyedDebouncedSearch.spec.ts
0 → 100644
View file @
f6bff97d
import
{
beforeEach
,
afterEach
,
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
import
{
useKeyedDebouncedSearch
}
from
'
@/composables/useKeyedDebouncedSearch
'
const
flushPromises
=
()
=>
Promise
.
resolve
()
describe
(
'
useKeyedDebouncedSearch
'
,
()
=>
{
beforeEach
(()
=>
{
vi
.
useFakeTimers
()
})
afterEach
(()
=>
{
vi
.
useRealTimers
()
})
it
(
'
为不同 key 独立防抖触发搜索
'
,
async
()
=>
{
const
search
=
vi
.
fn
().
mockResolvedValue
([])
const
onSuccess
=
vi
.
fn
()
const
searcher
=
useKeyedDebouncedSearch
<
string
[]
>
({
delay
:
100
,
search
,
onSuccess
})
searcher
.
trigger
(
'
a
'
,
'
foo
'
)
searcher
.
trigger
(
'
b
'
,
'
bar
'
)
expect
(
search
).
not
.
toHaveBeenCalled
()
vi
.
advanceTimersByTime
(
100
)
await
flushPromises
()
expect
(
search
).
toHaveBeenCalledTimes
(
2
)
expect
(
search
).
toHaveBeenNthCalledWith
(
1
,
'
foo
'
,
expect
.
objectContaining
({
key
:
'
a
'
,
signal
:
expect
.
any
(
AbortSignal
)
})
)
expect
(
search
).
toHaveBeenNthCalledWith
(
2
,
'
bar
'
,
expect
.
objectContaining
({
key
:
'
b
'
,
signal
:
expect
.
any
(
AbortSignal
)
})
)
expect
(
onSuccess
).
toHaveBeenCalledTimes
(
2
)
})
it
(
'
同 key 新请求会取消旧请求并忽略过期响应
'
,
async
()
=>
{
const
resolves
:
Array
<
(
value
:
string
[])
=>
void
>
=
[]
const
search
=
vi
.
fn
().
mockImplementation
(
()
=>
new
Promise
<
string
[]
>
((
resolve
)
=>
{
resolves
.
push
(
resolve
)
})
)
const
onSuccess
=
vi
.
fn
()
const
searcher
=
useKeyedDebouncedSearch
<
string
[]
>
({
delay
:
50
,
search
,
onSuccess
})
searcher
.
trigger
(
'
rule-1
'
,
'
first
'
)
vi
.
advanceTimersByTime
(
50
)
await
flushPromises
()
searcher
.
trigger
(
'
rule-1
'
,
'
second
'
)
vi
.
advanceTimersByTime
(
50
)
await
flushPromises
()
expect
(
search
).
toHaveBeenCalledTimes
(
2
)
resolves
[
1
]([
'
second
'
])
await
flushPromises
()
expect
(
onSuccess
).
toHaveBeenCalledTimes
(
1
)
expect
(
onSuccess
).
toHaveBeenLastCalledWith
(
'
rule-1
'
,
[
'
second
'
])
resolves
[
0
]([
'
first
'
])
await
flushPromises
()
expect
(
onSuccess
).
toHaveBeenCalledTimes
(
1
)
})
it
(
'
clearKey 会取消未执行任务
'
,
()
=>
{
const
search
=
vi
.
fn
().
mockResolvedValue
([])
const
onSuccess
=
vi
.
fn
()
const
searcher
=
useKeyedDebouncedSearch
<
string
[]
>
({
delay
:
100
,
search
,
onSuccess
})
searcher
.
trigger
(
'
a
'
,
'
foo
'
)
searcher
.
clearKey
(
'
a
'
)
vi
.
advanceTimersByTime
(
100
)
expect
(
search
).
not
.
toHaveBeenCalled
()
expect
(
onSuccess
).
not
.
toHaveBeenCalled
()
})
})
frontend/src/composables/useKeyedDebouncedSearch.ts
0 → 100644
View file @
f6bff97d
import
{
getCurrentInstance
,
onUnmounted
}
from
'
vue
'
export
interface
KeyedDebouncedSearchContext
{
key
:
string
signal
:
AbortSignal
}
interface
UseKeyedDebouncedSearchOptions
<
T
>
{
delay
?:
number
search
:
(
keyword
:
string
,
context
:
KeyedDebouncedSearchContext
)
=>
Promise
<
T
>
onSuccess
:
(
key
:
string
,
result
:
T
)
=>
void
onError
?:
(
key
:
string
,
error
:
unknown
)
=>
void
}
/**
* 多实例隔离的防抖搜索:每个 key 有独立的防抖、请求取消与过期响应保护。
*/
export
function
useKeyedDebouncedSearch
<
T
>
(
options
:
UseKeyedDebouncedSearchOptions
<
T
>
)
{
const
delay
=
options
.
delay
??
300
const
timers
=
new
Map
<
string
,
ReturnType
<
typeof
setTimeout
>>
()
const
controllers
=
new
Map
<
string
,
AbortController
>
()
const
versions
=
new
Map
<
string
,
number
>
()
const
clearKey
=
(
key
:
string
)
=>
{
const
timer
=
timers
.
get
(
key
)
if
(
timer
)
{
clearTimeout
(
timer
)
timers
.
delete
(
key
)
}
const
controller
=
controllers
.
get
(
key
)
if
(
controller
)
{
controller
.
abort
()
controllers
.
delete
(
key
)
}
versions
.
delete
(
key
)
}
const
clearAll
=
()
=>
{
const
allKeys
=
new
Set
<
string
>
([
...
timers
.
keys
(),
...
controllers
.
keys
(),
...
versions
.
keys
()
])
allKeys
.
forEach
((
key
)
=>
clearKey
(
key
))
}
const
trigger
=
(
key
:
string
,
keyword
:
string
)
=>
{
const
nextVersion
=
(
versions
.
get
(
key
)
??
0
)
+
1
versions
.
set
(
key
,
nextVersion
)
const
existingTimer
=
timers
.
get
(
key
)
if
(
existingTimer
)
{
clearTimeout
(
existingTimer
)
timers
.
delete
(
key
)
}
const
inFlight
=
controllers
.
get
(
key
)
if
(
inFlight
)
{
inFlight
.
abort
()
controllers
.
delete
(
key
)
}
const
timer
=
setTimeout
(
async
()
=>
{
timers
.
delete
(
key
)
const
controller
=
new
AbortController
()
controllers
.
set
(
key
,
controller
)
const
requestVersion
=
versions
.
get
(
key
)
try
{
const
result
=
await
options
.
search
(
keyword
,
{
key
,
signal
:
controller
.
signal
})
if
(
controller
.
signal
.
aborted
)
return
if
(
versions
.
get
(
key
)
!==
requestVersion
)
return
options
.
onSuccess
(
key
,
result
)
}
catch
(
error
)
{
if
(
controller
.
signal
.
aborted
)
return
if
(
versions
.
get
(
key
)
!==
requestVersion
)
return
options
.
onError
?.(
key
,
error
)
}
finally
{
if
(
controllers
.
get
(
key
)
===
controller
)
{
controllers
.
delete
(
key
)
}
}
},
delay
)
timers
.
set
(
key
,
timer
)
}
if
(
getCurrentInstance
())
{
onUnmounted
(()
=>
{
clearAll
()
})
}
return
{
trigger
,
clearKey
,
clearAll
}
}
frontend/src/i18n/index.ts
View file @
f6bff97d
import
{
createI18n
}
from
'
vue-i18n
'
import
en
from
'
./locales/en
'
import
zh
from
'
./locales/zh
'
type
LocaleCode
=
'
en
'
|
'
zh
'
type
LocaleMessages
=
Record
<
string
,
any
>
const
LOCALE_KEY
=
'
sub2api_locale
'
const
DEFAULT_LOCALE
:
LocaleCode
=
'
en
'
const
localeLoaders
:
Record
<
LocaleCode
,
()
=>
Promise
<
{
default
:
LocaleMessages
}
>>
=
{
en
:
()
=>
import
(
'
./locales/en
'
),
zh
:
()
=>
import
(
'
./locales/zh
'
)
}
function
getDefaultLocale
():
string
{
// Check localStorage first
function
isLocaleCode
(
value
:
string
):
value
is
LocaleCode
{
return
value
===
'
en
'
||
value
===
'
zh
'
}
function
getDefaultLocale
():
LocaleCode
{
const
saved
=
localStorage
.
getItem
(
LOCALE_KEY
)
if
(
saved
&&
[
'
en
'
,
'
zh
'
].
inclu
de
s
(
saved
))
{
if
(
saved
&&
isLocaleCo
de
(
saved
))
{
return
saved
}
// Check browser language
const
browserLang
=
navigator
.
language
.
toLowerCase
()
if
(
browserLang
.
startsWith
(
'
zh
'
))
{
return
'
zh
'
}
return
'
en
'
return
DEFAULT_LOCALE
}
export
const
i18n
=
createI18n
({
legacy
:
false
,
locale
:
getDefaultLocale
(),
fallbackLocale
:
'
en
'
,
messages
:
{
en
,
zh
},
fallbackLocale
:
DEFAULT_LOCALE
,
messages
:
{},
// 禁用 HTML 消息警告 - 引导步骤使用富文本内容(driver.js 支持 HTML)
// 这些内容是内部定义的,不存在 XSS 风险
warnHtmlMessage
:
false
})
export
function
setLocale
(
locale
:
string
)
{
if
([
'
en
'
,
'
zh
'
].
includes
(
locale
))
{
i18n
.
global
.
locale
.
value
=
locale
as
'
en
'
|
'
zh
'
localStorage
.
setItem
(
LOCALE_KEY
,
locale
)
document
.
documentElement
.
setAttribute
(
'
lang
'
,
locale
)
const
loadedLocales
=
new
Set
<
LocaleCode
>
()
export
async
function
loadLocaleMessages
(
locale
:
LocaleCode
):
Promise
<
void
>
{
if
(
loadedLocales
.
has
(
locale
)
)
{
return
}
const
loader
=
localeLoaders
[
locale
]
const
module
=
await
loader
()
i18n
.
global
.
setLocaleMessage
(
locale
,
module
.
default
)
loadedLocales
.
add
(
locale
)
}
export
async
function
initI18n
():
Promise
<
void
>
{
const
current
=
getLocale
()
await
loadLocaleMessages
(
current
)
document
.
documentElement
.
setAttribute
(
'
lang
'
,
current
)
}
export
async
function
setLocale
(
locale
:
string
):
Promise
<
void
>
{
if
(
!
isLocaleCode
(
locale
))
{
return
}
await
loadLocaleMessages
(
locale
)
i18n
.
global
.
locale
.
value
=
locale
localStorage
.
setItem
(
LOCALE_KEY
,
locale
)
document
.
documentElement
.
setAttribute
(
'
lang
'
,
locale
)
}
export
function
getLocale
():
string
{
return
i18n
.
global
.
locale
.
value
export
function
getLocale
():
LocaleCode
{
const
current
=
i18n
.
global
.
locale
.
value
return
isLocaleCode
(
current
)
?
current
:
DEFAULT_LOCALE
}
export
const
availableLocales
=
[
{
code
:
'
en
'
,
name
:
'
English
'
,
flag
:
'
🇺🇸
'
},
{
code
:
'
zh
'
,
name
:
'
中文
'
,
flag
:
'
🇨🇳
'
}
]
]
as
const
export
default
i18n
frontend/src/main.ts
View file @
f6bff97d
...
...
@@ -2,28 +2,33 @@ import { createApp } from 'vue'
import
{
createPinia
}
from
'
pinia
'
import
App
from
'
./App.vue
'
import
router
from
'
./router
'
import
i18n
from
'
./i18n
'
import
i18n
,
{
initI18n
}
from
'
./i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
'
./style.css
'
const
app
=
createApp
(
App
)
const
pinia
=
createPinia
()
app
.
use
(
pinia
)
async
function
bootstrap
()
{
const
app
=
createApp
(
App
)
const
pinia
=
createPinia
()
app
.
use
(
pinia
)
// Initialize settings from injected config BEFORE mounting (prevents flash)
// This must happen after pinia is installed but before router and i18n
import
{
useAppStore
}
from
'
@/stores/app
'
const
appStore
=
useAppStore
()
appStore
.
initFromInjectedConfig
()
// Initialize settings from injected config BEFORE mounting (prevents flash)
// This must happen after pinia is installed but before router and i18n
const
appStore
=
useAppStore
()
appStore
.
initFromInjectedConfig
()
// Set document title immediately after config is loaded
if
(
appStore
.
siteName
&&
appStore
.
siteName
!==
'
Sub2API
'
)
{
document
.
title
=
`
${
appStore
.
siteName
}
- AI API Gateway`
}
// Set document title immediately after config is loaded
if
(
appStore
.
siteName
&&
appStore
.
siteName
!==
'
Sub2API
'
)
{
document
.
title
=
`
${
appStore
.
siteName
}
- AI API Gateway`
}
app
.
use
(
router
)
app
.
use
(
i18n
)
await
initI18n
()
// 等待路由器完成初始导航后再挂载,避免竞态条件导致的空白渲染
router
.
isReady
().
then
(()
=>
{
app
.
use
(
router
)
app
.
use
(
i18n
)
// 等待路由器完成初始导航后再挂载,避免竞态条件导致的空白渲染
await
router
.
isReady
()
app
.
mount
(
'
#app
'
)
})
}
bootstrap
()
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