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
2fe8932c
Unverified
Commit
2fe8932c
authored
Feb 03, 2026
by
Call White
Committed by
GitHub
Feb 03, 2026
Browse files
Merge pull request #3 from cyhhao/main
merge to main
parents
2f2e76f9
adb77af1
Changes
267
Hide whitespace changes
Inline
Side-by-side
frontend/src/components/account/EditAccountModal.vue
View file @
2fe8932c
...
...
@@ -566,7 +566,7 @@
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.billingRateMultiplier
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
form.rate_multiplier
"
type
=
"
number
"
min
=
"
0
"
step
=
"
0.01
"
class
=
"
input
"
/>
<
input
v
-
model
.
number
=
"
form.rate_multiplier
"
type
=
"
number
"
min
=
"
0
"
step
=
"
0.
0
01
"
class
=
"
input
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.billingRateMultiplierHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
...
...
@@ -732,6 +732,60 @@
<
/div
>
<
/div
>
<
/div
>
<!--
TLS
Fingerprint
-->
<
div
class
=
"
rounded-lg border border-gray-200 p-4 dark:border-dark-600
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.quotaControl.tlsFingerprint.label
'
)
}}
<
/label
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.quotaControl.tlsFingerprint.hint
'
)
}}
<
/p
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
tlsFingerprintEnabled = !tlsFingerprintEnabled
"
:
class
=
"
[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
tlsFingerprintEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]
"
>
<
span
:
class
=
"
[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
tlsFingerprintEnabled ? 'translate-x-5' : 'translate-x-0'
]
"
/>
<
/button
>
<
/div
>
<
/div
>
<!--
Session
ID
Masking
-->
<
div
class
=
"
rounded-lg border border-gray-200 p-4 dark:border-dark-600
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionIdMasking.label
'
)
}}
<
/label
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionIdMasking.hint
'
)
}}
<
/p
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
sessionIdMaskingEnabled = !sessionIdMaskingEnabled
"
:
class
=
"
[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
sessionIdMaskingEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]
"
>
<
span
:
class
=
"
[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
sessionIdMaskingEnabled ? 'translate-x-5' : 'translate-x-0'
]
"
/>
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<
div
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600
"
>
...
...
@@ -829,7 +883,7 @@ import { useI18n } from 'vue-i18n'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Account
,
Proxy
,
Group
}
from
'
@/types
'
import
type
{
Account
,
Proxy
,
Admin
Group
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
...
...
@@ -847,7 +901,7 @@ interface Props {
show
:
boolean
account
:
Account
|
null
proxies
:
Proxy
[]
groups
:
Group
[]
groups
:
Admin
Group
[]
}
const
props
=
defineProps
<
Props
>
()
...
...
@@ -904,6 +958,8 @@ const windowCostStickyReserve = ref<number | null>(null)
const
sessionLimitEnabled
=
ref
(
false
)
const
maxSessions
=
ref
<
number
|
null
>
(
null
)
const
sessionIdleTimeout
=
ref
<
number
|
null
>
(
null
)
const
tlsFingerprintEnabled
=
ref
(
false
)
const
sessionIdMaskingEnabled
=
ref
(
false
)
// Computed: current preset mappings based on platform
const
presetMappings
=
computed
(()
=>
getPresetMappingsByPlatform
(
props
.
account
?.
platform
||
'
anthropic
'
))
...
...
@@ -1237,6 +1293,8 @@ function loadQuotaControlSettings(account: Account) {
sessionLimitEnabled
.
value
=
false
maxSessions
.
value
=
null
sessionIdleTimeout
.
value
=
null
tlsFingerprintEnabled
.
value
=
false
sessionIdMaskingEnabled
.
value
=
false
// Only applies to Anthropic OAuth/SetupToken accounts
if
(
account
.
platform
!==
'
anthropic
'
||
(
account
.
type
!==
'
oauth
'
&&
account
.
type
!==
'
setup-token
'
))
{
...
...
@@ -1255,6 +1313,16 @@ function loadQuotaControlSettings(account: Account) {
maxSessions
.
value
=
account
.
max_sessions
sessionIdleTimeout
.
value
=
account
.
session_idle_timeout_minutes
??
5
}
// Load TLS fingerprint setting
if
(
account
.
enable_tls_fingerprint
===
true
)
{
tlsFingerprintEnabled
.
value
=
true
}
// Load session ID masking setting
if
(
account
.
session_id_masking_enabled
===
true
)
{
sessionIdMaskingEnabled
.
value
=
true
}
}
function
formatTempUnschedKeywords
(
value
:
unknown
)
{
...
...
@@ -1407,6 +1475,20 @@ const handleSubmit = async () => {
delete
newExtra
.
session_idle_timeout_minutes
}
// TLS fingerprint setting
if
(
tlsFingerprintEnabled
.
value
)
{
newExtra
.
enable_tls_fingerprint
=
true
}
else
{
delete
newExtra
.
enable_tls_fingerprint
}
// Session ID masking setting
if
(
sessionIdMaskingEnabled
.
value
)
{
newExtra
.
session_id_masking_enabled
=
true
}
else
{
delete
newExtra
.
session_id_masking_enabled
}
updatePayload
.
extra
=
newExtra
}
...
...
frontend/src/components/admin/account/AccountActionMenu.vue
View file @
2fe8932c
<
template
>
<Teleport
to=
"body"
>
<div
v-if=
"show && position"
class=
"action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800"
:style=
"
{ top: position.top + 'px', left: position.left + 'px' }">
<div
class=
"py-1"
>
<template
v-if=
"account"
>
<button
@
click=
"$emit('test', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
>
<Icon
name=
"play"
size=
"sm"
class=
"text-green-500"
:stroke-width=
"2"
/>
{{
t
(
'
admin.accounts.testConnection
'
)
}}
</button>
<button
@
click=
"$emit('stats', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
>
<Icon
name=
"chart"
size=
"sm"
class=
"text-indigo-500"
/>
{{
t
(
'
admin.accounts.viewStats
'
)
}}
</button>
<template
v-if=
"account.type === 'oauth' || account.type === 'setup-token'"
>
<button
@
click=
"$emit('reauth', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:hover:bg-dark-700"
>
<Icon
name=
"link"
size=
"sm"
/>
{{
t
(
'
admin.accounts.reAuthorize
'
)
}}
<div
v-if=
"show && position"
>
<!-- Backdrop: click anywhere outside to close -->
<div
class=
"fixed inset-0 z-[9998]"
@
click=
"emit('close')"
></div>
<div
class=
"action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800"
:style=
"
{ top: position.top + 'px', left: position.left + 'px' }"
@click.stop
>
<div
class=
"py-1"
>
<template
v-if=
"account"
>
<button
@
click=
"$emit('test', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
>
<Icon
name=
"play"
size=
"sm"
class=
"text-green-500"
:stroke-width=
"2"
/>
{{
t
(
'
admin.accounts.testConnection
'
)
}}
</button>
<button
@
click=
"$emit('refresh-token', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-purple-600 hover:bg-gray-100 dark:hover:bg-dark-700"
>
<Icon
name=
"refresh"
size=
"sm"
/>
{{
t
(
'
admin.accounts.refreshToken
'
)
}}
<button
@
click=
"$emit('stats', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
>
<Icon
name=
"chart"
size=
"sm"
class=
"text-indigo-500"
/>
{{
t
(
'
admin.accounts.viewStats
'
)
}}
</button>
<template
v-if=
"account.type === 'oauth' || account.type === 'setup-token'"
>
<button
@
click=
"$emit('reauth', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:hover:bg-dark-700"
>
<Icon
name=
"link"
size=
"sm"
/>
{{
t
(
'
admin.accounts.reAuthorize
'
)
}}
</button>
<button
@
click=
"$emit('refresh-token', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-purple-600 hover:bg-gray-100 dark:hover:bg-dark-700"
>
<Icon
name=
"refresh"
size=
"sm"
/>
{{
t
(
'
admin.accounts.refreshToken
'
)
}}
</button>
</
template
>
<div
v-if=
"account.status === 'error' || isRateLimited || isOverloaded"
class=
"my-1 border-t border-gray-100 dark:border-dark-700"
></div>
<button
v-if=
"account.status === 'error'"
@
click=
"$emit('reset-status', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:hover:bg-dark-700"
>
<Icon
name=
"sync"
size=
"sm"
/>
{{ t('admin.accounts.resetStatus') }}
</button>
<button
v-if=
"isRateLimited || isOverloaded"
@
click=
"$emit('clear-rate-limit', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:hover:bg-dark-700"
>
<Icon
name=
"clock"
size=
"sm"
/>
{{ t('admin.accounts.clearRateLimit') }}
</button>
</template>
<div
v-if=
"account.status === 'error' || isRateLimited || isOverloaded"
class=
"my-1 border-t border-gray-100 dark:border-dark-700"
></div>
<button
v-if=
"account.status === 'error'"
@
click=
"$emit('reset-status', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:hover:bg-dark-700"
>
<Icon
name=
"sync"
size=
"sm"
/>
{{ t('admin.accounts.resetStatus') }}
</button>
<button
v-if=
"isRateLimited || isOverloaded"
@
click=
"$emit('clear-rate-limit', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:hover:bg-dark-700"
>
<Icon
name=
"clock"
size=
"sm"
/>
{{ t('admin.accounts.clearRateLimit') }}
</button>
</template>
</div>
</div>
</div>
</Teleport>
</template>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
computed
,
watch
,
onUnmounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
Icon
}
from
'
@/components/icons
'
import
type
{
Account
}
from
'
@/types
'
const
props
=
defineProps
<
{
show
:
boolean
;
account
:
Account
|
null
;
position
:
{
top
:
number
;
left
:
number
}
|
null
}
>
()
defineEmits
([
'
close
'
,
'
test
'
,
'
stats
'
,
'
reauth
'
,
'
refresh-token
'
,
'
reset-status
'
,
'
clear-rate-limit
'
])
const
emit
=
defineEmits
([
'
close
'
,
'
test
'
,
'
stats
'
,
'
reauth
'
,
'
refresh-token
'
,
'
reset-status
'
,
'
clear-rate-limit
'
])
const
{
t
}
=
useI18n
()
const
isRateLimited
=
computed
(()
=>
props
.
account
?.
rate_limit_reset_at
&&
new
Date
(
props
.
account
.
rate_limit_reset_at
)
>
new
Date
())
const
isOverloaded
=
computed
(()
=>
props
.
account
?.
overload_until
&&
new
Date
(
props
.
account
.
overload_until
)
>
new
Date
())
const
handleKeydown
=
(
event
:
KeyboardEvent
)
=>
{
if
(
event
.
key
===
'
Escape
'
)
emit
(
'
close
'
)
}
watch
(
()
=>
props
.
show
,
(
visible
)
=>
{
if
(
visible
)
{
window
.
addEventListener
(
'
keydown
'
,
handleKeydown
)
}
else
{
window
.
removeEventListener
(
'
keydown
'
,
handleKeydown
)
}
},
{
immediate
:
true
}
)
onUnmounted
(()
=>
{
window
.
removeEventListener
(
'
keydown
'
,
handleKeydown
)
})
</
script
>
frontend/src/components/admin/account/AccountTableActions.vue
View file @
2fe8932c
<
template
>
<div
class=
"flex flex-wrap items-center gap-3"
>
<slot
name=
"before"
></slot>
<button
@
click=
"$emit('refresh')"
:disabled=
"loading"
class=
"btn btn-secondary"
>
<Icon
name=
"refresh"
size=
"md"
:class=
"[loading ? 'animate-spin' : '']"
/>
</button>
<slot
name=
"after"
></slot>
<button
@
click=
"$emit('sync')"
class=
"btn btn-secondary"
>
{{
t
(
'
admin.accounts.syncFromCrs
'
)
}}
</button>
<button
@
click=
"$emit('create')"
class=
"btn btn-primary"
>
{{
t
(
'
admin.accounts.createAccount
'
)
}}
</button>
</div>
...
...
frontend/src/components/admin/account/AccountTestModal.vue
View file @
2fe8932c
...
...
@@ -232,8 +232,11 @@ const loadAvailableModels = async () => {
if
(
availableModels
.
value
.
length
>
0
)
{
if
(
props
.
account
.
platform
===
'
gemini
'
)
{
const
preferred
=
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-2.0-flash
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-2.5-flash
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-2.5-pro
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-3-pro
'
)
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-3-flash-preview
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-3-pro-preview
'
)
selectedModelId
.
value
=
preferred
?.
id
||
availableModels
.
value
[
0
].
id
}
else
{
// Try to select Sonnet as default, otherwise use first model
...
...
frontend/src/components/admin/usage/UsageCleanupDialog.vue
0 → 100644
View file @
2fe8932c
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.usage.cleanup.title')"
width=
"wide"
@
close=
"handleClose"
>
<div
class=
"space-y-4"
>
<UsageFilters
v-model=
"localFilters"
v-model:startDate=
"localStartDate"
v-model:endDate=
"localEndDate"
:exporting=
"false"
:show-actions=
"false"
@
change=
"noop"
/>
<div
class=
"rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200"
>
{{
t
(
'
admin.usage.cleanup.warning
'
)
}}
</div>
<div
class=
"rounded-xl border border-gray-200 p-4 dark:border-dark-700"
>
<div
class=
"flex items-center justify-between"
>
<h4
class=
"text-sm font-semibold text-gray-700 dark:text-gray-200"
>
{{
t
(
'
admin.usage.cleanup.recentTasks
'
)
}}
</h4>
<button
type=
"button"
class=
"btn btn-ghost btn-sm"
@
click=
"loadTasks"
>
{{
t
(
'
common.refresh
'
)
}}
</button>
</div>
<div
class=
"mt-3 space-y-2"
>
<div
v-if=
"tasksLoading"
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.usage.cleanup.loadingTasks
'
)
}}
</div>
<div
v-else-if=
"tasks.length === 0"
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.usage.cleanup.noTasks
'
)
}}
</div>
<div
v-else
class=
"space-y-2"
>
<div
v-for=
"task in tasks"
:key=
"task.id"
class=
"flex flex-col gap-2 rounded-lg border border-gray-100 px-3 py-2 text-sm text-gray-600 dark:border-dark-700 dark:text-gray-300"
>
<div
class=
"flex flex-wrap items-center justify-between gap-2"
>
<div
class=
"flex items-center gap-2"
>
<span
:class=
"statusClass(task.status)"
class=
"rounded-full px-2 py-0.5 text-xs font-semibold"
>
{{
statusLabel
(
task
.
status
)
}}
</span>
<span
class=
"text-xs text-gray-400"
>
#
{{
task
.
id
}}
</span>
<button
v-if=
"canCancel(task)"
type=
"button"
class=
"btn btn-ghost btn-xs text-rose-600 hover:text-rose-700 dark:text-rose-300"
@
click=
"openCancelConfirm(task)"
>
{{
t
(
'
admin.usage.cleanup.cancel
'
)
}}
</button>
</div>
<div
class=
"text-xs text-gray-400"
>
{{
formatDateTime
(
task
.
created_at
)
}}
</div>
</div>
<div
class=
"flex flex-wrap items-center gap-4 text-xs text-gray-500 dark:text-gray-400"
>
<span>
{{
t
(
'
admin.usage.cleanup.range
'
)
}}
:
{{
formatRange
(
task
)
}}
</span>
<span>
{{
t
(
'
admin.usage.cleanup.deletedRows
'
)
}}
:
{{
task
.
deleted_rows
.
toLocaleString
()
}}
</span>
</div>
<div
v-if=
"task.error_message"
class=
"text-xs text-rose-500"
>
{{
task
.
error_message
}}
</div>
</div>
</div>
</div>
<Pagination
v-if=
"tasksTotal > tasksPageSize"
class=
"mt-4"
:total=
"tasksTotal"
:page=
"tasksPage"
:page-size=
"tasksPageSize"
:page-size-options=
"[5]"
:show-page-size-selector=
"false"
:show-jump=
"true"
@
update:page=
"handleTaskPageChange"
@
update:pageSize=
"handleTaskPageSizeChange"
/>
</div>
</div>
<template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
type=
"button"
class=
"btn btn-secondary"
@
click=
"handleClose"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"button"
class=
"btn btn-danger"
:disabled=
"submitting"
@
click=
"openConfirm"
>
{{
submitting
?
t
(
'
admin.usage.cleanup.submitting
'
)
:
t
(
'
admin.usage.cleanup.submit
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
<ConfirmDialog
:show=
"confirmVisible"
:title=
"t('admin.usage.cleanup.confirmTitle')"
:message=
"t('admin.usage.cleanup.confirmMessage')"
:confirm-text=
"t('admin.usage.cleanup.confirmSubmit')"
danger
@
confirm=
"submitCleanup"
@
cancel=
"confirmVisible = false"
/>
<ConfirmDialog
:show=
"cancelConfirmVisible"
:title=
"t('admin.usage.cleanup.cancelConfirmTitle')"
:message=
"t('admin.usage.cleanup.cancelConfirmMessage')"
:confirm-text=
"t('admin.usage.cleanup.cancelConfirm')"
danger
@
confirm=
"cancelTask"
@
cancel=
"cancelConfirmVisible = false"
/>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
watch
,
onUnmounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
UsageFilters
from
'
@/components/admin/usage/UsageFilters.vue
'
import
{
adminUsageAPI
}
from
'
@/api/admin/usage
'
import
type
{
AdminUsageQueryParams
,
UsageCleanupTask
,
CreateUsageCleanupTaskRequest
}
from
'
@/api/admin/usage
'
interface
Props
{
show
:
boolean
filters
:
AdminUsageQueryParams
startDate
:
string
endDate
:
string
}
const
props
=
defineProps
<
Props
>
()
const
emit
=
defineEmits
([
'
close
'
])
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
localFilters
=
ref
<
AdminUsageQueryParams
>
({})
const
localStartDate
=
ref
(
''
)
const
localEndDate
=
ref
(
''
)
const
tasks
=
ref
<
UsageCleanupTask
[]
>
([])
const
tasksLoading
=
ref
(
false
)
const
tasksPage
=
ref
(
1
)
const
tasksPageSize
=
ref
(
5
)
const
tasksTotal
=
ref
(
0
)
const
submitting
=
ref
(
false
)
const
confirmVisible
=
ref
(
false
)
const
cancelConfirmVisible
=
ref
(
false
)
const
canceling
=
ref
(
false
)
const
cancelTarget
=
ref
<
UsageCleanupTask
|
null
>
(
null
)
let
pollTimer
:
number
|
null
=
null
const
noop
=
()
=>
{}
const
resetFilters
=
()
=>
{
localFilters
.
value
=
{
...
props
.
filters
}
localStartDate
.
value
=
props
.
startDate
localEndDate
.
value
=
props
.
endDate
localFilters
.
value
.
start_date
=
localStartDate
.
value
localFilters
.
value
.
end_date
=
localEndDate
.
value
tasksPage
.
value
=
1
tasksTotal
.
value
=
0
}
const
startPolling
=
()
=>
{
stopPolling
()
pollTimer
=
window
.
setInterval
(()
=>
{
loadTasks
()
},
10000
)
}
const
stopPolling
=
()
=>
{
if
(
pollTimer
!==
null
)
{
window
.
clearInterval
(
pollTimer
)
pollTimer
=
null
}
}
const
handleClose
=
()
=>
{
stopPolling
()
confirmVisible
.
value
=
false
cancelConfirmVisible
.
value
=
false
canceling
.
value
=
false
cancelTarget
.
value
=
null
submitting
.
value
=
false
emit
(
'
close
'
)
}
const
statusLabel
=
(
status
:
string
)
=>
{
const
map
:
Record
<
string
,
string
>
=
{
pending
:
t
(
'
admin.usage.cleanup.status.pending
'
),
running
:
t
(
'
admin.usage.cleanup.status.running
'
),
succeeded
:
t
(
'
admin.usage.cleanup.status.succeeded
'
),
failed
:
t
(
'
admin.usage.cleanup.status.failed
'
),
canceled
:
t
(
'
admin.usage.cleanup.status.canceled
'
)
}
return
map
[
status
]
||
status
}
const
statusClass
=
(
status
:
string
)
=>
{
const
map
:
Record
<
string
,
string
>
=
{
pending
:
'
bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200
'
,
running
:
'
bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-200
'
,
succeeded
:
'
bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200
'
,
failed
:
'
bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-200
'
,
canceled
:
'
bg-gray-200 text-gray-600 dark:bg-dark-600 dark:text-gray-300
'
}
return
map
[
status
]
||
'
bg-gray-100 text-gray-600
'
}
const
formatDateTime
=
(
value
?:
string
|
null
)
=>
{
if
(
!
value
)
return
'
--
'
const
date
=
new
Date
(
value
)
if
(
Number
.
isNaN
(
date
.
getTime
()))
return
value
return
date
.
toLocaleString
()
}
const
formatRange
=
(
task
:
UsageCleanupTask
)
=>
{
const
start
=
formatDateTime
(
task
.
filters
.
start_time
)
const
end
=
formatDateTime
(
task
.
filters
.
end_time
)
return
`
${
start
}
~
${
end
}
`
}
const
getUserTimezone
=
()
=>
{
try
{
return
Intl
.
DateTimeFormat
().
resolvedOptions
().
timeZone
}
catch
{
return
'
UTC
'
}
}
const
loadTasks
=
async
()
=>
{
if
(
!
props
.
show
)
return
tasksLoading
.
value
=
true
try
{
const
res
=
await
adminUsageAPI
.
listCleanupTasks
({
page
:
tasksPage
.
value
,
page_size
:
tasksPageSize
.
value
})
tasks
.
value
=
res
.
items
||
[]
tasksTotal
.
value
=
res
.
total
||
0
if
(
res
.
page
)
{
tasksPage
.
value
=
res
.
page
}
if
(
res
.
page_size
)
{
tasksPageSize
.
value
=
res
.
page_size
}
}
catch
(
error
)
{
console
.
error
(
'
Failed to load cleanup tasks:
'
,
error
)
appStore
.
showError
(
t
(
'
admin.usage.cleanup.loadFailed
'
))
}
finally
{
tasksLoading
.
value
=
false
}
}
const
handleTaskPageChange
=
(
page
:
number
)
=>
{
tasksPage
.
value
=
page
loadTasks
()
}
const
handleTaskPageSizeChange
=
(
size
:
number
)
=>
{
if
(
!
Number
.
isFinite
(
size
)
||
size
<=
0
)
return
tasksPageSize
.
value
=
size
tasksPage
.
value
=
1
loadTasks
()
}
const
openConfirm
=
()
=>
{
confirmVisible
.
value
=
true
}
const
canCancel
=
(
task
:
UsageCleanupTask
)
=>
{
return
task
.
status
===
'
pending
'
||
task
.
status
===
'
running
'
}
const
openCancelConfirm
=
(
task
:
UsageCleanupTask
)
=>
{
cancelTarget
.
value
=
task
cancelConfirmVisible
.
value
=
true
}
const
buildPayload
=
():
CreateUsageCleanupTaskRequest
|
null
=>
{
if
(
!
localStartDate
.
value
||
!
localEndDate
.
value
)
{
appStore
.
showError
(
t
(
'
admin.usage.cleanup.missingRange
'
))
return
null
}
const
payload
:
CreateUsageCleanupTaskRequest
=
{
start_date
:
localStartDate
.
value
,
end_date
:
localEndDate
.
value
,
timezone
:
getUserTimezone
()
}
if
(
localFilters
.
value
.
user_id
&&
localFilters
.
value
.
user_id
>
0
)
{
payload
.
user_id
=
localFilters
.
value
.
user_id
}
if
(
localFilters
.
value
.
api_key_id
&&
localFilters
.
value
.
api_key_id
>
0
)
{
payload
.
api_key_id
=
localFilters
.
value
.
api_key_id
}
if
(
localFilters
.
value
.
account_id
&&
localFilters
.
value
.
account_id
>
0
)
{
payload
.
account_id
=
localFilters
.
value
.
account_id
}
if
(
localFilters
.
value
.
group_id
&&
localFilters
.
value
.
group_id
>
0
)
{
payload
.
group_id
=
localFilters
.
value
.
group_id
}
if
(
localFilters
.
value
.
model
)
{
payload
.
model
=
localFilters
.
value
.
model
}
if
(
localFilters
.
value
.
stream
!==
null
&&
localFilters
.
value
.
stream
!==
undefined
)
{
payload
.
stream
=
localFilters
.
value
.
stream
}
if
(
localFilters
.
value
.
billing_type
!==
null
&&
localFilters
.
value
.
billing_type
!==
undefined
)
{
payload
.
billing_type
=
localFilters
.
value
.
billing_type
}
return
payload
}
const
submitCleanup
=
async
()
=>
{
const
payload
=
buildPayload
()
if
(
!
payload
)
{
confirmVisible
.
value
=
false
return
}
submitting
.
value
=
true
confirmVisible
.
value
=
false
try
{
await
adminUsageAPI
.
createCleanupTask
(
payload
)
appStore
.
showSuccess
(
t
(
'
admin.usage.cleanup.submitSuccess
'
))
loadTasks
()
}
catch
(
error
)
{
console
.
error
(
'
Failed to create cleanup task:
'
,
error
)
appStore
.
showError
(
t
(
'
admin.usage.cleanup.submitFailed
'
))
}
finally
{
submitting
.
value
=
false
}
}
const
cancelTask
=
async
()
=>
{
const
task
=
cancelTarget
.
value
if
(
!
task
)
{
cancelConfirmVisible
.
value
=
false
return
}
canceling
.
value
=
true
cancelConfirmVisible
.
value
=
false
try
{
await
adminUsageAPI
.
cancelCleanupTask
(
task
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.usage.cleanup.cancelSuccess
'
))
loadTasks
()
}
catch
(
error
)
{
console
.
error
(
'
Failed to cancel cleanup task:
'
,
error
)
appStore
.
showError
(
t
(
'
admin.usage.cleanup.cancelFailed
'
))
}
finally
{
canceling
.
value
=
false
cancelTarget
.
value
=
null
}
}
watch
(
()
=>
props
.
show
,
(
show
)
=>
{
if
(
show
)
{
resetFilters
()
loadTasks
()
startPolling
()
}
else
{
stopPolling
()
}
}
)
onUnmounted
(()
=>
{
stopPolling
()
})
</
script
>
frontend/src/components/admin/usage/UsageFilters.vue
View file @
2fe8932c
...
...
@@ -127,6 +127,12 @@
<
Select
v
-
model
=
"
filters.stream
"
:
options
=
"
streamTypeOptions
"
@
change
=
"
emitChange
"
/>
<
/div
>
<!--
Billing
Type
Filter
-->
<
div
class
=
"
w-full sm:w-auto sm:min-w-[200px]
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.usage.billingType
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
filters.billing_type
"
:
options
=
"
billingTypeOptions
"
@
change
=
"
emitChange
"
/>
<
/div
>
<!--
Group
Filter
-->
<
div
class
=
"
w-full sm:w-auto sm:min-w-[200px]
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.usage.group
'
)
}}
<
/label
>
...
...
@@ -147,10 +153,13 @@
<
/div
>
<!--
Right
:
actions
-->
<
div
class
=
"
flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto
"
>
<
div
v
-
if
=
"
showActions
"
class
=
"
flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto
"
>
<
button
type
=
"
button
"
@
click
=
"
$emit('reset')
"
class
=
"
btn btn-secondary
"
>
{{
t
(
'
common.reset
'
)
}}
<
/button
>
<
button
type
=
"
button
"
@
click
=
"
$emit('cleanup')
"
class
=
"
btn btn-danger
"
>
{{
t
(
'
admin.usage.cleanup.button
'
)
}}
<
/button
>
<
button
type
=
"
button
"
@
click
=
"
$emit('export')
"
:
disabled
=
"
exporting
"
class
=
"
btn btn-primary
"
>
{{
t
(
'
usage.exportExcel
'
)
}}
<
/button
>
...
...
@@ -174,16 +183,20 @@ interface Props {
exporting
:
boolean
startDate
:
string
endDate
:
string
showActions
?:
boolean
}
const
props
=
defineProps
<
Props
>
()
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
showActions
:
true
}
)
const
emit
=
defineEmits
([
'
update:modelValue
'
,
'
update:startDate
'
,
'
update:endDate
'
,
'
change
'
,
'
reset
'
,
'
export
'
'
export
'
,
'
cleanup
'
])
const
{
t
}
=
useI18n
()
...
...
@@ -221,6 +234,12 @@ const streamTypeOptions = ref<SelectOption[]>([
{
value
:
false
,
label
:
t
(
'
usage.sync
'
)
}
])
const
billingTypeOptions
=
ref
<
SelectOption
[]
>
([
{
value
:
null
,
label
:
t
(
'
admin.usage.allBillingTypes
'
)
}
,
{
value
:
0
,
label
:
t
(
'
admin.usage.billingTypeBalance
'
)
}
,
{
value
:
1
,
label
:
t
(
'
admin.usage.billingTypeSubscription
'
)
}
])
const
emitChange
=
()
=>
emit
(
'
change
'
)
const
updateStartDate
=
(
value
:
string
)
=>
{
...
...
frontend/src/components/admin/usage/UsageTable.vue
View file @
2fe8932c
...
...
@@ -239,7 +239,7 @@ import { formatDateTime } from '@/utils/format'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
type
{
UsageLog
}
from
'
@/types
'
import
type
{
Admin
UsageLog
}
from
'
@/types
'
defineProps
([
'
data
'
,
'
loading
'
])
const
{
t
}
=
useI18n
()
...
...
@@ -247,12 +247,12 @@ const { t } = useI18n()
// Tooltip state - cost
const
tooltipVisible
=
ref
(
false
)
const
tooltipPosition
=
ref
({
x
:
0
,
y
:
0
})
const
tooltipData
=
ref
<
UsageLog
|
null
>
(
null
)
const
tooltipData
=
ref
<
Admin
UsageLog
|
null
>
(
null
)
// Tooltip state - token
const
tokenTooltipVisible
=
ref
(
false
)
const
tokenTooltipPosition
=
ref
({
x
:
0
,
y
:
0
})
const
tokenTooltipData
=
ref
<
UsageLog
|
null
>
(
null
)
const
tokenTooltipData
=
ref
<
Admin
UsageLog
|
null
>
(
null
)
const
cols
=
computed
(()
=>
[
{
key
:
'
user
'
,
label
:
t
(
'
admin.usage.user
'
),
sortable
:
false
},
...
...
@@ -296,7 +296,7 @@ const formatDuration = (ms: number | null | undefined): string => {
}
// Cost tooltip functions
const
showTooltip
=
(
event
:
MouseEvent
,
row
:
UsageLog
)
=>
{
const
showTooltip
=
(
event
:
MouseEvent
,
row
:
Admin
UsageLog
)
=>
{
const
target
=
event
.
currentTarget
as
HTMLElement
const
rect
=
target
.
getBoundingClientRect
()
tooltipData
.
value
=
row
...
...
@@ -311,7 +311,7 @@ const hideTooltip = () => {
}
// Token tooltip functions
const
showTokenTooltip
=
(
event
:
MouseEvent
,
row
:
UsageLog
)
=>
{
const
showTokenTooltip
=
(
event
:
MouseEvent
,
row
:
Admin
UsageLog
)
=>
{
const
target
=
event
.
currentTarget
as
HTMLElement
const
rect
=
target
.
getBoundingClientRect
()
tokenTooltipData
.
value
=
row
...
...
frontend/src/components/admin/user/UserAllowedGroupsModal.vue
View file @
2fe8932c
...
...
@@ -39,10 +39,10 @@ import { ref, watch } from 'vue'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
User
,
Group
}
from
'
@/types
'
import
type
{
Admin
User
,
Group
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
User
|
null
}
>
()
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
Admin
User
|
null
}
>
()
const
emit
=
defineEmits
([
'
close
'
,
'
success
'
]);
const
{
t
}
=
useI18n
();
const
appStore
=
useAppStore
()
const
groups
=
ref
<
Group
[]
>
([]);
const
selectedIds
=
ref
<
number
[]
>
([]);
const
loading
=
ref
(
false
);
const
submitting
=
ref
(
false
)
...
...
@@ -56,4 +56,4 @@ const handleSave = async () => {
appStore
.
showSuccess
(
t
(
'
admin.users.allowedGroupsUpdated
'
));
emit
(
'
success
'
);
emit
(
'
close
'
)
}
catch
(
error
)
{
console
.
error
(
'
Failed to update allowed groups:
'
,
error
)
}
finally
{
submitting
.
value
=
false
}
}
</
script
>
\ No newline at end of file
</
script
>
frontend/src/components/admin/user/UserApiKeysModal.vue
View file @
2fe8932c
...
...
@@ -32,10 +32,10 @@ import { ref, watch } from 'vue'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
formatDateTime
}
from
'
@/utils/format
'
import
type
{
User
,
ApiKey
}
from
'
@/types
'
import
type
{
Admin
User
,
ApiKey
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
User
|
null
}
>
()
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
Admin
User
|
null
}
>
()
defineEmits
([
'
close
'
]);
const
{
t
}
=
useI18n
()
const
apiKeys
=
ref
<
ApiKey
[]
>
([]);
const
loading
=
ref
(
false
)
...
...
@@ -44,4 +44,4 @@ const load = async () => {
if
(
!
props
.
user
)
return
;
loading
.
value
=
true
try
{
const
res
=
await
adminAPI
.
users
.
getUserApiKeys
(
props
.
user
.
id
);
apiKeys
.
value
=
res
.
items
||
[]
}
catch
(
error
)
{
console
.
error
(
'
Failed to load API keys:
'
,
error
)
}
finally
{
loading
.
value
=
false
}
}
</
script
>
\ No newline at end of file
</
script
>
frontend/src/components/admin/user/UserBalanceModal.vue
View file @
2fe8932c
...
...
@@ -29,10 +29,10 @@ import { reactive, ref, watch } from 'vue'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
User
}
from
'
@/types
'
import
type
{
Admin
User
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
User
|
null
,
operation
:
'
add
'
|
'
subtract
'
}
>
()
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
Admin
User
|
null
,
operation
:
'
add
'
|
'
subtract
'
}
>
()
const
emit
=
defineEmits
([
'
close
'
,
'
success
'
]);
const
{
t
}
=
useI18n
();
const
appStore
=
useAppStore
()
const
submitting
=
ref
(
false
);
const
form
=
reactive
({
amount
:
0
,
notes
:
''
})
...
...
frontend/src/components/admin/user/UserEditModal.vue
View file @
2fe8932c
...
...
@@ -56,12 +56,12 @@ import { useI18n } from 'vue-i18n'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
User
,
UserAttributeValuesMap
}
from
'
@/types
'
import
type
{
Admin
User
,
UserAttributeValuesMap
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
UserAttributeForm
from
'
@/components/user/UserAttributeForm.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
User
|
null
}
>
()
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
Admin
User
|
null
}
>
()
const
emit
=
defineEmits
([
'
close
'
,
'
success
'
])
const
{
t
}
=
useI18n
();
const
appStore
=
useAppStore
();
const
{
copyToClipboard
}
=
useClipboard
()
...
...
frontend/src/components/auth/TotpLoginModal.vue
0 → 100644
View file @
2fe8932c
<
template
>
<div
class=
"fixed inset-0 z-50 overflow-y-auto"
>
<div
class=
"flex min-h-full items-center justify-center p-4"
>
<div
class=
"fixed inset-0 bg-black/50 transition-opacity"
></div>
<div
class=
"relative w-full max-w-md transform rounded-xl bg-white p-6 shadow-xl transition-all dark:bg-dark-800"
>
<!-- Header -->
<div
class=
"mb-6 text-center"
>
<div
class=
"mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30"
>
<svg
class=
"h-6 w-6 text-primary-600 dark:text-primary-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
</div>
<h3
class=
"mt-4 text-xl font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
profile.totp.loginTitle
'
)
}}
</h3>
<p
class=
"mt-2 text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
profile.totp.loginHint
'
)
}}
</p>
<p
v-if=
"userEmailMasked"
class=
"mt-1 text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
userEmailMasked
}}
</p>
</div>
<!-- Code Input -->
<div
class=
"mb-6"
>
<div
class=
"flex justify-center gap-2"
>
<input
v-for=
"(_, index) in 6"
:key=
"index"
:ref=
"(el) => setInputRef(el, index)"
type=
"text"
maxlength=
"1"
inputmode=
"numeric"
pattern=
"[0-9]"
class=
"h-12 w-10 rounded-lg border border-gray-300 text-center text-lg font-semibold focus:border-primary-500 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
:disabled=
"verifying"
@
input=
"handleCodeInput($event, index)"
@
keydown=
"handleKeydown($event, index)"
@
paste=
"handlePaste"
/>
</div>
<!-- Loading indicator -->
<div
v-if=
"verifying"
class=
"mt-3 flex items-center justify-center gap-2 text-sm text-gray-500"
>
<div
class=
"animate-spin rounded-full h-4 w-4 border-b-2 border-primary-500"
></div>
{{
t
(
'
common.verifying
'
)
}}
</div>
</div>
<!-- Error -->
<div
v-if=
"error"
class=
"mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400"
>
{{
error
}}
</div>
<!-- Cancel button only -->
<button
type=
"button"
class=
"btn btn-secondary w-full"
:disabled=
"verifying"
@
click=
"$emit('cancel')"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
</div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
watch
,
nextTick
,
onMounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
defineProps
<
{
tempToken
:
string
userEmailMasked
?:
string
}
>
()
const
emit
=
defineEmits
<
{
verify
:
[
code
:
string
]
cancel
:
[]
}
>
()
const
{
t
}
=
useI18n
()
const
verifying
=
ref
(
false
)
const
error
=
ref
(
''
)
const
code
=
ref
<
string
[]
>
([
''
,
''
,
''
,
''
,
''
,
''
])
const
inputRefs
=
ref
<
(
HTMLInputElement
|
null
)[]
>
([])
// Watch for code changes and auto-submit when 6 digits are entered
watch
(
()
=>
code
.
value
.
join
(
''
),
(
newCode
)
=>
{
if
(
newCode
.
length
===
6
&&
!
verifying
.
value
)
{
emit
(
'
verify
'
,
newCode
)
}
}
)
defineExpose
({
setVerifying
:
(
value
:
boolean
)
=>
{
verifying
.
value
=
value
},
setError
:
(
message
:
string
)
=>
{
error
.
value
=
message
code
.
value
=
[
''
,
''
,
''
,
''
,
''
,
''
]
// Clear input DOM values
inputRefs
.
value
.
forEach
(
input
=>
{
if
(
input
)
input
.
value
=
''
})
nextTick
(()
=>
{
inputRefs
.
value
[
0
]?.
focus
()
})
}
})
const
setInputRef
=
(
el
:
any
,
index
:
number
)
=>
{
inputRefs
.
value
[
index
]
=
el
as
HTMLInputElement
|
null
}
const
handleCodeInput
=
(
event
:
Event
,
index
:
number
)
=>
{
const
input
=
event
.
target
as
HTMLInputElement
const
value
=
input
.
value
.
replace
(
/
[^
0-9
]
/g
,
''
)
code
.
value
[
index
]
=
value
if
(
value
&&
index
<
5
)
{
nextTick
(()
=>
{
inputRefs
.
value
[
index
+
1
]?.
focus
()
})
}
}
const
handleKeydown
=
(
event
:
KeyboardEvent
,
index
:
number
)
=>
{
if
(
event
.
key
===
'
Backspace
'
)
{
const
input
=
event
.
target
as
HTMLInputElement
// If current cell is empty and not the first, move to previous cell
if
(
!
input
.
value
&&
index
>
0
)
{
event
.
preventDefault
()
inputRefs
.
value
[
index
-
1
]?.
focus
()
}
// Otherwise, let the browser handle the backspace naturally
// The input event will sync code.value via handleCodeInput
}
}
const
handlePaste
=
(
event
:
ClipboardEvent
)
=>
{
event
.
preventDefault
()
const
pastedData
=
event
.
clipboardData
?.
getData
(
'
text
'
)
||
''
const
digits
=
pastedData
.
replace
(
/
[^
0-9
]
/g
,
''
).
slice
(
0
,
6
).
split
(
''
)
// Update both the ref and the input elements
digits
.
forEach
((
digit
,
index
)
=>
{
code
.
value
[
index
]
=
digit
if
(
inputRefs
.
value
[
index
])
{
inputRefs
.
value
[
index
]
!
.
value
=
digit
}
})
// Clear remaining inputs if pasted less than 6 digits
for
(
let
i
=
digits
.
length
;
i
<
6
;
i
++
)
{
code
.
value
[
i
]
=
''
if
(
inputRefs
.
value
[
i
])
{
inputRefs
.
value
[
i
]
!
.
value
=
''
}
}
const
focusIndex
=
Math
.
min
(
digits
.
length
,
5
)
nextTick
(()
=>
{
inputRefs
.
value
[
focusIndex
]?.
focus
()
})
}
onMounted
(()
=>
{
nextTick
(()
=>
{
inputRefs
.
value
[
0
]?.
focus
()
})
})
</
script
>
frontend/src/components/common/DataTable.vue
View file @
2fe8932c
...
...
@@ -181,6 +181,10 @@ import Icon from '@/components/icons/Icon.vue'
const
{
t
}
=
useI18n
()
const
emit
=
defineEmits
<
{
sort
:
[
key
:
string
,
order
:
'
asc
'
|
'
desc
'
]
}
>
()
// 表格容器引用
const
tableWrapperRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
isScrollable
=
ref
(
false
)
...
...
@@ -279,18 +283,149 @@ interface Props {
expandableActions
?:
boolean
actionsCount
?:
number
// 操作按钮总数,用于判断是否需要展开功能
rowKey
?:
string
|
((
row
:
any
)
=>
string
|
number
)
/**
* Default sort configuration (only applied when there is no persisted sort state)
*/
defaultSortKey
?:
string
defaultSortOrder
?:
'
asc
'
|
'
desc
'
/**
* Persist sort state (key + order) to localStorage using this key.
* If provided, DataTable will load the stored sort state on mount.
*/
sortStorageKey
?:
string
/**
* Enable server-side sorting mode. When true, clicking sort headers
* will emit 'sort' events instead of performing client-side sorting.
*/
serverSideSort
?:
boolean
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
loading
:
false
,
stickyFirstColumn
:
true
,
stickyActionsColumn
:
true
,
expandableActions
:
true
expandableActions
:
true
,
defaultSortOrder
:
'
asc
'
,
serverSideSort
:
false
})
const
sortKey
=
ref
<
string
>
(
''
)
const
sortOrder
=
ref
<
'
asc
'
|
'
desc
'
>
(
'
asc
'
)
const
actionsExpanded
=
ref
(
false
)
type
PersistedSortState
=
{
key
:
string
order
:
'
asc
'
|
'
desc
'
}
const
collator
=
new
Intl
.
Collator
(
undefined
,
{
numeric
:
true
,
sensitivity
:
'
base
'
})
const
getSortableKeys
=
()
=>
{
const
keys
=
new
Set
<
string
>
()
for
(
const
col
of
props
.
columns
)
{
if
(
col
.
sortable
)
keys
.
add
(
col
.
key
)
}
return
keys
}
const
normalizeSortKey
=
(
candidate
:
string
)
=>
{
if
(
!
candidate
)
return
''
const
sortableKeys
=
getSortableKeys
()
return
sortableKeys
.
has
(
candidate
)
?
candidate
:
''
}
const
normalizeSortOrder
=
(
candidate
:
any
):
'
asc
'
|
'
desc
'
=>
{
return
candidate
===
'
desc
'
?
'
desc
'
:
'
asc
'
}
const
readPersistedSortState
=
():
PersistedSortState
|
null
=>
{
if
(
!
props
.
sortStorageKey
)
return
null
try
{
const
raw
=
localStorage
.
getItem
(
props
.
sortStorageKey
)
if
(
!
raw
)
return
null
const
parsed
=
JSON
.
parse
(
raw
)
as
Partial
<
PersistedSortState
>
const
key
=
normalizeSortKey
(
typeof
parsed
.
key
===
'
string
'
?
parsed
.
key
:
''
)
if
(
!
key
)
return
null
return
{
key
,
order
:
normalizeSortOrder
(
parsed
.
order
)
}
}
catch
(
e
)
{
console
.
error
(
'
[DataTable] Failed to read persisted sort state:
'
,
e
)
return
null
}
}
const
writePersistedSortState
=
(
state
:
PersistedSortState
)
=>
{
if
(
!
props
.
sortStorageKey
)
return
try
{
localStorage
.
setItem
(
props
.
sortStorageKey
,
JSON
.
stringify
(
state
))
}
catch
(
e
)
{
console
.
error
(
'
[DataTable] Failed to persist sort state:
'
,
e
)
}
}
const
resolveInitialSortState
=
():
PersistedSortState
|
null
=>
{
const
persisted
=
readPersistedSortState
()
if
(
persisted
)
return
persisted
const
key
=
normalizeSortKey
(
props
.
defaultSortKey
||
''
)
if
(
!
key
)
return
null
return
{
key
,
order
:
normalizeSortOrder
(
props
.
defaultSortOrder
)
}
}
const
applySortState
=
(
state
:
PersistedSortState
|
null
)
=>
{
if
(
!
state
)
return
sortKey
.
value
=
state
.
key
sortOrder
.
value
=
state
.
order
}
const
isNullishOrEmpty
=
(
value
:
any
)
=>
value
===
null
||
value
===
undefined
||
value
===
''
const
toFiniteNumberOrNull
=
(
value
:
any
):
number
|
null
=>
{
if
(
typeof
value
===
'
number
'
)
return
Number
.
isFinite
(
value
)
?
value
:
null
if
(
typeof
value
===
'
boolean
'
)
return
value
?
1
:
0
if
(
typeof
value
===
'
string
'
)
{
const
trimmed
=
value
.
trim
()
if
(
!
trimmed
)
return
null
const
n
=
Number
(
trimmed
)
return
Number
.
isFinite
(
n
)
?
n
:
null
}
return
null
}
const
toSortableString
=
(
value
:
any
):
string
=>
{
if
(
value
===
null
||
value
===
undefined
)
return
''
if
(
typeof
value
===
'
string
'
)
return
value
if
(
typeof
value
===
'
number
'
||
typeof
value
===
'
boolean
'
)
return
String
(
value
)
if
(
value
instanceof
Date
)
return
value
.
toISOString
()
try
{
return
JSON
.
stringify
(
value
)
}
catch
{
return
String
(
value
)
}
}
const
compareSortValues
=
(
a
:
any
,
b
:
any
):
number
=>
{
const
aEmpty
=
isNullishOrEmpty
(
a
)
const
bEmpty
=
isNullishOrEmpty
(
b
)
if
(
aEmpty
&&
bEmpty
)
return
0
if
(
aEmpty
)
return
1
if
(
bEmpty
)
return
-
1
const
aNum
=
toFiniteNumberOrNull
(
a
)
const
bNum
=
toFiniteNumberOrNull
(
b
)
if
(
aNum
!==
null
&&
bNum
!==
null
)
{
if
(
aNum
===
bNum
)
return
0
return
aNum
<
bNum
?
-
1
:
1
}
const
aStr
=
toSortableString
(
a
)
const
bStr
=
toSortableString
(
b
)
const
res
=
collator
.
compare
(
aStr
,
bStr
)
if
(
res
===
0
)
return
0
return
res
<
0
?
-
1
:
1
}
const
resolveRowKey
=
(
row
:
any
,
index
:
number
)
=>
{
if
(
typeof
props
.
rowKey
===
'
function
'
)
{
const
key
=
props
.
rowKey
(
row
)
...
...
@@ -323,26 +458,39 @@ watch(actionsExpanded, async () => {
})
const
handleSort
=
(
key
:
string
)
=>
{
let
newOrder
:
'
asc
'
|
'
desc
'
=
'
asc
'
if
(
sortKey
.
value
===
key
)
{
sortOrder
.
value
=
sortOrder
.
value
===
'
asc
'
?
'
desc
'
:
'
asc
'
newOrder
=
sortOrder
.
value
===
'
asc
'
?
'
desc
'
:
'
asc
'
}
if
(
props
.
serverSideSort
)
{
// Server-side sort mode: emit event and update internal state for UI feedback
sortKey
.
value
=
key
sortOrder
.
value
=
newOrder
emit
(
'
sort
'
,
key
,
newOrder
)
}
else
{
// Client-side sort mode: just update internal state
sortKey
.
value
=
key
sortOrder
.
value
=
'
asc
'
sortOrder
.
value
=
newOrder
}
}
const
sortedData
=
computed
(()
=>
{
if
(
!
sortKey
.
value
||
!
props
.
data
)
return
props
.
data
return
[...
props
.
data
].
sort
((
a
,
b
)
=>
{
const
aVal
=
a
[
sortKey
.
value
]
const
bVal
=
b
[
sortKey
.
value
]
if
(
aVal
===
bVal
)
return
0
const
comparison
=
aVal
>
bVal
?
1
:
-
1
return
sortOrder
.
value
===
'
asc
'
?
comparison
:
-
comparison
})
// Server-side sort mode: return data as-is (server handles sorting)
if
(
props
.
serverSideSort
||
!
sortKey
.
value
||
!
props
.
data
)
return
props
.
data
const
key
=
sortKey
.
value
const
order
=
sortOrder
.
value
// Stable sort (tie-break with original index) to avoid jitter when values are equal.
return
props
.
data
.
map
((
row
,
index
)
=>
({
row
,
index
}))
.
sort
((
a
,
b
)
=>
{
const
cmp
=
compareSortValues
(
a
.
row
?.[
key
],
b
.
row
?.[
key
])
if
(
cmp
!==
0
)
return
order
===
'
asc
'
?
cmp
:
-
cmp
return
a
.
index
-
b
.
index
})
.
map
(
item
=>
item
.
row
)
})
const
hasActionsColumn
=
computed
(()
=>
{
...
...
@@ -396,6 +544,51 @@ const getAdaptivePaddingClass = () => {
return
'
px-6
'
// 24px (原始值)
}
}
// Init + keep persisted sort state consistent with current columns
const
didInitSort
=
ref
(
false
)
onMounted
(()
=>
{
const
initial
=
resolveInitialSortState
()
applySortState
(
initial
)
didInitSort
.
value
=
true
})
watch
(
()
=>
props
.
columns
,
()
=>
{
// If current sort key is no longer sortable/visible, fall back to default/persisted.
const
normalized
=
normalizeSortKey
(
sortKey
.
value
)
if
(
!
sortKey
.
value
)
{
const
initial
=
resolveInitialSortState
()
applySortState
(
initial
)
return
}
if
(
!
normalized
)
{
const
fallback
=
resolveInitialSortState
()
if
(
fallback
)
{
applySortState
(
fallback
)
}
else
{
sortKey
.
value
=
''
sortOrder
.
value
=
'
asc
'
}
}
},
{
deep
:
true
}
)
watch
(
[
sortKey
,
sortOrder
],
([
nextKey
,
nextOrder
])
=>
{
if
(
!
didInitSort
.
value
)
return
if
(
!
props
.
sortStorageKey
)
return
const
key
=
normalizeSortKey
(
nextKey
)
if
(
!
key
)
return
writePersistedSortState
({
key
,
order
:
normalizeSortOrder
(
nextOrder
)
})
},
{
flush
:
'
post
'
}
)
</
script
>
<
style
scoped
>
...
...
frontend/src/components/common/GroupSelector.vue
View file @
2fe8932c
...
...
@@ -42,13 +42,13 @@
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
GroupBadge
from
'
./GroupBadge.vue
'
import
type
{
Group
,
GroupPlatform
}
from
'
@/types
'
import
type
{
Admin
Group
,
GroupPlatform
}
from
'
@/types
'
const
{
t
}
=
useI18n
()
interface
Props
{
modelValue
:
number
[]
groups
:
Group
[]
groups
:
Admin
Group
[]
platform
?:
GroupPlatform
// Optional platform filter
mixedScheduling
?:
boolean
// For antigravity accounts: allow anthropic/gemini groups
}
...
...
frontend/src/components/common/Pagination.vue
View file @
2fe8932c
...
...
@@ -37,7 +37,7 @@
<
/p
>
<!--
Page
size
selector
-->
<
div
class
=
"
flex items-center space-x-2
"
>
<
div
v
-
if
=
"
showPageSizeSelector
"
class
=
"
flex items-center space-x-2
"
>
<
span
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
pagination.perPage
'
)
}}
:
<
/spa
n
>
...
...
@@ -49,6 +49,22 @@
/>
<
/div
>
<
/div
>
<
div
v
-
if
=
"
showJump
"
class
=
"
flex items-center space-x-2
"
>
<
span
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
pagination.jumpTo
'
)
}}
<
/span
>
<
input
v
-
model
=
"
jumpPage
"
type
=
"
number
"
min
=
"
1
"
:
max
=
"
totalPages
"
class
=
"
input w-20 text-sm
"
:
placeholder
=
"
t('pagination.jumpPlaceholder')
"
@
keyup
.
enter
=
"
submitJump
"
/>
<
button
type
=
"
button
"
class
=
"
btn btn-ghost btn-sm
"
@
click
=
"
submitJump
"
>
{{
t
(
'
pagination.jumpAction
'
)
}}
<
/button
>
<
/div
>
<
/div
>
<!--
Desktop
pagination
buttons
-->
...
...
@@ -102,7 +118,7 @@
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
computed
}
from
'
vue
'
import
{
computed
,
ref
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
Select
from
'
./Select.vue
'
...
...
@@ -114,6 +130,8 @@ interface Props {
page
:
number
pageSize
:
number
pageSizeOptions
?:
number
[]
showPageSizeSelector
?:
boolean
showJump
?:
boolean
}
interface
Emits
{
...
...
@@ -122,7 +140,9 @@ interface Emits {
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
pageSizeOptions
:
()
=>
[
10
,
20
,
50
,
100
]
pageSizeOptions
:
()
=>
[
10
,
20
,
50
,
100
],
showPageSizeSelector
:
true
,
showJump
:
false
}
)
const
emit
=
defineEmits
<
Emits
>
()
...
...
@@ -146,6 +166,8 @@ const pageSizeSelectOptions = computed(() => {
}
))
}
)
const
jumpPage
=
ref
(
''
)
const
visiblePages
=
computed
(()
=>
{
const
pages
:
(
number
|
string
)[]
=
[]
const
maxVisible
=
7
...
...
@@ -196,6 +218,16 @@ const handlePageSizeChange = (value: string | number | boolean | null) => {
const
newPageSize
=
typeof
value
===
'
string
'
?
parseInt
(
value
)
:
value
emit
(
'
update:pageSize
'
,
newPageSize
)
}
const
submitJump
=
()
=>
{
const
value
=
jumpPage
.
value
.
trim
()
if
(
!
value
)
return
const
pageNum
=
Number
.
parseInt
(
value
,
10
)
if
(
Number
.
isNaN
(
pageNum
))
return
const
nextPage
=
Math
.
min
(
Math
.
max
(
pageNum
,
1
),
totalPages
.
value
)
jumpPage
.
value
=
''
goToPage
(
nextPage
)
}
<
/script
>
<
style
scoped
>
...
...
frontend/src/components/common/README.md
View file @
2fe8932c
...
...
@@ -13,6 +13,9 @@ A generic data table component with sorting, loading states, and custom cell ren
-
`columns: Column[]`
- Array of column definitions with key, label, sortable, and formatter
-
`data: any[]`
- Array of data objects to display
-
`loading?: boolean`
- Show loading skeleton
-
`defaultSortKey?: string`
- Default sort key (only used if no persisted sort state)
-
`defaultSortOrder?: 'asc' | 'desc'`
- Default sort order (default:
`asc`
)
-
`sortStorageKey?: string`
- Persist sort state (key + order) to localStorage
-
`rowKey?: string | (row: any) => string | number`
- Row key field or resolver (defaults to
`row.id`
, falls back to index)
**Slots:**
...
...
frontend/src/components/keys/UseKeyModal.vue
View file @
2fe8932c
...
...
@@ -443,7 +443,7 @@ $env:ANTHROPIC_AUTH_TOKEN="${apiKey}"`
}
function
generateGeminiCliContent
(
baseUrl
:
string
,
apiKey
:
string
):
FileConfig
{
const
model
=
'
gemini-2.
5-pro
'
const
model
=
'
gemini-2.
0-flash
'
const
modelComment
=
t
(
'
keys.useKeyModal.gemini.modelComment
'
)
let
path
:
string
let
content
:
string
...
...
@@ -548,14 +548,22 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
}
}
const
geminiModels
=
{
'
gemini-3-pro-high
'
:
{
name
:
'
Gemini 3 Pro High
'
},
'
gemini-2.0-flash
'
:
{
name
:
'
Gemini 2.0 Flash
'
},
'
gemini-2.5-flash
'
:
{
name
:
'
Gemini 2.5 Flash
'
},
'
gemini-2.5-pro
'
:
{
name
:
'
Gemini 2.5 Pro
'
},
'
gemini-3-flash-preview
'
:
{
name
:
'
Gemini 3 Flash Preview
'
},
'
gemini-3-pro-preview
'
:
{
name
:
'
Gemini 3 Pro Preview
'
}
}
const
antigravityGeminiModels
=
{
'
gemini-2.5-flash
'
:
{
name
:
'
Gemini 2.5 Flash
'
},
'
gemini-2.5-flash-lite
'
:
{
name
:
'
Gemini 2.5 Flash Lite
'
},
'
gemini-2.5-flash-thinking
'
:
{
name
:
'
Gemini 2.5 Flash Thinking
'
},
'
gemini-3-flash
'
:
{
name
:
'
Gemini 3 Flash
'
},
'
gemini-3-pro-low
'
:
{
name
:
'
Gemini 3 Pro Low
'
},
'
gemini-3-pro-high
'
:
{
name
:
'
Gemini 3 Pro High
'
},
'
gemini-3-pro-preview
'
:
{
name
:
'
Gemini 3 Pro Preview
'
},
'
gemini-3-pro-image
'
:
{
name
:
'
Gemini 3 Pro Image
'
},
'
gemini-3-flash
'
:
{
name
:
'
Gemini 3 Flash
'
},
'
gemini-2.5-flash-thinking
'
:
{
name
:
'
Gemini 2.5 Flash Thinking
'
},
'
gemini-2.5-flash
'
:
{
name
:
'
Gemini 2.5 Flash
'
},
'
gemini-2.5-flash-lite
'
:
{
name
:
'
Gemini 2.5 Flash Lite
'
}
'
gemini-3-pro-image
'
:
{
name
:
'
Gemini 3 Pro Image
'
}
}
const
claudeModels
=
{
'
claude-opus-4-5-thinking
'
:
{
name
:
'
Claude Opus 4.5 Thinking
'
},
...
...
@@ -575,7 +583,7 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
}
else
if
(
platform
===
'
antigravity-gemini
'
)
{
provider
[
platform
].
npm
=
'
@ai-sdk/google
'
provider
[
platform
].
name
=
'
Antigravity (Gemini)
'
provider
[
platform
].
models
=
g
eminiModels
provider
[
platform
].
models
=
antigravityG
eminiModels
}
else
if
(
platform
===
'
openai
'
)
{
provider
[
platform
].
models
=
openaiModels
}
...
...
frontend/src/components/layout/AppHeader.vue
View file @
2fe8932c
...
...
@@ -21,8 +21,20 @@
</div>
</div>
<!-- Right: Language + Subscriptions + Balance + User Dropdown -->
<!-- Right:
Docs +
Language + Subscriptions + Balance + User Dropdown -->
<div
class=
"flex items-center gap-3"
>
<!-- Docs Link -->
<a
v-if=
"docUrl"
:href=
"docUrl"
target=
"_blank"
rel=
"noopener noreferrer"
class=
"flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-dark-400 dark:hover:bg-dark-800 dark:hover:text-white"
>
<Icon
name=
"book"
size=
"sm"
/>
<span
class=
"hidden sm:inline"
>
{{
t
(
'
nav.docs
'
)
}}
</span>
</a>
<!-- Language Switcher -->
<LocaleSwitcher
/>
...
...
@@ -211,6 +223,7 @@ const user = computed(() => authStore.user)
const
dropdownOpen
=
ref
(
false
)
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
contactInfo
=
computed
(()
=>
appStore
.
contactInfo
)
const
docUrl
=
computed
(()
=>
appStore
.
docUrl
)
// 只在标准模式的管理员下显示新手引导按钮
const
showOnboardingButton
=
computed
(()
=>
{
...
...
frontend/src/components/layout/AppSidebar.vue
View file @
2fe8932c
...
...
@@ -421,6 +421,16 @@ const userNavItems = computed(() => {
{
path
:
'
/keys
'
,
label
:
t
(
'
nav.apiKeys
'
),
icon
:
KeyIcon
},
{
path
:
'
/usage
'
,
label
:
t
(
'
nav.usage
'
),
icon
:
ChartIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/subscriptions
'
,
label
:
t
(
'
nav.mySubscriptions
'
),
icon
:
CreditCardIcon
,
hideInSimpleMode
:
true
},
...(
appStore
.
cachedPublicSettings
?.
purchase_subscription_enabled
?
[
{
path
:
'
/purchase
'
,
label
:
t
(
'
nav.buySubscription
'
),
icon
:
CreditCardIcon
,
hideInSimpleMode
:
true
}
]
:
[]),
{
path
:
'
/redeem
'
,
label
:
t
(
'
nav.redeem
'
),
icon
:
GiftIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/profile
'
,
label
:
t
(
'
nav.profile
'
),
icon
:
UserIcon
}
]
...
...
@@ -433,6 +443,16 @@ const personalNavItems = computed(() => {
{
path
:
'
/keys
'
,
label
:
t
(
'
nav.apiKeys
'
),
icon
:
KeyIcon
},
{
path
:
'
/usage
'
,
label
:
t
(
'
nav.usage
'
),
icon
:
ChartIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/subscriptions
'
,
label
:
t
(
'
nav.mySubscriptions
'
),
icon
:
CreditCardIcon
,
hideInSimpleMode
:
true
},
...(
appStore
.
cachedPublicSettings
?.
purchase_subscription_enabled
?
[
{
path
:
'
/purchase
'
,
label
:
t
(
'
nav.buySubscription
'
),
icon
:
CreditCardIcon
,
hideInSimpleMode
:
true
}
]
:
[]),
{
path
:
'
/redeem
'
,
label
:
t
(
'
nav.redeem
'
),
icon
:
GiftIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/profile
'
,
label
:
t
(
'
nav.profile
'
),
icon
:
UserIcon
}
]
...
...
frontend/src/components/user/profile/ProfileTotpCard.vue
0 → 100644
View file @
2fe8932c
<
template
>
<div
class=
"card"
>
<div
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h2
class=
"text-lg font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
profile.totp.title
'
)
}}
</h2>
<p
class=
"mt-1 text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
profile.totp.description
'
)
}}
</p>
</div>
<div
class=
"px-6 py-6"
>
<!-- Loading state -->
<div
v-if=
"loading"
class=
"flex items-center justify-center py-8"
>
<div
class=
"animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"
></div>
</div>
<!-- Feature disabled globally -->
<div
v-else-if=
"status && !status.feature_enabled"
class=
"flex items-center gap-4 py-4"
>
<div
class=
"flex-shrink-0 rounded-full bg-gray-100 p-3 dark:bg-dark-700"
>
<svg
class=
"h-6 w-6 text-gray-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
</div>
<div>
<p
class=
"font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
profile.totp.featureDisabled
'
)
}}
</p>
<p
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
profile.totp.featureDisabledHint
'
)
}}
</p>
</div>
</div>
<!-- 2FA Enabled -->
<div
v-else-if=
"status?.enabled"
class=
"flex items-center justify-between"
>
<div
class=
"flex items-center gap-4"
>
<div
class=
"flex-shrink-0 rounded-full bg-green-100 p-3 dark:bg-green-900/30"
>
<svg
class=
"h-6 w-6 text-green-600 dark:text-green-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
</div>
<div>
<p
class=
"font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
profile.totp.enabled
'
)
}}
</p>
<p
v-if=
"status.enabled_at"
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
profile.totp.enabledAt
'
)
}}
:
{{
formatDate
(
status
.
enabled_at
)
}}
</p>
</div>
</div>
<button
type=
"button"
class=
"btn btn-outline-danger"
@
click=
"showDisableDialog = true"
>
{{
t
(
'
profile.totp.disable
'
)
}}
</button>
</div>
<!-- 2FA Not Enabled -->
<div
v-else
class=
"flex items-center justify-between"
>
<div
class=
"flex items-center gap-4"
>
<div
class=
"flex-shrink-0 rounded-full bg-gray-100 p-3 dark:bg-dark-700"
>
<svg
class=
"h-6 w-6 text-gray-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
</div>
<div>
<p
class=
"font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
profile.totp.notEnabled
'
)
}}
</p>
<p
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
profile.totp.notEnabledHint
'
)
}}
</p>
</div>
</div>
<button
type=
"button"
class=
"btn btn-primary"
@
click=
"showSetupModal = true"
>
{{
t
(
'
profile.totp.enable
'
)
}}
</button>
</div>
</div>
<!-- Setup Modal -->
<TotpSetupModal
v-if=
"showSetupModal"
@
close=
"showSetupModal = false"
@
success=
"handleSetupSuccess"
/>
<!-- Disable Dialog -->
<TotpDisableDialog
v-if=
"showDisableDialog"
@
close=
"showDisableDialog = false"
@
success=
"handleDisableSuccess"
/>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
onMounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
totpAPI
}
from
'
@/api
'
import
type
{
TotpStatus
}
from
'
@/types
'
import
TotpSetupModal
from
'
./TotpSetupModal.vue
'
import
TotpDisableDialog
from
'
./TotpDisableDialog.vue
'
const
{
t
}
=
useI18n
()
const
loading
=
ref
(
true
)
const
status
=
ref
<
TotpStatus
|
null
>
(
null
)
const
showSetupModal
=
ref
(
false
)
const
showDisableDialog
=
ref
(
false
)
const
loadStatus
=
async
()
=>
{
loading
.
value
=
true
try
{
status
.
value
=
await
totpAPI
.
getStatus
()
}
catch
(
error
)
{
console
.
error
(
'
Failed to load TOTP status:
'
,
error
)
}
finally
{
loading
.
value
=
false
}
}
const
handleSetupSuccess
=
()
=>
{
showSetupModal
.
value
=
false
loadStatus
()
}
const
handleDisableSuccess
=
()
=>
{
showDisableDialog
.
value
=
false
loadStatus
()
}
const
formatDate
=
(
timestamp
:
number
)
=>
{
// Backend returns Unix timestamp in seconds, convert to milliseconds
const
date
=
new
Date
(
timestamp
*
1000
)
return
date
.
toLocaleDateString
(
undefined
,
{
year
:
'
numeric
'
,
month
:
'
long
'
,
day
:
'
numeric
'
,
hour
:
'
2-digit
'
,
minute
:
'
2-digit
'
})
}
onMounted
(()
=>
{
loadStatus
()
})
</
script
>
Prev
1
…
8
9
10
11
12
13
14
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