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
Expand all
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
This diff is collapsed.
Click to expand it.
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
This diff is collapsed.
Click to expand it.
frontend/src/views/user/ProfileView.vue
View file @
3c341947
This diff is collapsed.
Click to expand it.
frontend/src/views/user/RedeemView.vue
View file @
3c341947
This diff is collapsed.
Click to expand it.
frontend/src/views/user/UsageView.vue
View file @
3c341947
This diff is collapsed.
Click to expand it.
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