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
3c341947
Commit
3c341947
authored
Dec 29, 2025
by
yangjianbo
Browse files
Merge branch 'main' into test-dev
parents
3a7d3387
c01db6b1
Changes
78
Show whitespace changes
Inline
Side-by-side
frontend/src/style.css
View file @
3c341947
...
...
@@ -307,6 +307,35 @@
@apply
flex
items-center
justify-end
gap-3;
}
/* ============ Dialog ============ */
.dialog-overlay
{
@apply
fixed
inset-0
z-50;
@apply
bg-black/40
dark
:
bg-black
/
60
;
@apply
flex
items-center
justify-center
p-4;
}
.dialog-container
{
@apply
flex
w-full
flex-col;
@apply
max-h-[90vh];
@apply
rounded-2xl
bg-white
dark
:
bg-dark-800
;
@apply
shadow-xl;
}
.dialog-header
{
@apply
border-b
border-gray-100
px-6
py-4
dark
:
border-dark-700
;
@apply
flex
items-center
justify-between;
}
.dialog-body
{
@apply
overflow-y-auto
px-6
py-4;
}
.dialog-footer
{
@apply
border-t
border-gray-100
px-6
py-4
dark
:
border-dark-700
;
@apply
bg-gray-50/60
dark
:
bg-dark-900
/
40
;
@apply
flex
items-center
justify-end
gap-3;
}
/* ============ Toast 通知 ============ */
.toast
{
@apply
fixed
right-4
top-4
z-[100];
...
...
frontend/src/types/index.ts
View file @
3c341947
...
...
@@ -60,7 +60,11 @@ export interface PublicSettings {
export
interface
AuthResponse
{
access_token
:
string
token_type
:
string
user
:
User
user
:
User
&
{
run_mode
?:
'
standard
'
|
'
simple
'
}
}
export
interface
CurrentUserResponse
extends
User
{
run_mode
?:
'
standard
'
|
'
simple
'
}
// ==================== Subscription Types ====================
...
...
frontend/src/views/admin/AccountsView.vue
View file @
3c341947
...
...
@@ -165,7 +165,7 @@
<
/div
>
<
/div
>
<
DataTable
:
columns
=
"
columns
"
:
data
=
"
accounts
"
:
loading
=
"
loading
"
:
actions
-
count
=
"
6
"
>
<
DataTable
:
columns
=
"
columns
"
:
data
=
"
accounts
"
:
loading
=
"
loading
"
>
<
template
#
cell
-
select
=
"
{ row
}
"
>
<
input
type
=
"
checkbox
"
...
...
@@ -275,9 +275,9 @@
<
/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
"
...
...
@@ -297,6 +297,8 @@
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
common.edit
'
)
}}
<
/span
>
<
/button
>
<!--
Delete
Button
-->
<
button
@
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
"
...
...
@@ -317,131 +319,28 @@
<
span
class
=
"
text-xs
"
>
{{
t
(
'
common.delete
'
)
}}
<
/span
>
<
/button
>
<!--
次要操作
:
展开时显示
-->
<
template
v
-
if
=
"
expanded
"
>
<!--
Reset
Status
button
for
error
accounts
-->
<
button
v
-
if
=
"
row.status === 'error'
"
@
click
=
"
handleResetStatus(row)
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-red-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
=
"
M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3
"
/>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
admin.accounts.resetStatus
'
)
}}
<
/span
>
<
/button
>
<!--
Clear
Rate
Limit
button
-->
<
button
v
-
if
=
"
isRateLimited(row) || isOverloaded(row)
"
@
click
=
"
handleClearRateLimit(row)
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-amber-500 transition-colors hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-900/20 dark:hover:text-amber-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 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z
"
/>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
admin.accounts.clearRateLimit
'
)
}}
<
/span
>
<
/button
>
<!--
Test
Connection
button
-->
<
button
@
click
=
"
handleTest(row)
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-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.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z
"
/>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
admin.accounts.testConnection
'
)
}}
<
/span
>
<
/button
>
<!--
View
Stats
button
-->
<
button
@
click
=
"
handleViewStats(row)
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-indigo-50 hover:text-indigo-600 dark:hover:bg-indigo-900/20 dark:hover:text-indigo-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
=
"
M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z
"
/>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
admin.accounts.viewStats
'
)
}}
<
/span
>
<
/button
>
<!--
More
Actions
Menu
Trigger
-->
<
button
v
-
if
=
"
row.type === 'oauth' || row.type === 'setup-token'
"
@
click
=
"
handleReAuth(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
"
:
ref
=
"
(el) => setActionButtonRef(row.id, el)
"
@
click
=
"
openActionMenu(row)
"
class
=
"
action-menu-trigger flex flex-col items-center gap-0.5 rounded-lg p-1.5 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
"
fill
=
"
none
"
stroke
=
"
currentColor
"
viewBox
=
"
0 0 24 24
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244
"
/>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
admin.accounts.reAuthorize
'
)
}}
<
/span
>
<
/button
>
<
button
v
-
if
=
"
row.type === 'oauth' || row.type === 'setup-token'
"
@
click
=
"
handleRefreshToken(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
=
"
M
1
6.
023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a
8.
2
5
8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99
"
d
=
"
M6.
75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM12.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM1
8.
7
5
12a.75.75 0 11-1.5 0 .75.75 0 011.5 0z
"
/>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
admin.accounts.refreshToken
'
)
}}
<
/span
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
common.more
'
)
}}
<
/span
>
<
/button
>
<
/template
>
<
/div
>
<
/template
>
...
...
@@ -463,6 +362,7 @@
:
total
=
"
pagination.total
"
:
page
-
size
=
"
pagination.page_size
"
@
update
:
page
=
"
handlePageChange
"
@
update
:
pageSize
=
"
handlePageSizeChange
"
/>
<
/template
>
<
/TablePageLayout
>
...
...
@@ -537,13 +437,64 @@
@
close
=
"
showBulkEditModal = false
"
@
updated
=
"
handleBulkUpdated
"
/>
<!--
Action
Menu
(
Teleported
)
-->
<
Teleport
to
=
"
body
"
>
<
div
v
-
if
=
"
activeMenuId !== null && menuPosition
"
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 dark:ring-white/10
"
:
style
=
"
{ top: menuPosition.top + 'px', left: menuPosition.left + 'px'
}
"
>
<
div
class
=
"
py-1
"
>
<
template
v
-
for
=
"
account in accounts
"
:
key
=
"
account.id
"
>
<
template
v
-
if
=
"
account.id === activeMenuId
"
>
<
button
@
click
=
"
handleTest(account); 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-green-500
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
><
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z
"
/><
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M21 12a9 9 0 11-18 0 9 9 0 0118 0z
"
/><
/svg
>
{{
t
(
'
admin.accounts.testConnection
'
)
}}
<
/button
>
<
button
@
click
=
"
handleViewStats(account); 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-indigo-500
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
><
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z
"
/><
/svg
>
{{
t
(
'
admin.accounts.viewStats
'
)
}}
<
/button
>
<
template
v
-
if
=
"
account.type === 'oauth' || account.type === 'setup-token'
"
>
<
button
@
click
=
"
handleReAuth(account); 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-blue-500
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
><
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1
"
/><
/svg
>
{{
t
(
'
admin.accounts.reAuthorize
'
)
}}
<
/button
>
<
button
@
click
=
"
handleRefreshToken(account); 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-purple-500
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
><
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M4 4v5h5M20 20v-5h-5M4 4l16 16
"
/><
/svg
>
{{
t
(
'
admin.accounts.refreshToken
'
)
}}
<
/button
>
<
/template
>
<
div
v
-
if
=
"
account.status === 'error' || isRateLimited(account) || isOverloaded(account)
"
class
=
"
my-1 border-t border-gray-100 dark:border-dark-700
"
><
/div
>
<
button
v
-
if
=
"
account.status === 'error'
"
@
click
=
"
handleResetStatus(account); closeActionMenu()
"
class
=
"
flex w-full items-center gap-2 px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:text-yellow-400 dark:hover:bg-dark-700
"
>
<
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
=
"
M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z
"
/><
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M21 12a9 9 0 11-18 0 9 9 0 0118 0z
"
/><
/svg
>
{{
t
(
'
admin.accounts.resetStatus
'
)
}}
<
/button
>
<
button
v
-
if
=
"
isRateLimited(account) || isOverloaded(account)
"
@
click
=
"
handleClearRateLimit(account); closeActionMenu()
"
class
=
"
flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:text-amber-400 dark:hover:bg-dark-700
"
>
<
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
=
"
M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z
"
/><
/svg
>
{{
t
(
'
admin.accounts.clearRateLimit
'
)
}}
<
/button
>
<
/template
>
<
/template
>
<
/div
>
<
/div
>
<
/Teleport
>
<
/AppLayout
>
<
/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
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Account
,
Proxy
,
Group
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
...
...
@@ -572,22 +523,34 @@ import { formatRelativeTime } from '@/utils/format'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
authStore
=
useAuthStore
()
// Table columns
const
columns
=
computed
<
Column
[]
>
(()
=>
[
const
columns
=
computed
<
Column
[]
>
(()
=>
{
const
cols
:
Column
[]
=
[
{
key
:
'
select
'
,
label
:
''
,
sortable
:
false
}
,
{
key
:
'
name
'
,
label
:
t
(
'
admin.accounts.columns.name
'
),
sortable
:
true
}
,
{
key
:
'
platform_type
'
,
label
:
t
(
'
admin.accounts.columns.platformType
'
),
sortable
:
false
}
,
{
key
:
'
concurrency
'
,
label
:
t
(
'
admin.accounts.columns.concurrencyStatus
'
),
sortable
:
false
}
,
{
key
:
'
status
'
,
label
:
t
(
'
admin.accounts.columns.status
'
),
sortable
:
true
}
,
{
key
:
'
schedulable
'
,
label
:
t
(
'
admin.accounts.columns.schedulable
'
),
sortable
:
true
}
,
{
key
:
'
today_stats
'
,
label
:
t
(
'
admin.accounts.columns.todayStats
'
),
sortable
:
false
}
,
{
key
:
'
groups
'
,
label
:
t
(
'
admin.accounts.columns.groups
'
),
sortable
:
false
}
,
{
key
:
'
today_stats
'
,
label
:
t
(
'
admin.accounts.columns.todayStats
'
),
sortable
:
false
}
]
// 简易模式下不显示分组列
if
(
!
authStore
.
isSimpleMode
)
{
cols
.
push
({
key
:
'
groups
'
,
label
:
t
(
'
admin.accounts.columns.groups
'
),
sortable
:
false
}
)
}
cols
.
push
(
{
key
:
'
usage
'
,
label
:
t
(
'
admin.accounts.columns.usageWindows
'
),
sortable
:
false
}
,
{
key
:
'
priority
'
,
label
:
t
(
'
admin.accounts.columns.priority
'
),
sortable
:
true
}
,
{
key
:
'
last_used_at
'
,
label
:
t
(
'
admin.accounts.columns.lastUsed
'
),
sortable
:
true
}
,
{
key
:
'
actions
'
,
label
:
t
(
'
admin.accounts.columns.actions
'
),
sortable
:
false
}
])
)
return
cols
}
)
// Filter options
const
platformOptions
=
computed
(()
=>
[
...
...
@@ -628,6 +591,7 @@ const pagination = reactive({
total
:
0
,
pages
:
0
}
)
let
abortController
:
AbortController
|
null
=
null
// Modal states
const
showCreateModal
=
ref
(
false
)
...
...
@@ -647,6 +611,49 @@ const statsAccount = ref<Account | null>(null)
const
togglingSchedulable
=
ref
<
number
|
null
>
(
null
)
const
bulkDeleting
=
ref
(
false
)
// 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
=
(
accountId
:
number
,
el
:
Element
|
ComponentPublicInstance
|
null
)
=>
{
if
(
el
instanceof
HTMLElement
)
{
actionButtonRefs
.
value
.
set
(
accountId
,
el
)
}
else
{
actionButtonRefs
.
value
.
delete
(
accountId
)
}
}
const
openActionMenu
=
(
account
:
Account
)
=>
{
if
(
activeMenuId
.
value
===
account
.
id
)
{
closeActionMenu
()
}
else
{
const
buttonEl
=
actionButtonRefs
.
value
.
get
(
account
.
id
)
if
(
buttonEl
)
{
const
rect
=
buttonEl
.
getBoundingClientRect
()
// Position menu to the left of the button, slightly below
menuPosition
.
value
=
{
top
:
rect
.
bottom
+
4
,
left
:
rect
.
right
-
208
// w-52 is 208px
}
}
activeMenuId
.
value
=
account
.
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
()
}
}
// Bulk selection
const
selectedAccountIds
=
ref
<
number
[]
>
([])
const
selectCurrentPageAccounts
=
()
=>
{
...
...
@@ -668,6 +675,9 @@ const isOverloaded = (account: Account): boolean => {
// Data loading
const
loadAccounts
=
async
()
=>
{
abortController
?.
abort
()
const
currentAbortController
=
new
AbortController
()
abortController
=
currentAbortController
loading
.
value
=
true
try
{
const
response
=
await
adminAPI
.
accounts
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
...
...
@@ -675,16 +685,25 @@ const loadAccounts = async () => {
type
:
filters
.
type
||
undefined
,
status
:
filters
.
status
||
undefined
,
search
:
searchQuery
.
value
||
undefined
}
,
{
signal
:
currentAbortController
.
signal
}
)
if
(
currentAbortController
.
signal
.
aborted
)
return
accounts
.
value
=
response
.
items
pagination
.
total
=
response
.
total
pagination
.
pages
=
response
.
pages
}
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.accounts.failedToLoad
'
))
console
.
error
(
'
Error loading accounts:
'
,
error
)
}
finally
{
if
(
abortController
===
currentAbortController
)
{
loading
.
value
=
false
}
}
}
const
loadProxies
=
async
()
=>
{
...
...
@@ -720,6 +739,12 @@ const handlePageChange = (page: number) => {
loadAccounts
()
}
const
handlePageSizeChange
=
(
pageSize
:
number
)
=>
{
pagination
.
page_size
=
pageSize
pagination
.
page
=
1
loadAccounts
()
}
const
handleCrsSynced
=
()
=>
{
showCrsSyncModal
.
value
=
false
loadAccounts
()
...
...
@@ -909,5 +934,12 @@ onMounted(() => {
loadAccounts
()
loadProxies
()
loadGroups
()
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
}
)
onUnmounted
(()
=>
{
abortController
?.
abort
()
abortController
=
null
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
}
)
<
/script
>
frontend/src/views/admin/DashboardView.vue
View file @
3c341947
...
...
@@ -407,10 +407,20 @@ const trendData = ref<TrendDataPoint[]>([])
const
modelStats
=
ref
<
ModelStat
[]
>
([])
const
userTrend
=
ref
<
UserUsageTrendPoint
[]
>
([])
// Helper function to format date in local timezone
const
formatLocalDate
=
(
date
:
Date
):
string
=>
{
return
`
${
date
.
getFullYear
()}
-
${
String
(
date
.
getMonth
()
+
1
).
padStart
(
2
,
'
0
'
)}
-
${
String
(
date
.
getDate
()).
padStart
(
2
,
'
0
'
)}
`
}
// Initialize date range immediately
const
now
=
new
Date
()
const
weekAgo
=
new
Date
(
now
)
weekAgo
.
setDate
(
weekAgo
.
getDate
()
-
6
)
// Date range
const
granularity
=
ref
<
'
day
'
|
'
hour
'
>
(
'
day
'
)
const
startDate
=
ref
(
''
)
const
endDate
=
ref
(
''
)
const
startDate
=
ref
(
formatLocalDate
(
weekAgo
)
)
const
endDate
=
ref
(
formatLocalDate
(
now
)
)
// Granularity options for Select component
const
granularityOptions
=
computed
(()
=>
[
...
...
@@ -597,18 +607,6 @@ const onDateRangeChange = (range: {
loadChartData
()
}
// Initialize default date range
const
initializeDateRange
=
()
=>
{
const
now
=
new
Date
()
const
today
=
now
.
toISOString
().
split
(
'
T
'
)[
0
]
const
weekAgo
=
new
Date
(
now
)
weekAgo
.
setDate
(
weekAgo
.
getDate
()
-
6
)
startDate
.
value
=
weekAgo
.
toISOString
().
split
(
'
T
'
)[
0
]
endDate
.
value
=
today
granularity
.
value
=
'
day
'
}
// Load data
const
loadDashboardStats
=
async
()
=>
{
loading
.
value
=
true
...
...
@@ -649,7 +647,6 @@ const loadChartData = async () => {
onMounted
(()
=>
{
loadDashboardStats
()
initializeDateRange
()
loadChartData
()
})
</
script
>
...
...
frontend/src/views/admin/GroupsView.vue
View file @
3c341947
...
...
@@ -223,18 +223,19 @@
:
total
=
"
pagination.total
"
:
page
-
size
=
"
pagination.page_size
"
@
update
:
page
=
"
handlePageChange
"
@
update
:
pageSize
=
"
handlePageSizeChange
"
/>
<
/template
>
<
/TablePageLayout
>
<!--
Create
Group
Modal
-->
<
Modal
<
BaseDialog
:
show
=
"
showCreateModal
"
:
title
=
"
t('admin.groups.createGroup')
"
size
=
"
lg
"
width
=
"
normal
"
@
close
=
"
closeCreateModal
"
>
<
form
@
submit
.
prevent
=
"
handleCreateGroup
"
class
=
"
space-y-5
"
>
<
form
id
=
"
create-group-form
"
@
submit
.
prevent
=
"
handleCreateGroup
"
class
=
"
space-y-5
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.groups.form.name
'
)
}}
<
/label
>
<
input
...
...
@@ -271,7 +272,42 @@
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.groups.rateMultiplierHint
'
)
}}
<
/p
>
<
/div
>
<
div
v
-
if
=
"
createForm.subscription_type !== 'subscription'
"
class
=
"
flex items-center gap-3
"
>
<
div
v
-
if
=
"
createForm.subscription_type !== 'subscription'
"
>
<
div
class
=
"
mb-1.5 flex items-center gap-1
"
>
<
label
class
=
"
text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.groups.form.exclusive
'
)
}}
<
/label
>
<!--
Help
Tooltip
-->
<
div
class
=
"
group relative inline-flex
"
>
<
svg
class
=
"
h-3.5 w-3.5 cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
2
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z
"
/>
<
/svg
>
<!--
Tooltip
Popover
-->
<
div
class
=
"
pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100
"
>
<
div
class
=
"
rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800
"
>
<
p
class
=
"
mb-2 text-xs font-medium
"
>
{{
t
(
'
admin.groups.exclusiveTooltip.title
'
)
}}
<
/p
>
<
p
class
=
"
mb-2 text-xs leading-relaxed text-gray-300
"
>
{{
t
(
'
admin.groups.exclusiveTooltip.description
'
)
}}
<
/p
>
<
div
class
=
"
rounded bg-gray-800 p-2 dark:bg-gray-700
"
>
<
p
class
=
"
text-xs leading-relaxed text-gray-300
"
>
<
span
class
=
"
text-primary-400
"
>
💡
{{
t
(
'
admin.groups.exclusiveTooltip.example
'
)
}}
<
/span
>
{{
t
(
'
admin.groups.exclusiveTooltip.exampleContent
'
)
}}
<
/p
>
<
/div
>
<!--
Arrow
-->
<
div
class
=
"
absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800
"
><
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
div
class
=
"
flex items-center gap-3
"
>
<
button
type
=
"
button
"
@
click
=
"
createForm.is_exclusive = !createForm.is_exclusive
"
...
...
@@ -287,18 +323,15 @@
]
"
/>
<
/button
>
<
label
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.groups.exclusiveHint
'
)
}}
<
/label
>
<
span
class
=
"
text-sm text-gray-500 dark:text-gray-400
"
>
{{
createForm
.
is_exclusive
?
t
(
'
admin.groups.exclusive
'
)
:
t
(
'
admin.groups.public
'
)
}}
<
/span
>
<
/div
>
<
/div
>
<!--
Subscription
Configuration
-->
<
div
class
=
"
mt-4 border-t pt-4
"
>
<
h4
class
=
"
mb-4 text-sm font-medium text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.groups.subscription.title
'
)
}}
<
/h4
>
<
div
class
=
"
mb-4
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.groups.subscription.type
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
createForm.subscription_type
"
:
options
=
"
subscriptionTypeOptions
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.groups.subscription.typeHint
'
)
}}
<
/p
>
...
...
@@ -345,11 +378,19 @@
<
/div
>
<
/div
>
<
/form
>
<
template
#
footer
>
<
div
class
=
"
flex justify-end gap-3 pt-4
"
>
<
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-group-form
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
>
<
svg
v
-
if
=
"
submitting
"
class
=
"
-ml-1 mr-2 h-4 w-4 animate-spin
"
...
...
@@ -373,17 +414,22 @@
{{
submitting
?
t
(
'
admin.groups.creating
'
)
:
t
(
'
common.create
'
)
}}
<
/button
>
<
/div
>
<
/
form
>
<
/
Modal
>
<
/
template
>
<
/
BaseDialog
>
<!--
Edit
Group
Modal
-->
<
Modal
<
BaseDialog
:
show
=
"
showEditModal
"
:
title
=
"
t('admin.groups.editGroup')
"
size
=
"
lg
"
width
=
"
normal
"
@
close
=
"
closeEditModal
"
>
<
form
v
-
if
=
"
editingGroup
"
@
submit
.
prevent
=
"
handleUpdateGroup
"
class
=
"
space-y-5
"
>
<
form
v
-
if
=
"
editingGroup
"
id
=
"
edit-group-form
"
@
submit
.
prevent
=
"
handleUpdateGroup
"
class
=
"
space-y-5
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.groups.form.name
'
)
}}
<
/label
>
<
input
v
-
model
=
"
editForm.name
"
type
=
"
text
"
required
class
=
"
input
"
/>
...
...
@@ -408,7 +454,42 @@
class
=
"
input
"
/>
<
/div
>
<
div
v
-
if
=
"
editForm.subscription_type !== 'subscription'
"
class
=
"
flex items-center gap-3
"
>
<
div
v
-
if
=
"
editForm.subscription_type !== 'subscription'
"
>
<
div
class
=
"
mb-1.5 flex items-center gap-1
"
>
<
label
class
=
"
text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.groups.form.exclusive
'
)
}}
<
/label
>
<!--
Help
Tooltip
-->
<
div
class
=
"
group relative inline-flex
"
>
<
svg
class
=
"
h-3.5 w-3.5 cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
2
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z
"
/>
<
/svg
>
<!--
Tooltip
Popover
-->
<
div
class
=
"
pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100
"
>
<
div
class
=
"
rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800
"
>
<
p
class
=
"
mb-2 text-xs font-medium
"
>
{{
t
(
'
admin.groups.exclusiveTooltip.title
'
)
}}
<
/p
>
<
p
class
=
"
mb-2 text-xs leading-relaxed text-gray-300
"
>
{{
t
(
'
admin.groups.exclusiveTooltip.description
'
)
}}
<
/p
>
<
div
class
=
"
rounded bg-gray-800 p-2 dark:bg-gray-700
"
>
<
p
class
=
"
text-xs leading-relaxed text-gray-300
"
>
<
span
class
=
"
text-primary-400
"
>
💡
{{
t
(
'
admin.groups.exclusiveTooltip.example
'
)
}}
<
/span
>
{{
t
(
'
admin.groups.exclusiveTooltip.exampleContent
'
)
}}
<
/p
>
<
/div
>
<!--
Arrow
-->
<
div
class
=
"
absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800
"
><
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
div
class
=
"
flex items-center gap-3
"
>
<
button
type
=
"
button
"
@
click
=
"
editForm.is_exclusive = !editForm.is_exclusive
"
...
...
@@ -424,9 +505,10 @@
]
"
/>
<
/button
>
<
label
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.groups.exclusiveHint
'
)
}}
<
/label
>
<
span
class
=
"
text-sm text-gray-500 dark:text-gray-400
"
>
{{
editForm
.
is_exclusive
?
t
(
'
admin.groups.exclusive
'
)
:
t
(
'
admin.groups.public
'
)
}}
<
/span
>
<
/div
>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.groups.form.status
'
)
}}
<
/label
>
...
...
@@ -435,11 +517,7 @@
<!--
Subscription
Configuration
-->
<
div
class
=
"
mt-4 border-t pt-4
"
>
<
h4
class
=
"
mb-4 text-sm font-medium text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.groups.subscription.title
'
)
}}
<
/h4
>
<
div
class
=
"
mb-4
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.groups.subscription.type
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
editForm.subscription_type
"
...
...
@@ -490,11 +568,19 @@
<
/div
>
<
/div
>
<
/form
>
<
template
#
footer
>
<
div
class
=
"
flex justify-end gap-3 pt-4
"
>
<
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-group-form
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
>
<
svg
v
-
if
=
"
submitting
"
class
=
"
-ml-1 mr-2 h-4 w-4 animate-spin
"
...
...
@@ -518,8 +604,8 @@
{{
submitting
?
t
(
'
admin.groups.updating
'
)
:
t
(
'
common.update
'
)
}}
<
/button
>
<
/div
>
<
/
form
>
<
/
Modal
>
<
/
template
>
<
/
BaseDialog
>
<!--
Delete
Confirmation
Dialog
-->
<
ConfirmDialog
...
...
@@ -546,7 +632,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
'
...
...
@@ -616,6 +702,8 @@ const pagination = reactive({
pages
:
0
}
)
let
abortController
:
AbortController
|
null
=
null
const
showCreateModal
=
ref
(
false
)
const
showEditModal
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
...
...
@@ -660,22 +748,34 @@ const deleteConfirmMessage = computed(() => {
}
)
const
loadGroups
=
async
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
}
const
currentController
=
new
AbortController
()
abortController
=
currentController
const
{
signal
}
=
currentController
loading
.
value
=
true
try
{
const
response
=
await
adminAPI
.
groups
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
platform
:
(
filters
.
platform
as
GroupPlatform
)
||
undefined
,
status
:
filters
.
status
as
any
,
is_exclusive
:
filters
.
is_exclusive
?
filters
.
is_exclusive
===
'
true
'
:
undefined
}
)
}
,
{
signal
}
)
if
(
signal
.
aborted
)
return
groups
.
value
=
response
.
items
pagination
.
total
=
response
.
total
pagination
.
pages
=
response
.
pages
}
catch
(
error
)
{
}
catch
(
error
:
any
)
{
if
(
signal
.
aborted
||
error
?.
name
===
'
AbortError
'
||
error
?.
code
===
'
ERR_CANCELED
'
)
{
return
}
appStore
.
showError
(
t
(
'
admin.groups.failedToLoad
'
))
console
.
error
(
'
Error loading groups:
'
,
error
)
}
finally
{
if
(
abortController
===
currentController
&&
!
signal
.
aborted
)
{
loading
.
value
=
false
}
}
}
const
handlePageChange
=
(
page
:
number
)
=>
{
...
...
@@ -683,6 +783,12 @@ const handlePageChange = (page: number) => {
loadGroups
()
}
const
handlePageSizeChange
=
(
pageSize
:
number
)
=>
{
pagination
.
page_size
=
pageSize
pagination
.
page
=
1
loadGroups
()
}
const
closeCreateModal
=
()
=>
{
showCreateModal
.
value
=
false
createForm
.
name
=
''
...
...
frontend/src/views/admin/ProxiesView.vue
View file @
3c341947
...
...
@@ -209,15 +209,16 @@
:total=
"pagination.total"
:page-size=
"pagination.page_size"
@
update:page=
"handlePageChange"
@
update:pageSize=
"handlePageSizeChange"
/>
</
template
>
</TablePageLayout>
<!-- Create Proxy Modal -->
<
Modal
<
BaseDialog
:show=
"showCreateModal"
:title=
"t('admin.proxies.createProxy')"
size=
"lg
"
width=
"normal
"
@
close=
"closeCreateModal"
>
<!-- Tab Switch -->
...
...
@@ -271,7 +272,12 @@
</div>
<!-- Standard Add Form -->
<form
v-if=
"createMode === 'standard'"
@
submit.prevent=
"handleCreateProxy"
class=
"space-y-5"
>
<form
v-if=
"createMode === 'standard'"
id=
"create-proxy-form"
@
submit.prevent=
"handleCreateProxy"
class=
"space-y-5"
>
<div>
<label
class=
"input-label"
>
{{ t('admin.proxies.name') }}
</label>
<input
...
...
@@ -329,34 +335,6 @@
/>
</div>
<div
class=
"flex justify-end gap-3 pt-4"
>
<button
@
click=
"closeCreateModal"
type=
"button"
class=
"btn btn-secondary"
>
{{ t('common.cancel') }}
</button>
<button
type=
"submit"
:disabled=
"submitting"
class=
"btn btn-primary"
>
<svg
v-if=
"submitting"
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>
{{ submitting ? t('admin.proxies.creating') : t('common.create') }}
</button>
</div>
</form>
<!-- Batch Add Form -->
...
...
@@ -435,11 +413,44 @@
</div>
</div>
<div
class=
"flex justify-end gap-3 pt-4"
>
</div>
<
template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"closeCreateModal"
type=
"button"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
v-if=
"createMode === 'standard'"
type=
"submit"
form=
"create-proxy-form"
:disabled=
"submitting"
class=
"btn btn-primary"
>
<svg
v-if=
"submitting"
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>
{{
submitting
?
t
(
'
admin.proxies.creating
'
)
:
t
(
'
common.create
'
)
}}
</button>
<button
v-else
@
click=
"handleBatchCreate"
type=
"button"
:disabled=
"submitting || batchParseResult.valid === 0"
...
...
@@ -472,17 +483,22 @@
}}
<
/button
>
<
/div
>
</
div
>
</
Modal
>
<
/
template
>
<
/
BaseDialog
>
<!--
Edit
Proxy
Modal
-->
<
Modal
<
BaseDialog
:
show
=
"
showEditModal
"
:
title
=
"
t('admin.proxies.editProxy')
"
size=
"lg
"
width
=
"
normal
"
@
close
=
"
closeEditModal
"
>
<form
v-if=
"editingProxy"
@
submit.prevent=
"handleUpdateProxy"
class=
"space-y-5"
>
<
form
v
-
if
=
"
editingProxy
"
id
=
"
edit-proxy-form
"
@
submit
.
prevent
=
"
handleUpdateProxy
"
class
=
"
space-y-5
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.proxies.name
'
)
}}
<
/label
>
<
input
v
-
model
=
"
editForm.name
"
type
=
"
text
"
required
class
=
"
input
"
/>
...
...
@@ -526,11 +542,20 @@
<
Select
v
-
model
=
"
editForm.status
"
:
options
=
"
editStatusOptions
"
/>
<
/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
v
-
if
=
"
editingProxy
"
type
=
"
submit
"
form
=
"
edit-proxy-form
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
>
<
svg
v
-
if
=
"
submitting
"
class
=
"
-ml-1 mr-2 h-4 w-4 animate-spin
"
...
...
@@ -554,8 +579,8 @@
{{
submitting
?
t
(
'
admin.proxies.updating
'
)
:
t
(
'
common.update
'
)
}}
<
/button
>
<
/div
>
</
form
>
</
Modal
>
<
/
template
>
<
/
BaseDialog
>
<!--
Delete
Confirmation
Dialog
-->
<
ConfirmDialog
...
...
@@ -582,7 +607,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
'
...
...
@@ -682,22 +707,44 @@ const editForm = reactive({
status
:
'
active
'
as
'
active
'
|
'
inactive
'
}
)
let
abortController
:
AbortController
|
null
=
null
const
isAbortError
=
(
error
:
unknown
)
=>
{
if
(
!
error
||
typeof
error
!==
'
object
'
)
return
false
const
maybeError
=
error
as
{
name
?:
string
;
code
?:
string
}
return
maybeError
.
name
===
'
AbortError
'
||
maybeError
.
code
===
'
ERR_CANCELED
'
}
const
loadProxies
=
async
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
}
const
currentAbortController
=
new
AbortController
()
abortController
=
currentAbortController
loading
.
value
=
true
try
{
const
response
=
await
adminAPI
.
proxies
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
protocol
:
filters
.
protocol
||
undefined
,
status
:
filters
.
status
as
any
,
search
:
searchQuery
.
value
||
undefined
})
}
,
{
signal
:
currentAbortController
.
signal
}
)
if
(
currentAbortController
.
signal
.
aborted
||
abortController
!==
currentAbortController
)
{
return
}
proxies
.
value
=
response
.
items
pagination
.
total
=
response
.
total
pagination
.
pages
=
response
.
pages
}
catch
(
error
)
{
if
(
isAbortError
(
error
))
{
return
}
appStore
.
showError
(
t
(
'
admin.proxies.failedToLoad
'
))
console
.
error
(
'
Error loading proxies:
'
,
error
)
}
finally
{
if
(
abortController
===
currentAbortController
)
{
loading
.
value
=
false
abortController
=
null
}
}
}
...
...
@@ -715,6 +762,12 @@ const handlePageChange = (page: number) => {
loadProxies
()
}
const
handlePageSizeChange
=
(
pageSize
:
number
)
=>
{
pagination
.
page_size
=
pageSize
pagination
.
page
=
1
loadProxies
()
}
const
closeCreateModal
=
()
=>
{
showCreateModal
.
value
=
false
createMode
.
value
=
'
standard
'
...
...
frontend/src/views/admin/RedeemView.vue
View file @
3c341947
...
...
@@ -186,6 +186,7 @@
:
total
=
"
pagination.total
"
:
page
-
size
=
"
pagination.page_size
"
@
update
:
page
=
"
handlePageChange
"
@
update
:
pageSize
=
"
handlePageSizeChange
"
/>
<!--
Batch
Actions
-->
...
...
@@ -542,6 +543,8 @@ const pagination = reactive({
pages
:
0
}
)
let
abortController
:
AbortController
|
null
=
null
const
showDeleteDialog
=
ref
(
false
)
const
showDeleteUnusedDialog
=
ref
(
false
)
const
deletingCode
=
ref
<
RedeemCode
|
null
>
(
null
)
...
...
@@ -556,21 +559,46 @@ const generateForm = reactive({
}
)
const
loadCodes
=
async
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
}
const
currentController
=
new
AbortController
()
abortController
=
currentController
loading
.
value
=
true
try
{
const
response
=
await
adminAPI
.
redeem
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
const
response
=
await
adminAPI
.
redeem
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
type
:
filters
.
type
as
RedeemCodeType
,
status
:
filters
.
status
as
any
,
search
:
searchQuery
.
value
||
undefined
}
)
}
,
{
signal
:
currentController
.
signal
}
)
if
(
currentController
.
signal
.
aborted
)
{
return
}
codes
.
value
=
response
.
items
pagination
.
total
=
response
.
total
pagination
.
pages
=
response
.
pages
}
catch
(
error
)
{
}
catch
(
error
:
any
)
{
if
(
currentController
.
signal
.
aborted
||
error
?.
name
===
'
AbortError
'
||
error
?.
code
===
'
ERR_CANCELED
'
)
{
return
}
appStore
.
showError
(
t
(
'
admin.redeem.failedToLoad
'
))
console
.
error
(
'
Error loading redeem codes:
'
,
error
)
}
finally
{
if
(
abortController
===
currentController
&&
!
currentController
.
signal
.
aborted
)
{
loading
.
value
=
false
abortController
=
null
}
}
}
...
...
@@ -588,6 +616,12 @@ const handlePageChange = (page: number) => {
loadCodes
()
}
const
handlePageSizeChange
=
(
pageSize
:
number
)
=>
{
pagination
.
page_size
=
pageSize
pagination
.
page
=
1
loadCodes
()
}
const
handleGenerateCodes
=
async
()
=>
{
// 订阅类型必须选择分组
if
(
generateForm
.
type
===
'
subscription
'
&&
!
generateForm
.
group_id
)
{
...
...
frontend/src/views/admin/SubscriptionsView.vue
View file @
3c341947
...
...
@@ -316,18 +316,23 @@
:
total
=
"
pagination.total
"
:
page
-
size
=
"
pagination.page_size
"
@
update
:
page
=
"
handlePageChange
"
@
update
:
pageSize
=
"
handlePageSizeChange
"
/>
<
/template
>
<
/TablePageLayout
>
<!--
Assign
Subscription
Modal
-->
<
Modal
<
BaseDialog
:
show
=
"
showAssignModal
"
:
title
=
"
t('admin.subscriptions.assignSubscription')
"
size
=
"
lg
"
width
=
"
normal
"
@
close
=
"
closeAssignModal
"
>
<
form
@
submit
.
prevent
=
"
handleAssignSubscription
"
class
=
"
space-y-5
"
>
<
form
id
=
"
assign-subscription-form
"
@
submit
.
prevent
=
"
handleAssignSubscription
"
class
=
"
space-y-5
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.subscriptions.form.user
'
)
}}
<
/label
>
<
Select
...
...
@@ -351,12 +356,18 @@
<
input
v
-
model
.
number
=
"
assignForm.validity_days
"
type
=
"
number
"
min
=
"
1
"
class
=
"
input
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.subscriptions.validityHint
'
)
}}
<
/p
>
<
/div
>
<
div
class
=
"
flex justify-end gap-3 pt-4
"
>
<
/form
>
<
template
#
footer
>
<
div
class
=
"
flex justify-end gap-3
"
>
<
button
@
click
=
"
closeAssignModal
"
type
=
"
button
"
class
=
"
btn btn-secondary
"
>
{{
t
(
'
common.cancel
'
)
}}
<
/button
>
<
button
type
=
"
submit
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
>
<
button
type
=
"
submit
"
form
=
"
assign-subscription-form
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
>
<
svg
v
-
if
=
"
submitting
"
class
=
"
-ml-1 mr-2 h-4 w-4 animate-spin
"
...
...
@@ -380,18 +391,19 @@
{{
submitting
?
t
(
'
admin.subscriptions.assigning
'
)
:
t
(
'
admin.subscriptions.assign
'
)
}}
<
/button
>
<
/div
>
<
/
form
>
<
/
Modal
>
<
/
template
>
<
/
BaseDialog
>
<!--
Extend
Subscription
Modal
-->
<
Modal
<
BaseDialog
:
show
=
"
showExtendModal
"
:
title
=
"
t('admin.subscriptions.extendSubscription')
"
size
=
"
md
"
width
=
"
narrow
"
@
close
=
"
closeExtendModal
"
>
<
form
v
-
if
=
"
extendingSubscription
"
id
=
"
extend-subscription-form
"
@
submit
.
prevent
=
"
handleExtendSubscription
"
class
=
"
space-y-5
"
>
...
...
@@ -417,17 +429,23 @@
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.subscriptions.form.extendDays
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
extendForm.days
"
type
=
"
number
"
min
=
"
1
"
required
class
=
"
input
"
/>
<
/div
>
<
div
class
=
"
flex justify-end gap-3 pt-4
"
>
<
/form
>
<
template
#
footer
>
<
div
v
-
if
=
"
extendingSubscription
"
class
=
"
flex justify-end gap-3
"
>
<
button
@
click
=
"
closeExtendModal
"
type
=
"
button
"
class
=
"
btn btn-secondary
"
>
{{
t
(
'
common.cancel
'
)
}}
<
/button
>
<
button
type
=
"
submit
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
>
<
button
type
=
"
submit
"
form
=
"
extend-subscription-form
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
>
{{
submitting
?
t
(
'
admin.subscriptions.extending
'
)
:
t
(
'
admin.subscriptions.extend
'
)
}}
<
/button
>
<
/div
>
<
/
form
>
<
/
Modal
>
<
/
template
>
<
/
BaseDialog
>
<!--
Revoke
Confirmation
Dialog
-->
<
ConfirmDialog
...
...
@@ -455,7 +473,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
'
...
...
@@ -485,6 +503,7 @@ const subscriptions = ref<UserSubscription[]>([])
const
groups
=
ref
<
Group
[]
>
([])
const
users
=
ref
<
User
[]
>
([])
const
loading
=
ref
(
false
)
let
abortController
:
AbortController
|
null
=
null
const
filters
=
reactive
({
status
:
''
,
group_id
:
''
...
...
@@ -530,20 +549,36 @@ const subscriptionGroupOptions = computed(() =>
const
userOptions
=
computed
(()
=>
users
.
value
.
map
((
u
)
=>
({
value
:
u
.
id
,
label
:
u
.
email
}
)))
const
loadSubscriptions
=
async
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
}
const
requestController
=
new
AbortController
()
abortController
=
requestController
const
{
signal
}
=
requestController
loading
.
value
=
true
try
{
const
response
=
await
adminAPI
.
subscriptions
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
status
:
(
filters
.
status
as
any
)
||
undefined
,
group_id
:
filters
.
group_id
?
parseInt
(
filters
.
group_id
)
:
undefined
}
,
{
signal
}
)
if
(
signal
.
aborted
||
abortController
!==
requestController
)
return
subscriptions
.
value
=
response
.
items
pagination
.
total
=
response
.
total
pagination
.
pages
=
response
.
pages
}
catch
(
error
)
{
}
catch
(
error
:
any
)
{
if
(
signal
.
aborted
||
error
?.
name
===
'
AbortError
'
||
error
?.
code
===
'
ERR_CANCELED
'
)
{
return
}
appStore
.
showError
(
t
(
'
admin.subscriptions.failedToLoad
'
))
console
.
error
(
'
Error loading subscriptions:
'
,
error
)
}
finally
{
if
(
abortController
===
requestController
)
{
loading
.
value
=
false
abortController
=
null
}
}
}
...
...
@@ -569,6 +604,12 @@ const handlePageChange = (page: number) => {
loadSubscriptions
()
}
const
handlePageSizeChange
=
(
pageSize
:
number
)
=>
{
pagination
.
page_size
=
pageSize
pagination
.
page
=
1
loadSubscriptions
()
}
const
closeAssignModal
=
()
=>
{
showAssignModal
.
value
=
false
assignForm
.
user_id
=
null
...
...
frontend/src/views/admin/UsageView.vue
View file @
3c341947
...
...
@@ -224,7 +224,7 @@
v-model=
"filters.api_key_id"
:options=
"apiKeyOptions"
:placeholder=
"t('usage.allApiKeys')"
:disabled=
"!selectedUser && apiKeys.length === 0"
searchable
@
change=
"applyFilters"
/>
</div>
...
...
@@ -236,6 +236,7 @@
v-model=
"filters.model"
:options=
"modelOptions"
:placeholder=
"t('admin.usage.allModels')"
searchable
@
change=
"applyFilters"
/>
</div>
...
...
@@ -534,6 +535,7 @@
:total=
"pagination.total"
:page-size=
"pagination.page_size"
@
update:page=
"handlePageChange"
@
update:pageSize=
"handlePageSizeChange"
/>
</div>
</AppLayout>
...
...
@@ -666,6 +668,7 @@ const models = ref<string[]>([])
const
accounts
=
ref
<
any
[]
>
([])
const
groups
=
ref
<
any
[]
>
([])
const
loading
=
ref
(
false
)
let
abortController
:
AbortController
|
null
=
null
// User search state
const
userSearchKeyword
=
ref
(
''
)
...
...
@@ -675,7 +678,7 @@ const showUserDropdown = ref(false)
const
selectedUser
=
ref
<
SimpleUser
|
null
>
(
null
)
let
searchTimeout
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
// API Key options computed from
selected user's
keys
// API Key options computed from
loaded
keys
const
apiKeyOptions
=
computed
(()
=>
{
return
[
{
value
:
null
,
label
:
t
(
'
usage.allApiKeys
'
)
},
...
...
@@ -733,9 +736,19 @@ const groupOptions = computed(() => {
]
})
// Helper function to format date in local timezone
const
formatLocalDate
=
(
date
:
Date
):
string
=>
{
return
`
${
date
.
getFullYear
()}
-
${
String
(
date
.
getMonth
()
+
1
).
padStart
(
2
,
'
0
'
)}
-
${
String
(
date
.
getDate
()).
padStart
(
2
,
'
0
'
)}
`
}
// Initialize date range immediately
const
now
=
new
Date
()
const
weekAgo
=
new
Date
(
now
)
weekAgo
.
setDate
(
weekAgo
.
getDate
()
-
6
)
// Date range state
const
startDate
=
ref
(
''
)
const
endDate
=
ref
(
''
)
const
startDate
=
ref
(
formatLocalDate
(
weekAgo
)
)
const
endDate
=
ref
(
formatLocalDate
(
now
)
)
const
filters
=
ref
<
AdminUsageQueryParams
>
({
user_id
:
undefined
,
...
...
@@ -749,18 +762,9 @@ const filters = ref<AdminUsageQueryParams>({
end_date
:
undefined
})
// Initialize default date range (last 7 days)
const
initializeDateRange
=
()
=>
{
const
now
=
new
Date
()
const
today
=
now
.
toISOString
().
split
(
'
T
'
)[
0
]
const
weekAgo
=
new
Date
(
now
)
weekAgo
.
setDate
(
weekAgo
.
getDate
()
-
6
)
startDate
.
value
=
weekAgo
.
toISOString
().
split
(
'
T
'
)[
0
]
endDate
.
value
=
today
filters
.
value
.
start_date
=
startDate
.
value
filters
.
value
.
end_date
=
endDate
.
value
}
// Initialize filters with date range
filters
.
value
.
start_date
=
startDate
.
value
filters
.
value
.
end_date
=
endDate
.
value
// User search with debounce
const
debounceSearchUsers
=
()
=>
{
...
...
@@ -796,7 +800,7 @@ const selectUser = async (user: SimpleUser) => {
filters
.
value
.
api_key_id
=
undefined
// Load API keys for selected user
await
loadApiKeys
ForUser
(
user
.
id
)
await
loadApiKeys
(
user
.
id
)
applyFilters
()
}
...
...
@@ -807,10 +811,11 @@ const clearUserFilter = () => {
filters
.
value
.
user_id
=
undefined
filters
.
value
.
api_key_id
=
undefined
apiKeys
.
value
=
[]
loadApiKeys
()
applyFilters
()
}
const
loadApiKeys
ForUser
=
async
(
userId
:
number
)
=>
{
const
loadApiKeys
=
async
(
userId
?
:
number
)
=>
{
try
{
apiKeys
.
value
=
await
adminAPI
.
usage
.
searchApiKeys
(
userId
)
}
catch
(
error
)
{
...
...
@@ -863,7 +868,24 @@ const formatCacheTokens = (value: number): string => {
return
value
.
toLocaleString
()
}
const
isAbortError
=
(
error
:
unknown
):
boolean
=>
{
if
(
error
instanceof
DOMException
&&
error
.
name
===
'
AbortError
'
)
{
return
true
}
if
(
typeof
error
===
'
object
'
&&
error
!==
null
)
{
const
maybeError
=
error
as
{
code
?:
string
;
name
?:
string
}
return
maybeError
.
code
===
'
ERR_CANCELED
'
||
maybeError
.
name
===
'
CanceledError
'
}
return
false
}
const
loadUsageLogs
=
async
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
}
const
controller
=
new
AbortController
()
abortController
=
controller
const
{
signal
}
=
controller
loading
.
value
=
true
try
{
const
params
:
AdminUsageQueryParams
=
{
...
...
@@ -872,18 +894,24 @@ const loadUsageLogs = async () => {
...
filters
.
value
}
const
response
=
await
adminAPI
.
usage
.
list
(
params
)
const
response
=
await
adminAPI
.
usage
.
list
(
params
,
{
signal
})
if
(
signal
.
aborted
)
{
return
}
usageLogs
.
value
=
response
.
items
pagination
.
value
.
total
=
response
.
total
pagination
.
value
.
pages
=
response
.
pages
// Extract models from loaded logs for filter options
extractModelsFromLogs
()
}
catch
(
error
)
{
if
(
signal
.
aborted
||
isAbortError
(
error
))
{
return
}
appStore
.
showError
(
t
(
'
usage.failedToLoad
'
))
}
finally
{
if
(
!
signal
.
aborted
&&
abortController
===
controller
)
{
loading
.
value
=
false
}
}
}
const
loadUsageStats
=
async
()
=>
{
...
...
@@ -944,27 +972,40 @@ const applyFilters = () => {
// Load filter options
const
loadFilterOptions
=
async
()
=>
{
try
{
// Load accounts
const
accountsResponse
=
await
adminAPI
.
accounts
.
list
(
1
,
1000
)
const
[
accountsResponse
,
groupsResponse
]
=
await
Promise
.
all
([
adminAPI
.
accounts
.
list
(
1
,
1000
),
adminAPI
.
groups
.
list
(
1
,
1000
)
])
accounts
.
value
=
accountsResponse
.
items
||
[]
// Load groups
const
groupsResponse
=
await
adminAPI
.
groups
.
list
(
1
,
1000
)
groups
.
value
=
groupsResponse
.
items
||
[]
}
catch
(
error
)
{
console
.
error
(
'
Failed to load filter options:
'
,
error
)
}
await
loadModelOptions
()
}
// Extract unique models from usage logs
const
extractModelsFromLogs
=
()
=>
{
const
loadModelOptions
=
async
()
=>
{
try
{
const
endDate
=
new
Date
()
const
startDateRange
=
new
Date
(
endDate
)
startDateRange
.
setDate
(
startDateRange
.
getDate
()
-
29
)
// Use local timezone instead of UTC
const
endDateStr
=
`
${
endDate
.
getFullYear
()}
-
${
String
(
endDate
.
getMonth
()
+
1
).
padStart
(
2
,
'
0
'
)}
-
${
String
(
endDate
.
getDate
()).
padStart
(
2
,
'
0
'
)}
`
const
startDateStr
=
`
${
startDateRange
.
getFullYear
()}
-
${
String
(
startDateRange
.
getMonth
()
+
1
).
padStart
(
2
,
'
0
'
)}
-
${
String
(
startDateRange
.
getDate
()).
padStart
(
2
,
'
0
'
)}
`
const
response
=
await
adminAPI
.
dashboard
.
getModelStats
({
start_date
:
startDateStr
,
end_date
:
endDateStr
})
const
uniqueModels
=
new
Set
<
string
>
()
usageLogs
.
value
.
forEach
(
log
=>
{
if
(
log
.
model
)
{
uniqueModels
.
add
(
log
.
model
)
response
.
models
?
.
forEach
(
(
stat
)
=>
{
if
(
stat
.
model
)
{
uniqueModels
.
add
(
stat
.
model
)
}
})
models
.
value
=
Array
.
from
(
uniqueModels
).
sort
()
}
catch
(
error
)
{
console
.
error
(
'
Failed to load model options:
'
,
error
)
}
}
const
resetFilters
=
()
=>
{
...
...
@@ -985,8 +1026,15 @@ const resetFilters = () => {
}
granularity
.
value
=
'
day
'
// Reset date range to default (last 7 days)
initializeDateRange
()
const
now
=
new
Date
()
const
weekAgo
=
new
Date
(
now
)
weekAgo
.
setDate
(
weekAgo
.
getDate
()
-
6
)
startDate
.
value
=
formatLocalDate
(
weekAgo
)
endDate
.
value
=
formatLocalDate
(
now
)
filters
.
value
.
start_date
=
startDate
.
value
filters
.
value
.
end_date
=
endDate
.
value
pagination
.
value
.
page
=
1
loadApiKeys
()
loadUsageLogs
()
loadUsageStats
()
loadChartData
()
...
...
@@ -997,6 +1045,12 @@ const handlePageChange = (page: number) => {
loadUsageLogs
()
}
const
handlePageSizeChange
=
(
pageSize
:
number
)
=>
{
pagination
.
value
.
page_size
=
pageSize
pagination
.
value
.
page
=
1
loadUsageLogs
()
}
const
exportToCSV
=
()
=>
{
if
(
usageLogs
.
value
.
length
===
0
)
{
appStore
.
showWarning
(
t
(
'
usage.noDataToExport
'
))
...
...
@@ -1070,8 +1124,8 @@ const hideTooltip = () => {
}
onMounted
(()
=>
{
initializeDateRange
()
loadFilterOptions
()
loadApiKeys
()
loadUsageLogs
()
loadUsageStats
()
loadChartData
()
...
...
@@ -1083,5 +1137,8 @@ onUnmounted(() => {
if
(
searchTimeout
)
{
clearTimeout
(
searchTimeout
)
}
if
(
abortController
)
{
abortController
.
abort
()
}
})
</
script
>
frontend/src/views/admin/UsersView.vue
View file @
3c341947
...
...
@@ -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,179 +219,166 @@
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=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M
14.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 0
0-7
.5 0"
d=
"M
6.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM12.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM18.75 12a.75.75 0 11-1.5 0 .75.75
0 0
11
.5 0
z
"
/>
</svg>
<span
class=
"text-xs"
>
{{
t
(
'
common.delete
'
)
}}
</span>
</button>
</div>
</
template
>
<!-- 次要操作:展开时显示 -->
<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=
"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"
<
template
#empty
>
<EmptyState
:title=
"t('admin.users.noUsersYet')"
:description=
"t('admin.users.createFirstUser')"
:action-text=
"t('admin.users.createUser')"
@
action=
"showCreateModal = true"
/>
</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
"
</
template
>
<
/DataTable>
</template>
<!-- Pagination -->
<
template
#pagination
>
<Pagination
v-if=
"pagination.total > 0
"
:page=
"pagination.page"
: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>
<span
class=
"text-xs"
>
{{
row
.
status
===
'
active
'
?
t
(
'
admin.users.disable
'
)
:
t
(
'
admin.users.enable
'
)
}}
</span>
{{
t
(
'
admin.users.apiKeys
'
)
}}
</button>
<!-- Allowed Groups -->
<button
@
click=
"handleAllowedGroups(
row
)"
class=
"flex
flex-co
l items-center gap-
0.5 rounded-lg p-1.5
text-gray-
5
00
transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-4
00"
@
click=
"handleAllowedGroups(
user); closeActionMenu(
)"
class=
"flex
w-ful
l items-center gap-
2 px-4 py-2 text-sm
text-gray-
7
00
hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-7
00"
>
<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
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>
<span
class=
"text-xs"
>
{{
t
(
'
admin.users.groups
'
)
}}
</span>
{{
t
(
'
admin.users.groups
'
)
}}
</button>
<!-- View API Keys -->
<div
class=
"my-1 border-t border-gray-100 dark:border-dark-700"
></div>
<!-- Deposit -->
<button
@
click=
"handle
ViewApiKeys(row
)"
class=
"flex
flex-co
l items-center gap-
0.5 rounded-lg p-1.5
text-gray-
5
00
transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-4
00"
@
click=
"handle
Deposit(user); closeActionMenu(
)"
class=
"flex
w-ful
l items-center gap-
2 px-4 py-2 text-sm
text-gray-
7
00
hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-7
00"
>
<svg
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
<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"
>
<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
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>
<span
class=
"text-xs"
>
{{
t
(
'
admin.users.apiKeys
'
)
}}
</span>
{{
t
(
'
admin.users.withdraw
'
)
}}
</button>
<!-- Deposit -->
<div
class=
"my-1 border-t border-gray-100 dark:border-dark-700"
></div>
<!-- Toggle Status (not for admin) -->
<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"
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
class=
"h-4 w-4"
v-if=
"user.status === 'active'"
class=
"h-4 w-4 text-orange-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke
-width=
"1.5
"
stroke
=
"currentColor
"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 4.5v15m7.5-7.5h-15
"
/>
<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>
<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"
v-else
class=
"h-4 w-4 text-green-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke
-width=
"1.5
"
stroke
=
"currentColor
"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M
5
12
h14
"
/>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M
9
12
l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z
"
/>
</svg>
<span
class=
"text-xs"
>
{{
t
(
'
admin.users.withdraw
'
)
}}
</span>
{{
user
.
status
===
'
active
'
?
t
(
'
admin.users.disable
'
)
:
t
(
'
admin.users.enable
'
)
}}
</button>
</
template
>
</div>
</template>
<
template
#empty
>
<EmptyState
:title=
"t('admin.users.noUsersYet')"
:description=
"t('admin.users.createFirstUser')"
:action-text=
"t('admin.users.createUser')"
@
action=
"showCreateModal = true"
/>
</
template
>
</DataTable>
<!-- 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
>
<!-- Pagination -->
<
template
#pagination
>
<Pagination
v-if=
"pagination.total > 0"
:page=
"pagination.page"
:total=
"pagination.total"
:page-size=
"pagination.page_size"
@
update:page=
"handlePageChange"
/>
</template>
</TablePageLayout>
</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
,
{
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,17 +1433,29 @@ 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
{
if
(
abortController
===
currentAbortController
)
{
loading
.
value
=
false
}
}
}
let
searchTimeout
:
ReturnType
<
typeof
setTimeout
>
...
...
@@ -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 @
3c341947
...
...
@@ -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 @
3c341947
...
...
@@ -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 @
3c341947
...
...
@@ -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,29 +674,23 @@ 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
()
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
...
...
@@ -707,9 +701,8 @@ async function waitForServiceRestart() {
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 @
3c341947
...
...
@@ -10,7 +10,7 @@
<!-- Row 1: Core Stats -->
<div
class=
"grid grid-cols-2 gap-4 lg:grid-cols-4"
>
<!-- Balance -->
<div
class=
"card p-4"
>
<div
v-if=
"!authStore.isSimpleMode"
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30"
>
<svg
...
...
@@ -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,23 +708,39 @@ 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
[]
>
([])
// Helper function to format date in local timezone
const
formatLocalDate
=
(
date
:
Date
):
string
=>
{
return
`
${
date
.
getFullYear
()}
-
${
String
(
date
.
getMonth
()
+
1
).
padStart
(
2
,
'
0
'
)}
-
${
String
(
date
.
getDate
()).
padStart
(
2
,
'
0
'
)}
`
}
// Initialize date range immediately (not in onMounted)
const
now
=
new
Date
()
const
weekAgo
=
new
Date
(
now
)
weekAgo
.
setDate
(
weekAgo
.
getDate
()
-
6
)
// Date range
const
granularity
=
ref
<
'
day
'
|
'
hour
'
>
(
'
day
'
)
const
startDate
=
ref
(
''
)
const
endDate
=
ref
(
''
)
const
startDate
=
ref
(
formatLocalDate
(
weekAgo
)
)
const
endDate
=
ref
(
formatLocalDate
(
now
)
)
// Granularity options for Select component
const
granularityOptions
=
computed
(()
=>
[
...
...
@@ -938,18 +973,6 @@ const onDateRangeChange = (range: {
loadChartData
()
}
// Initialize default date range
const
initializeDateRange
=
()
=>
{
const
now
=
new
Date
()
const
today
=
now
.
toISOString
().
split
(
'
T
'
)[
0
]
const
weekAgo
=
new
Date
(
now
)
weekAgo
.
setDate
(
weekAgo
.
getDate
()
-
6
)
startDate
.
value
=
weekAgo
.
toISOString
().
split
(
'
T
'
)[
0
]
endDate
.
value
=
today
granularity
.
value
=
'
day
'
}
// Load data
const
loadDashboardStats
=
async
()
=>
{
loading
.
value
=
true
...
...
@@ -964,6 +987,7 @@ const loadDashboardStats = async () => {
}
const
loadChartData
=
async
()
=>
{
loadingCharts
.
value
=
true
try
{
const
params
=
{
start_date
:
startDate
.
value
,
...
...
@@ -981,19 +1005,19 @@ 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
{
// 后端 /usage 查询参数 start_date/end_date 仅接受 YYYY-MM-DD(见 backend usage handler 的校验逻辑)。
// 同时后端会将 end_date 自动扩展到当天 23:59:59.999...,因此前端只需要传「日期」即可。
// 注意:toISOString() 生成的是 UTC 日期字符串;如果需要按本地/服务端时区对齐统计口径,
// 请改用时区感知的日期格式化方法(例如 Intl.DateTimeFormat 指定 timeZone)。
// Use local timezone instead of UTC
const
now
=
new
Date
()
const
endDate
=
now
.
toISOString
().
split
(
'
T
'
)[
0
]
const
startDate
=
new
Date
(
Date
.
now
()
-
7
*
24
*
60
*
60
*
1000
).
toISOString
().
split
(
'
T
'
)[
0
]
const
endDate
=
`
${
now
.
getFullYear
()}
-
${
String
(
now
.
getMonth
()
+
1
).
padStart
(
2
,
'
0
'
)}
-
${
String
(
now
.
getDate
()).
padStart
(
2
,
'
0
'
)}
`
const
weekAgo
=
new
Date
(
Date
.
now
()
-
7
*
24
*
60
*
60
*
1000
)
const
startDate
=
`
${
weekAgo
.
getFullYear
()}
-
${
String
(
weekAgo
.
getMonth
()
+
1
).
padStart
(
2
,
'
0
'
)}
-
${
String
(
weekAgo
.
getDate
()).
padStart
(
2
,
'
0
'
)}
`
const
usageResponse
=
await
usageAPI
.
getByDateRange
(
startDate
,
endDate
)
recentUsage
.
value
=
usageResponse
.
items
.
slice
(
0
,
5
)
}
catch
(
error
)
{
...
...
@@ -1003,16 +1027,27 @@ const loadRecentUsage = async () => {
}
}
onMounted
(()
=>
{
loadDashboardStats
()
initializeDateRange
()
loadChartData
()
loadRecentUsage
()
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
)
})
// 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 @
3c341947
...
...
@@ -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,17 +656,25 @@ 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
)
{
if
(
!
isAbortError
(
e
))
{
console
.
error
(
'
Failed to load usage stats:
'
,
e
)
}
}
}
}
catch
(
error
)
{
if
(
isAbortError
(
error
))
{
return
}
appStore
.
showError
(
t
(
'
keys.failedToLoad
'
))
}
finally
{
if
(
abortController
===
controller
)
{
loading
.
value
=
false
}
}
}
const
loadGroups
=
async
()
=>
{
...
...
@@ -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 @
3c341947
...
...
@@ -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 @
3c341947
...
...
@@ -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 @
3c341947
...
...
@@ -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
[
...
...
@@ -464,9 +488,19 @@ const apiKeyOptions = computed(() => {
]
})
// Helper function to format date in local timezone
const
formatLocalDate
=
(
date
:
Date
):
string
=>
{
return
`
${
date
.
getFullYear
()}
-
${
String
(
date
.
getMonth
()
+
1
).
padStart
(
2
,
'
0
'
)}
-
${
String
(
date
.
getDate
()).
padStart
(
2
,
'
0
'
)}
`
}
// Initialize date range immediately
const
now
=
new
Date
()
const
weekAgo
=
new
Date
(
now
)
weekAgo
.
setDate
(
weekAgo
.
getDate
()
-
6
)
// Date range state
const
startDate
=
ref
(
''
)
const
endDate
=
ref
(
''
)
const
startDate
=
ref
(
formatLocalDate
(
weekAgo
)
)
const
endDate
=
ref
(
formatLocalDate
(
now
)
)
const
filters
=
ref
<
UsageQueryParams
>
({
api_key_id
:
undefined
,
...
...
@@ -474,18 +508,9 @@ const filters = ref<UsageQueryParams>({
end_date
:
undefined
})
// Initialize default date range (last 7 days)
const
initializeDateRange
=
()
=>
{
const
now
=
new
Date
()
const
today
=
now
.
toISOString
().
split
(
'
T
'
)[
0
]
const
weekAgo
=
new
Date
(
now
)
weekAgo
.
setDate
(
weekAgo
.
getDate
()
-
6
)
startDate
.
value
=
weekAgo
.
toISOString
().
split
(
'
T
'
)[
0
]
endDate
.
value
=
today
filters
.
value
.
start_date
=
startDate
.
value
filters
.
value
.
end_date
=
endDate
.
value
}
// Initialize filters with date range
filters
.
value
.
start_date
=
startDate
.
value
filters
.
value
.
end_date
=
endDate
.
value
// Handle date range change from DateRangePicker
const
onDateRangeChange
=
(
range
:
{
...
...
@@ -498,7 +523,7 @@ const onDateRangeChange = (range: {
applyFilters
()
}
const
pagination
=
re
f
({
const
pagination
=
re
active
({
page
:
1
,
page_size
:
20
,
total
:
0
,
...
...
@@ -532,23 +557,41 @@ 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
{
if
(
abortController
===
currentAbortController
)
{
loading
.
value
=
false
}
}
}
const
loadApiKeys
=
async
()
=>
{
...
...
@@ -575,7 +618,7 @@ const loadUsageStats = async () => {
}
const
applyFilters
=
()
=>
{
pagination
.
value
.
page
=
1
pagination
.
page
=
1
loadUsageLogs
()
loadUsageStats
()
}
...
...
@@ -587,61 +630,135 @@ const resetFilters = () => {
end_date
:
undefined
}
// Reset date range to default (last 7 days)
initializeDateRange
()
pagination
.
value
.
page
=
1
const
now
=
new
Date
()
const
weekAgo
=
new
Date
(
now
)
weekAgo
.
setDate
(
weekAgo
.
getDate
()
-
6
)
startDate
.
value
=
formatLocalDate
(
weekAgo
)
endDate
.
value
=
formatLocalDate
(
now
)
filters
.
value
.
start_date
=
startDate
.
value
filters
.
value
.
end_date
=
endDate
.
value
pagination
.
page
=
1
loadUsageLogs
()
loadUsageStats
()
}
const
handlePageChange
=
(
page
:
number
)
=>
{
pagination
.
value
.
page
=
page
pagination
.
page
=
page
loadUsageLogs
()
}
const
handlePageSizeChange
=
(
pageSize
:
number
)
=>
{
pagination
.
page_size
=
pageSize
pagination
.
page
=
1
loadUsageLogs
()
}
const
exportToCSV
=
()
=>
{
if
(
usageLogs
.
value
.
length
===
0
)
{
/**
* 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
}
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 Write Tokens
'
,
'
Total Cost
'
,
'
Cache Creation Tokens
'
,
'
Rate Multiplier
'
,
'
Billed Cost
'
,
'
Original Cost
'
,
'
Billing Type
'
,
'
First Token (ms)
'
,
'
Duration (ms)
'
,
'
Time
'
'
Duration (ms)
'
]
const
rows
=
usageLogs
.
value
.
map
((
log
)
=>
[
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
.
total_cost
.
toFixed
(
6
),
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
,
log
.
created_at
]
)
log
.
duration_ms
].
map
(
escapeCSVValue
)
)
const
csvContent
=
[
headers
.
join
(
'
,
'
),
...
rows
.
map
((
row
)
=>
row
.
join
(
'
,
'
))].
join
(
'
\n
'
)
const
csvContent
=
[
headers
.
map
(
escapeCSVValue
).
join
(
'
,
'
),
...
rows
.
map
((
row
)
=>
row
.
join
(
'
,
'
))
].
join
(
'
\n
'
)
const
blob
=
new
Blob
([
csvContent
],
{
type
:
'
text/csv
'
})
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_
${
new
Date
().
toISOString
().
split
(
'
T
'
)[
0
]
}
.csv`
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
...
...
@@ -662,7 +779,6 @@ const hideTooltip = () => {
}
onMounted
(()
=>
{
initializeDateRange
()
loadApiKeys
()
loadUsageLogs
()
loadUsageStats
()
...
...
Prev
1
2
3
4
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