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
ff06583c
Commit
ff06583c
authored
Dec 28, 2025
by
song
Browse files
Merge branch 'main' into feature/antigravity_auth
parents
b0389ca4
fb9d0878
Changes
49
Hide whitespace changes
Inline
Side-by-side
frontend/src/views/admin/UsersView.vue
View file @
ff06583c
...
...
@@ -198,12 +198,13 @@
<span
class=
"text-sm text-gray-500 dark:text-dark-400"
>
{{
formatDateTime
(
value
)
}}
</span>
</
template
>
<
template
#cell-actions=
"{ row
, expanded
}"
>
<
template
#cell-actions=
"{ row }"
>
<div
class=
"flex items-center gap-1"
>
<!--
主要操作:编辑和删除(始终显示)
-->
<!--
Edit Button
-->
<button
@
click=
"handleEdit(row)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
class=
"flex h-8 w-8 items-center justify-center rounded-lg text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
:title=
"t('common.edit')"
>
<svg
class=
"h-4 w-4"
...
...
@@ -218,145 +219,29 @@
d=
"M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
<span
class=
"text-xs"
>
{{
t
(
'
common.edit
'
)
}}
</span>
</button>
<!-- More Actions Menu Trigger -->
<button
v-if=
"row.role !== 'admin'"
@
click=
"handleDelete(row)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
:ref=
"(el) => setActionButtonRef(row.id, el)"
@
click=
"openActionMenu(row)"
class=
"action-menu-trigger flex h-8 w-8 items-center justify-center rounded-lg text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-dark-700 dark:hover:text-white"
:class=
"
{ 'bg-gray-100 text-gray-900 dark:bg-dark-700 dark:text-white': activeMenuId === row.id }"
>
<svg
class=
"h-
4
w-
4
"
class=
"h-
5
w-
5
"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
<span
class=
"text-xs"
>
{{
t
(
'
common.delete
'
)
}}
</span>
</button>
<!-- 次要操作:展开时显示 -->
<template
v-if=
"expanded"
>
<!-- Toggle Status (hidden for admin users) -->
<button
v-if=
"row.role !== 'admin'"
@
click=
"handleToggleStatus(row)"
:class=
"[
'flex flex-col items-center gap-0.5 rounded-lg p-1.5 transition-colors',
row.status === 'active'
? 'text-gray-500 hover:bg-orange-50 hover:text-orange-600 dark:hover:bg-orange-900/20 dark:hover:text-orange-400'
: 'text-gray-500 hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400'
]"
>
<svg
v-if=
"row.status === 'active'"
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M
18.364 18.364A9 9 0 005.636 5.636m
12.7
28
12.7
28A9 9 0 015.636 5.636m12.728
12.7
28L5.636 5.636
"
d=
"M
6.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM
12.7
5
12
a
.7
5.75 0 11-1.5 0 .75.75 0 011.5 0zM18.75
12
a
.7
5.75 0 11-1.5 0 .75.75 0 011.5 0z
"
/>
</svg>
<svg
v-else
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span
class=
"text-xs"
>
{{
row
.
status
===
'
active
'
?
t
(
'
admin.users.disable
'
)
:
t
(
'
admin.users.enable
'
)
}}
</span>
</button>
<!-- Allowed Groups -->
<button
@
click=
"handleAllowedGroups(row)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
>
<svg
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
/>
</svg>
<span
class=
"text-xs"
>
{{
t
(
'
admin.users.groups
'
)
}}
</span>
</button>
<!-- View API Keys -->
<button
@
click=
"handleViewApiKeys(row)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400"
>
<svg
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
<span
class=
"text-xs"
>
{{
t
(
'
admin.users.apiKeys
'
)
}}
</span>
</button>
<!-- Deposit -->
<button
@
click=
"handleDeposit(row)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400"
>
<svg
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 4.5v15m7.5-7.5h-15"
/>
</svg>
<span
class=
"text-xs"
>
{{
t
(
'
admin.users.deposit
'
)
}}
</span>
</button>
<!-- Withdraw -->
<button
@
click=
"handleWithdraw(row)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
>
<svg
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M5 12h14"
/>
</svg>
<span
class=
"text-xs"
>
{{
t
(
'
admin.users.withdraw
'
)
}}
</span>
</button>
</
template
>
</button>
</div>
</
template
>
...
...
@@ -379,18 +264,121 @@
:total=
"pagination.total"
:page-size=
"pagination.page_size"
@
update:page=
"handlePageChange"
@
update:pageSize=
"handlePageSizeChange"
/>
</
template
>
</TablePageLayout>
<!-- Action Menu (Teleported) -->
<Teleport
to=
"body"
>
<div
v-if=
"activeMenuId !== null && menuPosition"
class=
"action-menu-content fixed z-[9999] w-48 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800 dark:ring-white/10"
:style=
"{ top: menuPosition.top + 'px', left: menuPosition.left + 'px' }"
>
<div
class=
"py-1"
>
<
template
v-for=
"user in users"
:key=
"user.id"
>
<template
v-if=
"user.id === activeMenuId"
>
<!-- View API Keys -->
<button
@
click=
"handleViewApiKeys(user); closeActionMenu()"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<svg
class=
"h-4 w-4 text-gray-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11.536 16.207l-1.414 1.414a2 2 0 01-2.828 0l-1.414-1.414a2 2 0 010-2.828l-1.414-1.414a2 2 0 010-2.828l1.414-1.414L10.257 6.257A6 6 0 1121 11.257V11.257"
/>
</svg>
{{
t
(
'
admin.users.apiKeys
'
)
}}
</button>
<!-- Allowed Groups -->
<button
@
click=
"handleAllowedGroups(user); closeActionMenu()"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<svg
class=
"h-4 w-4 text-gray-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
{{
t
(
'
admin.users.groups
'
)
}}
</button>
<div
class=
"my-1 border-t border-gray-100 dark:border-dark-700"
></div>
<!-- Deposit -->
<button
@
click=
"handleDeposit(user); closeActionMenu()"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<svg
class=
"h-4 w-4 text-emerald-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
{{
t
(
'
admin.users.deposit
'
)
}}
</button>
<!-- Withdraw -->
<button
@
click=
"handleWithdraw(user); closeActionMenu()"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<svg
class=
"h-4 w-4 text-amber-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M20 12H4"
/>
</svg>
{{
t
(
'
admin.users.withdraw
'
)
}}
</button>
<div
class=
"my-1 border-t border-gray-100 dark:border-dark-700"
></div>
<!-- Toggle Status (not for admin) -->
<button
v-if=
"user.role !== 'admin'"
@
click=
"handleToggleStatus(user); closeActionMenu()"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<svg
v-if=
"user.status === 'active'"
class=
"h-4 w-4 text-orange-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg>
<svg
v-else
class=
"h-4 w-4 text-green-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{{
user
.
status
===
'
active
'
?
t
(
'
admin.users.disable
'
)
:
t
(
'
admin.users.enable
'
)
}}
</button>
<!-- Delete (not for admin) -->
<button
v-if=
"user.role !== 'admin'"
@
click=
"handleDelete(user); closeActionMenu()"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
{{
t
(
'
common.delete
'
)
}}
</button>
</
template
>
</template>
</div>
</div>
</Teleport>
<!-- Create User Modal -->
<
Modal
<
BaseDialog
:show=
"showCreateModal"
:title=
"t('admin.users.createUser')"
size=
"lg
"
width=
"normal
"
@
close=
"closeCreateModal"
>
<form
@
submit.prevent=
"handleCreateUser"
class=
"space-y-5"
>
<form
id=
"create-user-form"
@
submit.prevent=
"handleCreateUser"
class=
"space-y-5"
>
<div>
<label
class=
"input-label"
>
{{ t('admin.users.email') }}
</label>
<input
...
...
@@ -512,12 +500,19 @@
<input
v-model.number=
"createForm.concurrency"
type=
"number"
class=
"input"
/>
</div>
</div>
</form>
<div
class=
"flex justify-end gap-3 pt-4"
>
<
template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"closeCreateModal"
type=
"button"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"submit"
:disabled=
"submitting"
class=
"btn btn-primary"
>
<button
type=
"submit"
form=
"create-user-form"
:disabled=
"submitting"
class=
"btn btn-primary"
>
<svg
v-if=
"submitting"
class=
"-ml-1 mr-2 h-4 w-4 animate-spin"
...
...
@@ -541,17 +536,22 @@
{{
submitting
?
t
(
'
admin.users.creating
'
)
:
t
(
'
common.create
'
)
}}
</button>
</div>
</
form
>
</
Modal
>
</
template
>
</
BaseDialog
>
<!-- Edit User Modal -->
<
Modal
<
BaseDialog
:show=
"showEditModal"
:title=
"t('admin.users.editUser')"
size=
"lg
"
width=
"normal
"
@
close=
"closeEditModal"
>
<form
v-if=
"editingUser"
@
submit.prevent=
"handleUpdateUser"
class=
"space-y-5"
>
<form
v-if=
"editingUser"
id=
"edit-user-form"
@
submit.prevent=
"handleUpdateUser"
class=
"space-y-5"
>
<div>
<label
class=
"input-label"
>
{{ t('admin.users.email') }}
</label>
<input
v-model=
"editForm.email"
type=
"email"
class=
"input"
/>
...
...
@@ -664,11 +664,19 @@
<input
v-model.number=
"editForm.concurrency"
type=
"number"
class=
"input"
/>
</div>
<div
class=
"flex justify-end gap-3 pt-4"
>
</form>
<
template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"closeEditModal"
type=
"button"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"submit"
:disabled=
"submitting"
class=
"btn btn-primary"
>
<button
type=
"submit"
form=
"edit-user-form"
:disabled=
"submitting"
class=
"btn btn-primary"
>
<svg
v-if=
"submitting"
class=
"-ml-1 mr-2 h-4 w-4 animate-spin"
...
...
@@ -692,14 +700,14 @@
{{
submitting
?
t
(
'
admin.users.updating
'
)
:
t
(
'
common.update
'
)
}}
</button>
</div>
</
form
>
</
Modal
>
</
template
>
</
BaseDialog
>
<!-- View API Keys Modal -->
<
Modal
<
BaseDialog
:show=
"showApiKeysModal"
:title=
"t('admin.users.userApiKeys')"
size=
"xl
"
width=
"wide
"
@
close=
"closeApiKeysModal"
>
<div
v-if=
"viewingUser"
class=
"space-y-4"
>
...
...
@@ -828,13 +836,13 @@
</button>
</div>
</
template
>
</
Modal
>
</
BaseDialog
>
<!-- Allowed Groups Modal -->
<
Modal
<
BaseDialog
:show=
"showAllowedGroupsModal"
:title=
"t('admin.users.setAllowedGroups')"
size=
"lg
"
width=
"normal
"
@
close=
"closeAllowedGroupsModal"
>
<div
v-if=
"allowedGroupsUser"
class=
"space-y-4"
>
...
...
@@ -994,16 +1002,21 @@
</button>
</div>
</
template
>
</
Modal
>
</
BaseDialog
>
<!-- Deposit/Withdraw Modal -->
<
Modal
<
BaseDialog
:show=
"showBalanceModal"
:title=
"balanceOperation === 'add' ? t('admin.users.deposit') : t('admin.users.withdraw')"
size=
"md
"
width=
"narrow
"
@
close=
"closeBalanceModal"
>
<form
v-if=
"balanceUser"
@
submit.prevent=
"handleBalanceSubmit"
class=
"space-y-5"
>
<form
v-if=
"balanceUser"
id=
"balance-form"
@
submit.prevent=
"handleBalanceSubmit"
class=
"space-y-5"
>
<div
class=
"flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700"
>
<div
class=
"flex h-10 w-10 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30"
...
...
@@ -1098,12 +1111,16 @@
</div>
</div>
<div
class=
"flex justify-end gap-3 pt-4"
>
</form>
<
template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"closeBalanceModal"
type=
"button"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"submit"
form=
"balance-form"
:disabled=
"
balanceSubmitting ||
!balanceForm.amount ||
...
...
@@ -1148,8 +1165,8 @@
}}
</button>
</div>
</
form
>
</
Modal
>
</
template
>
</
BaseDialog
>
<!-- Delete Confirmation Dialog -->
<ConfirmDialog
...
...
@@ -1166,7 +1183,7 @@
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
reactive
,
computed
,
onMounted
}
from
'
vue
'
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
,
type
ComponentPublicInstance
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
...
...
@@ -1181,7 +1198,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
Modal
from
'
@/components/common/
Modal
.vue
'
import
BaseDialog
from
'
@/components/common/
BaseDialog
.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
Select
from
'
@/components/common/Select.vue
'
...
...
@@ -1244,6 +1261,63 @@ const viewingUser = ref<User | null>(null)
const
userApiKeys
=
ref
<
ApiKey
[]
>
([])
const
loadingApiKeys
=
ref
(
false
)
const
passwordCopied
=
ref
(
false
)
let
abortController
:
AbortController
|
null
=
null
// Action Menu State
const
activeMenuId
=
ref
<
number
|
null
>
(
null
)
const
menuPosition
=
ref
<
{
top
:
number
;
left
:
number
}
|
null
>
(
null
)
const
actionButtonRefs
=
ref
<
Map
<
number
,
HTMLElement
>>
(
new
Map
())
const
setActionButtonRef
=
(
userId
:
number
,
el
:
Element
|
ComponentPublicInstance
|
null
)
=>
{
if
(
el
instanceof
HTMLElement
)
{
actionButtonRefs
.
value
.
set
(
userId
,
el
)
}
else
{
actionButtonRefs
.
value
.
delete
(
userId
)
}
}
const
openActionMenu
=
(
user
:
User
)
=>
{
if
(
activeMenuId
.
value
===
user
.
id
)
{
closeActionMenu
()
}
else
{
const
buttonEl
=
actionButtonRefs
.
value
.
get
(
user
.
id
)
if
(
buttonEl
)
{
const
rect
=
buttonEl
.
getBoundingClientRect
()
const
menuWidth
=
192
const
menuHeight
=
240
const
padding
=
8
const
viewportWidth
=
window
.
innerWidth
const
viewportHeight
=
window
.
innerHeight
const
left
=
Math
.
min
(
Math
.
max
(
rect
.
right
-
menuWidth
,
padding
),
Math
.
max
(
viewportWidth
-
menuWidth
-
padding
,
padding
)
)
let
top
=
rect
.
bottom
+
4
if
(
top
+
menuHeight
>
viewportHeight
-
padding
)
{
top
=
Math
.
max
(
rect
.
top
-
menuHeight
-
4
,
padding
)
}
// Position menu near the trigger, clamped to viewport
menuPosition
.
value
=
{
top
,
left
}
}
activeMenuId
.
value
=
user
.
id
}
}
const
closeActionMenu
=
()
=>
{
activeMenuId
.
value
=
null
menuPosition
.
value
=
null
}
// Close menu when clicking outside
const
handleClickOutside
=
(
event
:
MouseEvent
)
=>
{
const
target
=
event
.
target
as
HTMLElement
if
(
!
target
.
closest
(
'
.action-menu-trigger
'
)
&&
!
target
.
closest
(
'
.action-menu-content
'
))
{
closeActionMenu
()
}
}
// Allowed groups modal state
const
showAllowedGroupsModal
=
ref
(
false
)
...
...
@@ -1331,13 +1405,25 @@ const copyEditPassword = async () => {
}
const
loadUsers
=
async
()
=>
{
abortController
?.
abort
()
const
currentAbortController
=
new
AbortController
()
abortController
=
currentAbortController
const
{
signal
}
=
currentAbortController
loading
.
value
=
true
try
{
const
response
=
await
adminAPI
.
users
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
role
:
filters
.
role
as
any
,
status
:
filters
.
status
as
any
,
search
:
searchQuery
.
value
||
undefined
})
const
response
=
await
adminAPI
.
users
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
role
:
filters
.
role
as
any
,
status
:
filters
.
status
as
any
,
search
:
searchQuery
.
value
||
undefined
},
{
signal
}
)
if
(
signal
.
aborted
)
{
return
}
users
.
value
=
response
.
items
pagination
.
total
=
response
.
total
pagination
.
pages
=
response
.
pages
...
...
@@ -1347,16 +1433,28 @@ const loadUsers = async () => {
const
userIds
=
response
.
items
.
map
((
u
)
=>
u
.
id
)
try
{
const
usageResponse
=
await
adminAPI
.
dashboard
.
getBatchUsersUsage
(
userIds
)
if
(
signal
.
aborted
)
{
return
}
usageStats
.
value
=
usageResponse
.
stats
}
catch
(
e
)
{
if
(
signal
.
aborted
)
{
return
}
console
.
error
(
'
Failed to load usage stats:
'
,
e
)
}
}
}
catch
(
error
)
{
const
errorInfo
=
error
as
{
name
?:
string
;
code
?:
string
}
if
(
errorInfo
?.
name
===
'
AbortError
'
||
errorInfo
?.
name
===
'
CanceledError
'
||
errorInfo
?.
code
===
'
ERR_CANCELED
'
)
{
return
}
appStore
.
showError
(
t
(
'
admin.users.failedToLoad
'
))
console
.
error
(
'
Error loading users:
'
,
error
)
}
finally
{
loading
.
value
=
false
if
(
abortController
===
currentAbortController
)
{
loading
.
value
=
false
}
}
}
...
...
@@ -1374,6 +1472,12 @@ const handlePageChange = (page: number) => {
loadUsers
()
}
const
handlePageSizeChange
=
(
pageSize
:
number
)
=>
{
pagination
.
page_size
=
pageSize
pagination
.
page
=
1
loadUsers
()
}
const
closeCreateModal
=
()
=>
{
showCreateModal
.
value
=
false
createForm
.
email
=
''
...
...
@@ -1620,5 +1724,10 @@ const handleBalanceSubmit = async () => {
onMounted
(()
=>
{
loadUsers
()
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
})
onUnmounted
(()
=>
{
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
})
</
script
>
frontend/src/views/auth/LoginView.vue
View file @
ff06583c
...
...
@@ -39,6 +39,7 @@
v-model=
"formData.email"
type=
"email"
required
autofocus
autocomplete=
"email"
:disabled=
"isLoading"
class=
"input pl-11"
...
...
frontend/src/views/auth/RegisterView.vue
View file @
ff06583c
...
...
@@ -66,6 +66,7 @@
v
-
model
=
"
formData.email
"
type
=
"
email
"
required
autofocus
autocomplete
=
"
email
"
:
disabled
=
"
isLoading
"
class
=
"
input pl-11
"
...
...
frontend/src/views/setup/SetupWizardView.vue
View file @
ff06583c
...
...
@@ -563,13 +563,13 @@ const installing = ref(false)
const
confirmPassword
=
ref
(
''
)
const
serviceReady
=
ref
(
false
)
//
Get curren
t server port
from browser location (set by install.sh)
//
Defaul
t server port
const
getCurrentPort
=
():
number
=>
{
const
port
=
window
.
location
.
port
if
(
port
)
{
return
parseInt
(
port
,
10
)
}
// Default port based on protocol
return
window
.
location
.
protocol
===
'
https:
'
?
443
:
80
}
...
...
@@ -674,42 +674,35 @@ async function performInstall() {
// Wait for service to restart and become available
async
function
waitForServiceRestart
()
{
const
maxAttempts
=
3
0
//
3
0 attempts, ~
3
0 seconds max
const
maxAttempts
=
6
0
//
Increase to 6
0 attempts, ~
6
0 seconds max
const
interval
=
1000
// 1 second between attempts
// Wait a moment for the service to start restarting
await
new
Promise
((
resolve
)
=>
setTimeout
(
resolve
,
2
000
))
await
new
Promise
((
resolve
)
=>
setTimeout
(
resolve
,
3
000
))
for
(
let
attempt
=
0
;
attempt
<
maxAttempts
;
attempt
++
)
{
try
{
// Try to access the health endpoint
const
response
=
await
fetch
(
'
/health
'
,
{
// Use setup status endpoint as it tells us the real mode
// Service might return 404 or connection refused while restarting
const
response
=
await
fetch
(
'
/setup/status
'
,
{
method
:
'
GET
'
,
cache
:
'
no-store
'
})
if
(
response
.
ok
)
{
// Service is up, check if setup is no longer needed
const
statusResponse
=
await
fetch
(
'
/setup/status
'
,
{
method
:
'
GET
'
,
cache
:
'
no-store
'
})
if
(
statusResponse
.
ok
)
{
const
data
=
await
statusResponse
.
json
()
// If needs_setup is false, service has restarted in normal mode
if
(
data
.
data
&&
!
data
.
data
.
needs_setup
)
{
serviceReady
.
value
=
true
// Redirect to login page after a short delay
setTimeout
(()
=>
{
window
.
location
.
href
=
'
/login
'
},
1500
)
return
}
const
data
=
await
response
.
json
()
// If needs_setup is false, service has restarted in normal mode
if
(
data
.
data
&&
!
data
.
data
.
needs_setup
)
{
serviceReady
.
value
=
true
// Redirect to login page after a short delay
setTimeout
(()
=>
{
window
.
location
.
href
=
'
/login
'
},
1500
)
return
}
}
}
catch
{
// Service not ready
ye
t, continue polling
// Service not ready
or network error during restar
t, continue polling
}
await
new
Promise
((
resolve
)
=>
setTimeout
(
resolve
,
interval
))
...
...
frontend/src/views/user/DashboardView.vue
View file @
ff06583c
...
...
@@ -322,7 +322,13 @@
<!-- Charts Grid -->
<div
class=
"grid grid-cols-1 gap-6 lg:grid-cols-2"
>
<!-- Model Distribution Chart -->
<div
class=
"card p-4"
>
<div
class=
"card relative overflow-hidden p-4"
>
<div
v-if=
"loadingCharts"
class=
"absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-dark-800/50"
>
<LoadingSpinner
size=
"md"
/>
</div>
<h3
class=
"mb-4 text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
dashboard.modelDistribution
'
)
}}
</h3>
...
...
@@ -330,6 +336,7 @@
<div
class=
"h-48 w-48"
>
<Doughnut
v-if=
"modelChartData"
ref=
"modelChartRef"
:data=
"modelChartData"
:options=
"doughnutOptions"
/>
...
...
@@ -383,12 +390,23 @@
</div>
<!-- Token Usage Trend Chart -->
<div
class=
"card p-4"
>
<div
class=
"card relative overflow-hidden p-4"
>
<div
v-if=
"loadingCharts"
class=
"absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-dark-800/50"
>
<LoadingSpinner
size=
"md"
/>
</div>
<h3
class=
"mb-4 text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
dashboard.tokenUsageTrend
'
)
}}
</h3>
<div
class=
"h-48"
>
<Line
v-if=
"trendChartData"
:data=
"trendChartData"
:options=
"lineOptions"
/>
<Line
v-if=
"trendChartData"
ref=
"trendChartRef"
:data=
"trendChartData"
:options=
"lineOptions"
/>
<div
v-else
class=
"flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400"
...
...
@@ -645,10 +663,11 @@
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
onMounted
,
watch
}
from
'
vue
'
import
{
ref
,
computed
,
onMounted
,
watch
,
nextTick
}
from
'
vue
'
import
{
useRouter
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
useSubscriptionStore
}
from
'
@/stores/subscriptions
'
import
{
formatDateTime
}
from
'
@/utils/format
'
const
{
t
}
=
useI18n
()
...
...
@@ -689,15 +708,21 @@ ChartJS.register(
const
router
=
useRouter
()
const
authStore
=
useAuthStore
()
const
subscriptionStore
=
useSubscriptionStore
()
const
user
=
computed
(()
=>
authStore
.
user
)
const
stats
=
ref
<
UserDashboardStats
|
null
>
(
null
)
const
loading
=
ref
(
false
)
const
loadingUsage
=
ref
(
false
)
const
loadingCharts
=
ref
(
false
)
type
ChartComponentRef
=
{
chart
?:
ChartJS
}
// Chart data
const
trendData
=
ref
<
TrendDataPoint
[]
>
([])
const
modelStats
=
ref
<
ModelStat
[]
>
([])
const
modelChartRef
=
ref
<
ChartComponentRef
|
null
>
(
null
)
const
trendChartRef
=
ref
<
ChartComponentRef
|
null
>
(
null
)
// Recent usage
const
recentUsage
=
ref
<
UsageLog
[]
>
([])
...
...
@@ -964,6 +989,7 @@ const loadDashboardStats = async () => {
}
const
loadChartData
=
async
()
=>
{
loadingCharts
.
value
=
true
try
{
const
params
=
{
start_date
:
startDate
.
value
,
...
...
@@ -981,14 +1007,16 @@ const loadChartData = async () => {
modelStats
.
value
=
modelResponse
.
models
||
[]
}
catch
(
error
)
{
console
.
error
(
'
Error loading chart data:
'
,
error
)
}
finally
{
loadingCharts
.
value
=
false
}
}
const
loadRecentUsage
=
async
()
=>
{
loadingUsage
.
value
=
true
try
{
const
endDate
=
new
Date
().
toISOString
()
const
startDate
=
new
Date
(
Date
.
now
()
-
7
*
24
*
60
*
60
*
1000
).
toISOString
()
const
endDate
=
new
Date
().
toISOString
()
.
split
(
'
T
'
)[
0
]
const
startDate
=
new
Date
(
Date
.
now
()
-
7
*
24
*
60
*
60
*
1000
).
toISOString
()
.
split
(
'
T
'
)[
0
]
const
usageResponse
=
await
usageAPI
.
getByDateRange
(
startDate
,
endDate
)
recentUsage
.
value
=
usageResponse
.
items
.
slice
(
0
,
5
)
}
catch
(
error
)
{
...
...
@@ -998,16 +1026,30 @@ const loadRecentUsage = async () => {
}
}
onMounted
(()
=>
{
loadDashboardStats
()
onMounted
(
async
()
=>
{
// Load critical data first
await
loadDashboardStats
()
// Force refresh subscription status when entering dashboard (bypass cache)
subscriptionStore
.
fetchActiveSubscriptions
(
true
).
catch
((
error
)
=>
{
console
.
error
(
'
Failed to refresh subscription status:
'
,
error
)
})
// Initialize date range (synchronous)
initializeDateRange
()
loadChartData
()
loadRecentUsage
()
// Load chart data and recent usage in parallel (non-critical)
Promise
.
all
([
loadChartData
(),
loadRecentUsage
()]).
catch
((
error
)
=>
{
console
.
error
(
'
Error loading secondary data:
'
,
error
)
})
})
// Watch for dark mode changes
watch
(
isDarkMode
,
()
=>
{
// Force chart re-render on theme change
nextTick
(()
=>
{
modelChartRef
.
value
?.
chart
?.
update
()
trendChartRef
.
value
?.
chart
?.
update
()
})
})
</
script
>
...
...
frontend/src/views/user/KeysView.vue
View file @
ff06583c
...
...
@@ -292,17 +292,19 @@
:total=
"pagination.total"
:page-size=
"pagination.page_size"
@
update:page=
"handlePageChange"
@
update:pageSize=
"handlePageSizeChange"
/>
</
template
>
</TablePageLayout>
<!-- Create/Edit Modal -->
<
Modal
<
BaseDialog
:show=
"showCreateModal || showEditModal"
:title=
"showEditModal ? t('keys.editKey') : t('keys.createKey')"
width=
"narrow"
@
close=
"closeModals"
>
<form
@
submit.prevent=
"handleSubmit"
class=
"space-y-5"
>
<form
id=
"key-form"
@
submit.prevent=
"handleSubmit"
class=
"space-y-5"
>
<div>
<label
class=
"input-label"
>
{{ t('keys.nameLabel') }}
</label>
<input
...
...
@@ -383,12 +385,13 @@
:placeholder=
"t('keys.selectStatus')"
/>
</div>
<div
class=
"flex justify-end gap-3 pt-4"
>
</form>
<
template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"closeModals"
type=
"button"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"submit"
:disabled=
"submitting"
class=
"btn btn-primary"
>
<button
form=
"key-form"
type=
"submit"
:disabled=
"submitting"
class=
"btn btn-primary"
>
<svg
v-if=
"submitting"
class=
"-ml-1 mr-2 h-4 w-4 animate-spin"
...
...
@@ -418,8 +421,8 @@
}}
</button>
</div>
</
form
>
</
Modal
>
</
template
>
</
BaseDialog
>
<!-- Delete Confirmation Dialog -->
<ConfirmDialog
...
...
@@ -501,7 +504,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
Modal
from
'
@/components/common/
Modal
.vue
'
import
BaseDialog
from
'
@/components/common/
BaseDialog
.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
Select
from
'
@/components/common/Select.vue
'
...
...
@@ -557,6 +560,7 @@ const publicSettings = ref<PublicSettings | null>(null)
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
dropdownPosition
=
ref
<
{
top
:
number
;
left
:
number
}
|
null
>
(
null
)
const
groupButtonRefs
=
ref
<
Map
<
number
,
HTMLElement
>>
(
new
Map
())
let
abortController
:
AbortController
|
null
=
null
// Get the currently selected key for group change
const
selectedKeyForGroup
=
computed
(()
=>
{
...
...
@@ -623,14 +627,27 @@ const copyToClipboard = async (text: string, keyId: number) => {
copiedKeyId
.
value
=
keyId
setTimeout
(()
=>
{
copiedKeyId
.
value
=
null
},
20
00
)
},
8
00
)
}
}
const
isAbortError
=
(
error
:
unknown
)
=>
{
if
(
!
error
||
typeof
error
!==
'
object
'
)
return
false
const
{
name
,
code
}
=
error
as
{
name
?:
string
;
code
?:
string
}
return
name
===
'
AbortError
'
||
code
===
'
ERR_CANCELED
'
}
const
loadApiKeys
=
async
()
=>
{
abortController
?.
abort
()
const
controller
=
new
AbortController
()
abortController
=
controller
const
{
signal
}
=
controller
loading
.
value
=
true
try
{
const
response
=
await
keysAPI
.
list
(
pagination
.
value
.
page
,
pagination
.
value
.
page_size
)
const
response
=
await
keysAPI
.
list
(
pagination
.
value
.
page
,
pagination
.
value
.
page_size
,
{
signal
})
if
(
signal
.
aborted
)
return
apiKeys
.
value
=
response
.
items
pagination
.
value
.
total
=
response
.
total
pagination
.
value
.
pages
=
response
.
pages
...
...
@@ -639,16 +656,24 @@ const loadApiKeys = async () => {
if
(
response
.
items
.
length
>
0
)
{
const
keyIds
=
response
.
items
.
map
((
k
)
=>
k
.
id
)
try
{
const
usageResponse
=
await
usageAPI
.
getDashboardApiKeysUsage
(
keyIds
)
const
usageResponse
=
await
usageAPI
.
getDashboardApiKeysUsage
(
keyIds
,
{
signal
})
if
(
signal
.
aborted
)
return
usageStats
.
value
=
usageResponse
.
stats
}
catch
(
e
)
{
console
.
error
(
'
Failed to load usage stats:
'
,
e
)
if
(
!
isAbortError
(
e
))
{
console
.
error
(
'
Failed to load usage stats:
'
,
e
)
}
}
}
}
catch
(
error
)
{
if
(
isAbortError
(
error
))
{
return
}
appStore
.
showError
(
t
(
'
keys.failedToLoad
'
))
}
finally
{
loading
.
value
=
false
if
(
abortController
===
controller
)
{
loading
.
value
=
false
}
}
}
...
...
@@ -683,6 +708,12 @@ const handlePageChange = (page: number) => {
loadApiKeys
()
}
const
handlePageSizeChange
=
(
pageSize
:
number
)
=>
{
pagination
.
value
.
page_size
=
pageSize
pagination
.
value
.
page
=
1
loadApiKeys
()
}
const
editKey
=
(
key
:
ApiKey
)
=>
{
selectedKey
.
value
=
key
formData
.
value
=
{
...
...
frontend/src/views/user/ProfileView.vue
View file @
ff06583c
...
...
@@ -244,6 +244,12 @@
autocomplete=
"new-password"
class=
"input"
/>
<p
v-if=
"passwordForm.new_password && passwordForm.confirm_password && passwordForm.new_password !== passwordForm.confirm_password"
class=
"input-error-text"
>
{{
t
(
'
profile.passwordsNotMatch
'
)
}}
</p>
</div>
<div
class=
"flex justify-end pt-4"
>
...
...
@@ -392,6 +398,12 @@ const handleChangePassword = async () => {
}
const
handleUpdateProfile
=
async
()
=>
{
// Basic validation
if
(
!
profileForm
.
value
.
username
.
trim
())
{
appStore
.
showError
(
t
(
'
profile.usernameRequired
'
))
return
}
updatingProfile
.
value
=
true
try
{
const
updatedUser
=
await
userAPI
.
updateProfile
({
...
...
frontend/src/views/user/RedeemView.vue
View file @
ff06583c
...
...
@@ -445,6 +445,7 @@ import { ref, computed, onMounted } from 'vue'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useSubscriptionStore
}
from
'
@/stores/subscriptions
'
import
{
redeemAPI
,
authAPI
,
type
RedeemHistoryItem
}
from
'
@/api
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
{
formatDateTime
}
from
'
@/utils/format
'
...
...
@@ -452,6 +453,7 @@ import { formatDateTime } from '@/utils/format'
const
{
t
}
=
useI18n
()
const
authStore
=
useAuthStore
()
const
appStore
=
useAppStore
()
const
subscriptionStore
=
useSubscriptionStore
()
const
user
=
computed
(()
=>
authStore
.
user
)
...
...
@@ -544,6 +546,16 @@ const handleRedeem = async () => {
// Refresh user data to get updated balance/concurrency
await
authStore
.
refreshUser
()
// If subscription type, immediately refresh subscription status
if
(
result
.
type
===
'
subscription
'
)
{
try
{
await
subscriptionStore
.
fetchActiveSubscriptions
(
true
)
// force refresh
}
catch
(
error
)
{
console
.
error
(
'
Failed to refresh subscriptions after redeem:
'
,
error
)
appStore
.
showWarning
(
t
(
'
redeem.subscriptionRefreshFailed
'
))
}
}
// Clear the input
redeemCode
.
value
=
''
...
...
frontend/src/views/user/UsageView.vue
View file @
ff06583c
...
...
@@ -164,8 +164,28 @@
<button
@
click=
"resetFilters"
class=
"btn btn-secondary"
>
{{
t
(
'
common.reset
'
)
}}
</button>
<button
@
click=
"exportToCSV"
class=
"btn btn-primary"
>
{{
t
(
'
usage.exportCsv
'
)
}}
<button
@
click=
"exportToCSV"
:disabled=
"exporting"
class=
"btn btn-primary"
>
<svg
v-if=
"exporting"
class=
"-ml-1 mr-2 h-4 w-4 animate-spin"
fill=
"none"
viewBox=
"0 0 24 24"
>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{
exporting
?
t
(
'
usage.exporting
'
)
:
t
(
'
usage.exportCsv
'
)
}}
</button>
</div>
</div>
...
...
@@ -366,6 +386,7 @@
:total=
"pagination.total"
:page-size=
"pagination.page_size"
@
update:page=
"handlePageChange"
@
update:pageSize=
"handlePageSizeChange"
/>
</
template
>
</TablePageLayout>
...
...
@@ -412,7 +433,7 @@
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
onMounted
}
from
'
vue
'
import
{
ref
,
computed
,
reactive
,
onMounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
usageAPI
,
keysAPI
}
from
'
@/api
'
...
...
@@ -430,6 +451,8 @@ import { formatDateTime } from '@/utils/format'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
let
abortController
:
AbortController
|
null
=
null
// Tooltip state
const
tooltipVisible
=
ref
(
false
)
const
tooltipPosition
=
ref
({
x
:
0
,
y
:
0
})
...
...
@@ -453,6 +476,7 @@ const columns = computed<Column[]>(() => [
const
usageLogs
=
ref
<
UsageLog
[]
>
([])
const
apiKeys
=
ref
<
ApiKey
[]
>
([])
const
loading
=
ref
(
false
)
const
exporting
=
ref
(
false
)
const
apiKeyOptions
=
computed
(()
=>
{
return
[
...
...
@@ -498,7 +522,7 @@ const onDateRangeChange = (range: {
applyFilters
()
}
const
pagination
=
re
f
({
const
pagination
=
re
active
({
page
:
1
,
page_size
:
20
,
total
:
0
,
...
...
@@ -532,22 +556,40 @@ const formatCacheTokens = (value: number): string => {
}
const
loadUsageLogs
=
async
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
}
const
currentAbortController
=
new
AbortController
()
abortController
=
currentAbortController
const
{
signal
}
=
currentAbortController
loading
.
value
=
true
try
{
const
params
:
UsageQueryParams
=
{
page
:
pagination
.
value
.
page
,
page_size
:
pagination
.
value
.
page_size
,
page
:
pagination
.
page
,
page_size
:
pagination
.
page_size
,
...
filters
.
value
}
const
response
=
await
usageAPI
.
query
(
params
)
const
response
=
await
usageAPI
.
query
(
params
,
{
signal
})
if
(
signal
.
aborted
)
{
return
}
usageLogs
.
value
=
response
.
items
pagination
.
value
.
total
=
response
.
total
pagination
.
value
.
pages
=
response
.
pages
pagination
.
total
=
response
.
total
pagination
.
pages
=
response
.
pages
}
catch
(
error
)
{
if
(
signal
.
aborted
)
{
return
}
const
abortError
=
error
as
{
name
?:
string
;
code
?:
string
}
if
(
abortError
?.
name
===
'
AbortError
'
||
abortError
?.
code
===
'
ERR_CANCELED
'
)
{
return
}
appStore
.
showError
(
t
(
'
usage.failedToLoad
'
))
}
finally
{
loading
.
value
=
false
if
(
abortController
===
currentAbortController
)
{
loading
.
value
=
false
}
}
}
...
...
@@ -575,7 +617,7 @@ const loadUsageStats = async () => {
}
const
applyFilters
=
()
=>
{
pagination
.
value
.
page
=
1
pagination
.
page
=
1
loadUsageLogs
()
loadUsageStats
()
}
...
...
@@ -588,60 +630,128 @@ const resetFilters = () => {
}
// Reset date range to default (last 7 days)
initializeDateRange
()
pagination
.
value
.
page
=
1
pagination
.
page
=
1
loadUsageLogs
()
loadUsageStats
()
}
const
handlePageChange
=
(
page
:
number
)
=>
{
pagination
.
value
.
page
=
page
pagination
.
page
=
page
loadUsageLogs
()
}
const
exportToCSV
=
()
=>
{
if
(
usageLogs
.
value
.
length
===
0
)
{
const
handlePageSizeChange
=
(
pageSize
:
number
)
=>
{
pagination
.
page_size
=
pageSize
pagination
.
page
=
1
loadUsageLogs
()
}
/**
* Escape CSV value to prevent injection and handle special characters
*/
const
escapeCSVValue
=
(
value
:
unknown
):
string
=>
{
if
(
value
==
null
)
return
''
const
str
=
String
(
value
)
const
escaped
=
str
.
replace
(
/"/g
,
'
""
'
)
// Prevent formula injection by prefixing dangerous characters with single quote
if
(
/^
[
=+
\-
@
\t\r]
/
.
test
(
str
))
{
return
`"\'
${
escaped
}
"`
}
// Escape values containing comma, quote, or newline
if
(
/
[
,"
\n\r]
/
.
test
(
str
))
{
return
`"
${
escaped
}
"`
}
return
str
}
const
exportToCSV
=
async
()
=>
{
if
(
pagination
.
total
===
0
)
{
appStore
.
showWarning
(
t
(
'
usage.noDataToExport
'
))
return
}
const
headers
=
[
'
Model
'
,
'
Type
'
,
'
Input Tokens
'
,
'
Output Tokens
'
,
'
Cache Read Tokens
'
,
'
Cache Write Tokens
'
,
'
Total Cost
'
,
'
Billing Type
'
,
'
First Token (ms)
'
,
'
Duration (ms)
'
,
'
Time
'
]
const
rows
=
usageLogs
.
value
.
map
((
log
)
=>
[
log
.
model
,
log
.
stream
?
'
Stream
'
:
'
Sync
'
,
log
.
input_tokens
,
log
.
output_tokens
,
log
.
cache_read_tokens
,
log
.
cache_creation_tokens
,
log
.
total_cost
.
toFixed
(
6
),
log
.
billing_type
===
1
?
'
Subscription
'
:
'
Balance
'
,
log
.
first_token_ms
??
''
,
log
.
duration_ms
,
log
.
created_at
])
const
csvContent
=
[
headers
.
join
(
'
,
'
),
...
rows
.
map
((
row
)
=>
row
.
join
(
'
,
'
))].
join
(
'
\n
'
)
const
blob
=
new
Blob
([
csvContent
],
{
type
:
'
text/csv
'
})
const
url
=
window
.
URL
.
createObjectURL
(
blob
)
const
link
=
document
.
createElement
(
'
a
'
)
link
.
href
=
url
link
.
download
=
`usage_
${
new
Date
().
toISOString
().
split
(
'
T
'
)[
0
]}
.csv`
link
.
click
()
window
.
URL
.
revokeObjectURL
(
url
)
appStore
.
showSuccess
(
t
(
'
usage.exportSuccess
'
))
exporting
.
value
=
true
appStore
.
showInfo
(
t
(
'
usage.preparingExport
'
))
try
{
const
allLogs
:
UsageLog
[]
=
[]
const
pageSize
=
100
// Use a larger page size for export to reduce requests
const
totalRequests
=
Math
.
ceil
(
pagination
.
total
/
pageSize
)
for
(
let
page
=
1
;
page
<=
totalRequests
;
page
++
)
{
const
params
:
UsageQueryParams
=
{
page
:
page
,
page_size
:
pageSize
,
...
filters
.
value
}
const
response
=
await
usageAPI
.
query
(
params
)
allLogs
.
push
(...
response
.
items
)
}
if
(
allLogs
.
length
===
0
)
{
appStore
.
showWarning
(
t
(
'
usage.noDataToExport
'
))
return
}
const
headers
=
[
'
Time
'
,
'
API Key Name
'
,
'
Model
'
,
'
Type
'
,
'
Input Tokens
'
,
'
Output Tokens
'
,
'
Cache Read Tokens
'
,
'
Cache Creation Tokens
'
,
'
Rate Multiplier
'
,
'
Billed Cost
'
,
'
Original Cost
'
,
'
Billing Type
'
,
'
First Token (ms)
'
,
'
Duration (ms)
'
]
const
rows
=
allLogs
.
map
((
log
)
=>
[
log
.
created_at
,
log
.
api_key
?.
name
||
''
,
log
.
model
,
log
.
stream
?
'
Stream
'
:
'
Sync
'
,
log
.
input_tokens
,
log
.
output_tokens
,
log
.
cache_read_tokens
,
log
.
cache_creation_tokens
,
log
.
rate_multiplier
,
log
.
actual_cost
.
toFixed
(
8
),
log
.
total_cost
.
toFixed
(
8
),
log
.
billing_type
===
1
?
'
Subscription
'
:
'
Balance
'
,
log
.
first_token_ms
??
''
,
log
.
duration_ms
].
map
(
escapeCSVValue
)
)
const
csvContent
=
[
headers
.
map
(
escapeCSVValue
).
join
(
'
,
'
),
...
rows
.
map
((
row
)
=>
row
.
join
(
'
,
'
))
].
join
(
'
\n
'
)
const
blob
=
new
Blob
([
csvContent
],
{
type
:
'
text/csv;charset=utf-8;
'
})
const
url
=
window
.
URL
.
createObjectURL
(
blob
)
const
link
=
document
.
createElement
(
'
a
'
)
link
.
href
=
url
link
.
download
=
`usage_
${
filters
.
value
.
start_date
}
_to_
${
filters
.
value
.
end_date
}
.csv`
link
.
click
()
window
.
URL
.
revokeObjectURL
(
url
)
appStore
.
showSuccess
(
t
(
'
usage.exportSuccess
'
))
}
catch
(
error
)
{
appStore
.
showError
(
t
(
'
usage.exportFailed
'
))
console
.
error
(
'
CSV Export failed:
'
,
error
)
}
finally
{
exporting
.
value
=
false
}
}
// Tooltip functions
...
...
Prev
1
2
3
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