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
fb313356
Commit
fb313356
authored
Jan 05, 2026
by
yangjianbo
Browse files
Merge branch 'main' into test-dev
parents
048ed061
91f9d4c7
Changes
84
Hide whitespace changes
Inline
Side-by-side
frontend/src/components/user/profile/ProfileInfoCard.vue
0 → 100644
View file @
fb313356
<
template
>
<div
class=
"card overflow-hidden"
>
<div
class=
"border-b border-gray-100 bg-gradient-to-r from-primary-500/10 to-primary-600/5 px-6 py-5 dark:border-dark-700 dark:from-primary-500/20 dark:to-primary-600/10"
>
<div
class=
"flex items-center gap-4"
>
<!-- Avatar -->
<div
class=
"flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-500 to-primary-600 text-2xl font-bold text-white shadow-lg shadow-primary-500/20"
>
{{
user
?.
email
?.
charAt
(
0
).
toUpperCase
()
||
'
U
'
}}
</div>
<div
class=
"min-w-0 flex-1"
>
<h2
class=
"truncate text-lg font-semibold text-gray-900 dark:text-white"
>
{{
user
?.
email
}}
</h2>
<div
class=
"mt-1 flex items-center gap-2"
>
<span
:class=
"['badge', user?.role === 'admin' ? 'badge-primary' : 'badge-gray']"
>
{{
user
?.
role
===
'
admin
'
?
t
(
'
profile.administrator
'
)
:
t
(
'
profile.user
'
)
}}
</span>
<span
:class=
"['badge', user?.status === 'active' ? 'badge-success' : 'badge-danger']"
>
{{
user
?.
status
}}
</span>
</div>
</div>
</div>
</div>
<div
class=
"px-6 py-4"
>
<div
class=
"space-y-3"
>
<div
class=
"flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400"
>
<svg
class=
"h-4 w-4 text-gray-400 dark:text-gray-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
<span
class=
"truncate"
>
{{
user
?.
email
}}
</span>
</div>
<div
v-if=
"user?.username"
class=
"flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400"
>
<svg
class=
"h-4 w-4 text-gray-400 dark:text-gray-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
</svg>
<span
class=
"truncate"
>
{{
user
.
username
}}
</span>
</div>
</div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
useI18n
}
from
'
vue-i18n
'
import
type
{
User
}
from
'
@/types
'
defineProps
<
{
user
:
User
|
null
}
>
()
const
{
t
}
=
useI18n
()
</
script
>
frontend/src/components/user/profile/ProfilePasswordForm.vue
0 → 100644
View file @
fb313356
<
template
>
<div
class=
"card"
>
<div
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h2
class=
"text-lg font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
profile.changePassword
'
)
}}
</h2>
</div>
<div
class=
"px-6 py-6"
>
<form
@
submit.prevent=
"handleChangePassword"
class=
"space-y-4"
>
<div>
<label
for=
"old_password"
class=
"input-label"
>
{{
t
(
'
profile.currentPassword
'
)
}}
</label>
<input
id=
"old_password"
v-model=
"form.old_password"
type=
"password"
required
autocomplete=
"current-password"
class=
"input"
/>
</div>
<div>
<label
for=
"new_password"
class=
"input-label"
>
{{
t
(
'
profile.newPassword
'
)
}}
</label>
<input
id=
"new_password"
v-model=
"form.new_password"
type=
"password"
required
autocomplete=
"new-password"
class=
"input"
/>
<p
class=
"input-hint"
>
{{
t
(
'
profile.passwordHint
'
)
}}
</p>
</div>
<div>
<label
for=
"confirm_password"
class=
"input-label"
>
{{
t
(
'
profile.confirmNewPassword
'
)
}}
</label>
<input
id=
"confirm_password"
v-model=
"form.confirm_password"
type=
"password"
required
autocomplete=
"new-password"
class=
"input"
/>
<p
v-if=
"form.new_password && form.confirm_password && form.new_password !== form.confirm_password"
class=
"input-error-text"
>
{{
t
(
'
profile.passwordsNotMatch
'
)
}}
</p>
</div>
<div
class=
"flex justify-end pt-4"
>
<button
type=
"submit"
:disabled=
"loading"
class=
"btn btn-primary"
>
{{
loading
?
t
(
'
profile.changingPassword
'
)
:
t
(
'
profile.changePasswordButton
'
)
}}
</button>
</div>
</form>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
userAPI
}
from
'
@/api
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
loading
=
ref
(
false
)
const
form
=
ref
({
old_password
:
''
,
new_password
:
''
,
confirm_password
:
''
})
const
handleChangePassword
=
async
()
=>
{
if
(
form
.
value
.
new_password
!==
form
.
value
.
confirm_password
)
{
appStore
.
showError
(
t
(
'
profile.passwordsNotMatch
'
))
return
}
if
(
form
.
value
.
new_password
.
length
<
8
)
{
appStore
.
showError
(
t
(
'
profile.passwordTooShort
'
))
return
}
loading
.
value
=
true
try
{
await
userAPI
.
changePassword
(
form
.
value
.
old_password
,
form
.
value
.
new_password
)
form
.
value
=
{
old_password
:
''
,
new_password
:
''
,
confirm_password
:
''
}
appStore
.
showSuccess
(
t
(
'
profile.passwordChangeSuccess
'
))
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
profile.passwordChangeFailed
'
))
}
finally
{
loading
.
value
=
false
}
}
</
script
>
frontend/src/composables/useClipboard.ts
View file @
fb313356
import
{
ref
}
from
'
vue
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
i18n
}
from
'
@/i18n
'
const
{
t
}
=
i18n
.
global
/**
* 检测是否支持 Clipboard API(需要安全上下文:HTTPS/localhost)
...
...
@@ -31,7 +34,7 @@ export function useClipboard() {
const
copyToClipboard
=
async
(
text
:
string
,
successMessage
=
'
Copied to clipboard
'
successMessage
?:
string
):
Promise
<
boolean
>
=>
{
if
(
!
text
)
return
false
...
...
@@ -50,12 +53,12 @@ export function useClipboard() {
if
(
success
)
{
copied
.
value
=
true
appStore
.
showSuccess
(
successMessage
)
appStore
.
showSuccess
(
successMessage
||
t
(
'
common.copiedToClipboard
'
)
)
setTimeout
(()
=>
{
copied
.
value
=
false
},
2000
)
}
else
{
appStore
.
showError
(
'
C
opy
f
ailed
'
)
appStore
.
showError
(
t
(
'
common.c
opy
F
ailed
'
)
)
}
return
success
...
...
frontend/src/composables/useForm.ts
0 → 100644
View file @
fb313356
import
{
ref
}
from
'
vue
'
import
{
useAppStore
}
from
'
@/stores/app
'
interface
UseFormOptions
<
T
>
{
form
:
T
submitFn
:
(
data
:
T
)
=>
Promise
<
void
>
successMsg
?:
string
errorMsg
?:
string
}
/**
* 统一表单提交逻辑
* 管理加载状态、错误捕获及通知
*/
export
function
useForm
<
T
>
(
options
:
UseFormOptions
<
T
>
)
{
const
{
form
,
submitFn
,
successMsg
,
errorMsg
}
=
options
const
loading
=
ref
(
false
)
const
appStore
=
useAppStore
()
const
submit
=
async
()
=>
{
if
(
loading
.
value
)
return
loading
.
value
=
true
try
{
await
submitFn
(
form
)
if
(
successMsg
)
{
appStore
.
showSuccess
(
successMsg
)
}
}
catch
(
error
:
any
)
{
const
detail
=
error
.
response
?.
data
?.
detail
||
error
.
response
?.
data
?.
message
||
error
.
message
appStore
.
showError
(
errorMsg
||
detail
)
// 继续抛出错误,让组件有机会进行局部处理(如验证错误显示)
throw
error
}
finally
{
loading
.
value
=
false
}
}
return
{
loading
,
submit
}
}
frontend/src/composables/useTableLoader.ts
0 → 100644
View file @
fb313356
import
{
ref
,
reactive
,
onUnmounted
,
toRaw
}
from
'
vue
'
import
{
useDebounceFn
}
from
'
@vueuse/core
'
import
type
{
BasePaginationResponse
,
FetchOptions
}
from
'
@/types
'
interface
PaginationState
{
page
:
number
page_size
:
number
total
:
number
pages
:
number
}
interface
TableLoaderOptions
<
T
,
P
>
{
fetchFn
:
(
page
:
number
,
pageSize
:
number
,
params
:
P
,
options
?:
FetchOptions
)
=>
Promise
<
BasePaginationResponse
<
T
>>
initialParams
?:
P
pageSize
?:
number
debounceMs
?:
number
}
/**
* 通用表格数据加载 Composable
* 统一处理分页、筛选、搜索防抖和请求取消
*/
export
function
useTableLoader
<
T
,
P
extends
Record
<
string
,
any
>>
(
options
:
TableLoaderOptions
<
T
,
P
>
)
{
const
{
fetchFn
,
initialParams
,
pageSize
=
20
,
debounceMs
=
300
}
=
options
const
items
=
ref
<
T
[]
>
([])
const
loading
=
ref
(
false
)
const
params
=
reactive
<
P
>
({
...(
initialParams
||
{})
}
as
P
)
const
pagination
=
reactive
<
PaginationState
>
({
page
:
1
,
page_size
:
pageSize
,
total
:
0
,
pages
:
0
})
let
abortController
:
AbortController
|
null
=
null
const
isAbortError
=
(
error
:
any
)
=>
{
return
error
?.
name
===
'
AbortError
'
||
error
?.
code
===
'
ERR_CANCELED
'
||
error
?.
name
===
'
CanceledError
'
}
const
load
=
async
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
}
abortController
=
new
AbortController
()
loading
.
value
=
true
try
{
const
response
=
await
fetchFn
(
pagination
.
page
,
pagination
.
page_size
,
toRaw
(
params
)
as
P
,
{
signal
:
abortController
.
signal
}
)
items
.
value
=
response
.
items
||
[]
pagination
.
total
=
response
.
total
||
0
pagination
.
pages
=
response
.
pages
||
0
}
catch
(
error
)
{
if
(
!
isAbortError
(
error
))
{
console
.
error
(
'
Table load error:
'
,
error
)
throw
error
}
}
finally
{
if
(
abortController
&&
!
abortController
.
signal
.
aborted
)
{
loading
.
value
=
false
}
}
}
const
reload
=
()
=>
{
pagination
.
page
=
1
return
load
()
}
const
debouncedReload
=
useDebounceFn
(
reload
,
debounceMs
)
const
handlePageChange
=
(
page
:
number
)
=>
{
pagination
.
page
=
page
load
()
}
const
handlePageSizeChange
=
(
size
:
number
)
=>
{
pagination
.
page_size
=
size
pagination
.
page
=
1
load
()
}
onUnmounted
(()
=>
{
abortController
?.
abort
()
})
return
{
items
,
loading
,
params
,
pagination
,
load
,
reload
,
debouncedReload
,
handlePageChange
,
handlePageSizeChange
}
}
frontend/src/i18n/locales/en.ts
View file @
fb313356
...
...
@@ -47,6 +47,7 @@ export default {
description
:
'
Configure your Sub2API instance
'
,
database
:
{
title
:
'
Database Configuration
'
,
description
:
'
Connect to your PostgreSQL database
'
,
host
:
'
Host
'
,
port
:
'
Port
'
,
username
:
'
Username
'
,
...
...
@@ -63,6 +64,7 @@ export default {
},
redis
:
{
title
:
'
Redis Configuration
'
,
description
:
'
Connect to your Redis server
'
,
host
:
'
Host
'
,
port
:
'
Port
'
,
password
:
'
Password (optional)
'
,
...
...
@@ -71,6 +73,7 @@ export default {
},
admin
:
{
title
:
'
Admin Account
'
,
description
:
'
Create your administrator account
'
,
email
:
'
Email
'
,
password
:
'
Password
'
,
confirmPassword
:
'
Confirm Password
'
,
...
...
@@ -80,9 +83,21 @@ export default {
},
ready
:
{
title
:
'
Ready to Install
'
,
description
:
'
Review your configuration and complete setup
'
,
database
:
'
Database
'
,
redis
:
'
Redis
'
,
adminEmail
:
'
Admin Email
'
},
status
:
{
testing
:
'
Testing...
'
,
success
:
'
Connection Successful
'
,
testConnection
:
'
Test Connection
'
,
installing
:
'
Installing...
'
,
completeInstallation
:
'
Complete Installation
'
,
completed
:
'
Installation completed!
'
,
redirecting
:
'
Redirecting to login page...
'
,
restarting
:
'
Service is restarting, please wait...
'
,
timeout
:
'
Service restart is taking longer than expected. Please refresh the page manually.
'
}
},
...
...
@@ -130,11 +145,13 @@ export default {
copiedToClipboard
:
'
Copied to clipboard
'
,
copyFailed
:
'
Failed to copy
'
,
contactSupport
:
'
Contact Support
'
,
selectOption
:
'
Select an option
'
,
searchPlaceholder
:
'
Search...
'
,
noOptionsFound
:
'
No options found
'
,
saving
:
'
Saving...
'
,
refresh
:
'
Refresh
'
,
selectOption
:
'
Select an option
'
,
searchPlaceholder
:
'
Search...
'
,
noOptionsFound
:
'
No options found
'
,
noGroupsAvailable
:
'
No groups available
'
,
unknownError
:
'
Unknown error occurred
'
,
saving
:
'
Saving...
'
,
selectedCount
:
'
({count} selected)
'
,
refresh
:
'
Refresh
'
,
notAvailable
:
'
N/A
'
,
now
:
'
Now
'
,
unknown
:
'
Unknown
'
,
...
...
@@ -673,6 +690,10 @@ export default {
failedToWithdraw
:
'
Failed to withdraw
'
,
useDepositWithdrawButtons
:
'
Please use deposit/withdraw buttons to adjust balance
'
,
insufficientBalance
:
'
Insufficient balance, balance cannot be negative after withdrawal
'
,
roles
:
{
admin
:
'
Admin
'
,
user
:
'
User
'
},
// Settings Dropdowns
filterSettings
:
'
Filter Settings
'
,
columnSettings
:
'
Column Settings
'
,
...
...
@@ -739,6 +760,7 @@ export default {
groups
:
{
title
:
'
Group Management
'
,
description
:
'
Manage API key groups and rate multipliers
'
,
searchGroups
:
'
Search groups...
'
,
createGroup
:
'
Create Group
'
,
editGroup
:
'
Edit Group
'
,
deleteGroup
:
'
Delete Group
'
,
...
...
@@ -794,6 +816,13 @@ export default {
failedToCreate
:
'
Failed to create group
'
,
failedToUpdate
:
'
Failed to update group
'
,
failedToDelete
:
'
Failed to delete group
'
,
platforms
:
{
all
:
'
All Platforms
'
,
anthropic
:
'
Anthropic
'
,
openai
:
'
OpenAI
'
,
gemini
:
'
Gemini
'
,
antigravity
:
'
Antigravity
'
},
deleteConfirm
:
"
Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.
"
,
deleteConfirmSubscription
:
...
...
@@ -935,9 +964,16 @@ export default {
antigravityOauth
:
'
Antigravity OAuth
'
},
status
:
{
active
:
'
Active
'
,
inactive
:
'
Inactive
'
,
error
:
'
Error
'
,
cooldown
:
'
Cooldown
'
,
paused
:
'
Paused
'
,
limited
:
'
Limited
'
,
tempUnschedulable
:
'
Temp Unschedulable
'
tempUnschedulable
:
'
Temp Unschedulable
'
,
rateLimitedUntil
:
'
Rate limited until {time}
'
,
overloadedUntil
:
'
Overloaded until {time}
'
,
viewTempUnschedDetails
:
'
View temp unschedulable details
'
},
tempUnschedulable
:
{
title
:
'
Temp Unschedulable
'
,
...
...
@@ -1484,6 +1520,12 @@ export default {
searchProxies
:
'
Search proxies...
'
,
allProtocols
:
'
All Protocols
'
,
allStatus
:
'
All Status
'
,
protocols
:
{
http
:
'
HTTP
'
,
https
:
'
HTTPS
'
,
socks5
:
'
SOCKS5
'
,
socks5h
:
'
SOCKS5H (Remote DNS)
'
},
columns
:
{
name
:
'
Name
'
,
protocol
:
'
Protocol
'
,
...
...
@@ -1601,7 +1643,13 @@ export default {
selectGroupPlaceholder
:
'
Choose a subscription group
'
,
validityDays
:
'
Validity Days
'
,
groupRequired
:
'
Please select a subscription group
'
,
days
:
'
days
'
days
:
'
days
'
,
status
:
{
unused
:
'
Unused
'
,
used
:
'
Used
'
,
expired
:
'
Expired
'
,
disabled
:
'
Disabled
'
}
},
// Usage Records
...
...
@@ -1610,6 +1658,7 @@ export default {
description
:
'
View and manage all user usage records
'
,
userFilter
:
'
User
'
,
searchUserPlaceholder
:
'
Search user by email...
'
,
searchApiKeyPlaceholder
:
'
Search API key by name...
'
,
selectedUser
:
'
Selected
'
,
user
:
'
User
'
,
account
:
'
Account
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
fb313356
...
...
@@ -44,6 +44,7 @@ export default {
description
:
'
配置您的 Sub2API 实例
'
,
database
:
{
title
:
'
数据库配置
'
,
description
:
'
连接到您的 PostgreSQL 数据库
'
,
host
:
'
主机
'
,
port
:
'
端口
'
,
username
:
'
用户名
'
,
...
...
@@ -60,6 +61,7 @@ export default {
},
redis
:
{
title
:
'
Redis 配置
'
,
description
:
'
连接到您的 Redis 服务器
'
,
host
:
'
主机
'
,
port
:
'
端口
'
,
password
:
'
密码(可选)
'
,
...
...
@@ -68,6 +70,7 @@ export default {
},
admin
:
{
title
:
'
管理员账户
'
,
description
:
'
创建您的管理员账户
'
,
email
:
'
邮箱
'
,
password
:
'
密码
'
,
confirmPassword
:
'
确认密码
'
,
...
...
@@ -77,9 +80,21 @@ export default {
},
ready
:
{
title
:
'
准备安装
'
,
description
:
'
检查您的配置并完成安装
'
,
database
:
'
数据库
'
,
redis
:
'
Redis
'
,
adminEmail
:
'
管理员邮箱
'
},
status
:
{
testing
:
'
测试中...
'
,
success
:
'
连接成功
'
,
testConnection
:
'
测试连接
'
,
installing
:
'
安装中...
'
,
completeInstallation
:
'
完成安装
'
,
completed
:
'
安装完成!
'
,
redirecting
:
'
正在跳转到登录页面...
'
,
restarting
:
'
服务正在重启,请稍候...
'
,
timeout
:
'
服务重启时间超出预期,请手动刷新页面。
'
}
},
...
...
@@ -130,7 +145,10 @@ export default {
selectOption
:
'
请选择
'
,
searchPlaceholder
:
'
搜索...
'
,
noOptionsFound
:
'
无匹配选项
'
,
noGroupsAvailable
:
'
无可用分组
'
,
unknownError
:
'
发生未知错误
'
,
saving
:
'
保存中...
'
,
selectedCount
:
'
(已选 {count} 个)
'
,
refresh
:
'
刷新
'
,
notAvailable
:
'
不可用
'
,
now
:
'
现在
'
,
...
...
@@ -665,10 +683,6 @@ export default {
admin
:
'
管理员
'
,
user
:
'
用户
'
},
statuses
:
{
active
:
'
正常
'
,
banned
:
'
禁用
'
},
form
:
{
emailLabel
:
'
邮箱
'
,
emailPlaceholder
:
'
请输入邮箱
'
,
...
...
@@ -795,6 +809,7 @@ export default {
groups
:
{
title
:
'
分组管理
'
,
description
:
'
管理 API 密钥分组和费率配置
'
,
searchGroups
:
'
搜索分组...
'
,
createGroup
:
'
创建分组
'
,
editGroup
:
'
编辑分组
'
,
deleteGroup
:
'
删除分组
'
,
...
...
@@ -852,8 +867,10 @@ export default {
rateMultiplierHint
:
'
1.0 = 标准费率,0.5 = 半价,2.0 = 双倍
'
,
platforms
:
{
all
:
'
全部平台
'
,
claude
:
'
Claude
'
,
openai
:
'
OpenAI
'
anthropic
:
'
Anthropic
'
,
openai
:
'
OpenAI
'
,
gemini
:
'
Gemini
'
,
antigravity
:
'
Antigravity
'
},
saving
:
'
保存中...
'
,
noGroups
:
'
暂无分组
'
,
...
...
@@ -1054,16 +1071,17 @@ export default {
api_key
:
'
API Key
'
,
cookie
:
'
Cookie
'
},
status
es
:
{
status
:
{
active
:
'
正常
'
,
inactive
:
'
停用
'
,
error
:
'
错误
'
,
cooldown
:
'
冷却中
'
},
status
:
{
paused
:
'
已暂停
'
,
limited
:
'
受限
'
,
tempUnschedulable
:
'
临时不可调度
'
cooldown
:
'
冷却中
'
,
paused
:
'
暂停
'
,
limited
:
'
限流
'
,
tempUnschedulable
:
'
临时不可调度
'
,
rateLimitedUntil
:
'
限流中,重置时间:{time}
'
,
overloadedUntil
:
'
负载过重,重置时间:{time}
'
,
viewTempUnschedDetails
:
'
查看临时不可调度详情
'
},
tempUnschedulable
:
{
title
:
'
临时不可调度
'
,
...
...
@@ -1596,25 +1614,6 @@ export default {
deleteConfirmMessage
:
"
确定要删除代理 '{name}' 吗?
"
,
testProxy
:
'
测试代理
'
,
columns
:
{
name
:
'
名称
'
,
protocol
:
'
协议
'
,
address
:
'
地址
'
,
priority
:
'
优先级
'
,
status
:
'
状态
'
,
lastCheck
:
'
最近检测
'
,
actions
:
'
操作
'
},
protocols
:
{
http
:
'
HTTP
'
,
https
:
'
HTTPS
'
,
socks5
:
'
SOCKS5
'
},
statuses
:
{
active
:
'
正常
'
,
inactive
:
'
停用
'
,
error
:
'
错误
'
},
form
:
{
nameLabel
:
'
名称
'
,
namePlaceholder
:
'
请输入代理名称
'
,
protocolLabel
:
'
协议
'
,
...
...
@@ -1753,7 +1752,7 @@ export default {
validityDays
:
'
有效天数
'
,
groupRequired
:
'
请选择订阅分组
'
,
days
:
'
天
'
,
status
es
:
{
status
:
{
unused
:
'
未使用
'
,
used
:
'
已使用
'
,
expired
:
'
已过期
'
,
...
...
@@ -1805,6 +1804,7 @@ export default {
description
:
'
查看和管理所有用户的使用记录
'
,
userFilter
:
'
用户
'
,
searchUserPlaceholder
:
'
按邮箱搜索用户...
'
,
searchApiKeyPlaceholder
:
'
按名称搜索 API 密钥...
'
,
selectedUser
:
'
已选择
'
,
user
:
'
用户
'
,
account
:
'
账户
'
,
...
...
frontend/src/types/index.ts
View file @
fb313356
...
...
@@ -2,6 +2,26 @@
* Core Type Definitions for Sub2API Frontend
*/
// ==================== Common Types ====================
export
interface
SelectOption
{
value
:
string
|
number
|
boolean
|
null
label
:
string
[
key
:
string
]:
any
// Support extra properties for custom templates
}
export
interface
BasePaginationResponse
<
T
>
{
items
:
T
[]
total
:
number
page
:
number
page_size
:
number
pages
:
number
}
export
interface
FetchOptions
{
signal
?:
AbortSignal
}
// ==================== User & Auth Types ====================
export
interface
User
{
...
...
@@ -476,6 +496,7 @@ export interface UpdateAccountRequest {
proxy_id
?:
number
|
null
concurrency
?:
number
priority
?:
number
schedulable
?:
boolean
status
?:
'
active
'
|
'
inactive
'
group_ids
?:
number
[]
confirm_mixed_channel_risk
?:
boolean
...
...
@@ -826,6 +847,7 @@ export type UserAttributeType = 'text' | 'textarea' | 'number' | 'email' | 'url'
export
interface
UserAttributeOption
{
value
:
string
label
:
string
[
key
:
string
]:
unknown
}
export
interface
UserAttributeValidation
{
...
...
frontend/src/utils/format.ts
View file @
fb313356
...
...
@@ -3,7 +3,7 @@
* 参考 CRS 项目的 format.js 实现
*/
import
{
i18n
}
from
'
@/i18n
'
import
{
i18n
,
getLocale
}
from
'
@/i18n
'
/**
* 格式化相对时间
...
...
@@ -39,33 +39,39 @@ export function formatRelativeTime(date: string | Date | null | undefined): stri
export
function
formatNumber
(
num
:
number
|
null
|
undefined
):
string
{
if
(
num
===
null
||
num
===
undefined
)
return
'
0
'
const
locale
=
getLocale
()
const
absNum
=
Math
.
abs
(
num
)
if
(
absNum
>=
1
e9
)
{
return
(
num
/
1
e9
).
toFixed
(
2
)
+
'
B
'
}
else
if
(
absNum
>=
1
e6
)
{
return
(
num
/
1
e6
).
toFixed
(
2
)
+
'
M
'
}
else
if
(
absNum
>=
1
e3
)
{
return
(
num
/
1
e3
).
toFixed
(
1
)
+
'
K
'
}
// Use Intl.NumberFormat for compact notation if supported and needed
// Note: Compact notation in 'zh' uses '万/亿', which is appropriate for Chinese
const
formatter
=
new
Intl
.
NumberFormat
(
locale
,
{
notation
:
absNum
>=
10000
?
'
compact
'
:
'
standard
'
,
maximumFractionDigits
:
1
})
return
num
.
toLocaleString
(
)
return
formatter
.
format
(
num
)
}
/**
* 格式化货币金额
* @param amount 金额
* @returns 格式化后的字符串,如 "$1.25" 或 "$0.000123"
* @param currency 货币代码,默认 USD
* @returns 格式化后的字符串,如 "$1.25"
*/
export
function
formatCurrency
(
amount
:
number
|
null
|
undefined
):
string
{
export
function
formatCurrency
(
amount
:
number
|
null
|
undefined
,
currency
:
string
=
'
USD
'
):
string
{
if
(
amount
===
null
||
amount
===
undefined
)
return
'
$0.00
'
// 小于 0.01 时显示更多小数位
if
(
amount
>
0
&&
amount
<
0.01
)
{
return
'
$
'
+
amount
.
toFixed
(
6
)
}
const
locale
=
getLocale
()
return
'
$
'
+
amount
.
toFixed
(
2
)
// For very small amounts, show more decimals
const
fractionDigits
=
amount
>
0
&&
amount
<
0.01
?
6
:
2
return
new
Intl
.
NumberFormat
(
locale
,
{
style
:
'
currency
'
,
currency
:
currency
,
minimumFractionDigits
:
fractionDigits
,
maximumFractionDigits
:
fractionDigits
}).
format
(
amount
)
}
/**
...
...
@@ -89,57 +95,89 @@ export function formatBytes(bytes: number, decimals: number = 2): string {
/**
* 格式化日期
* @param date 日期字符串或 Date 对象
* @param
format 格式字符串,支持 YYYY, MM, DD, HH, mm, s
s
* @param
options Intl.DateTimeFormatOption
s
* @returns 格式化后的日期字符串
*/
export
function
formatDate
(
date
:
string
|
Date
|
null
|
undefined
,
format
:
string
=
'
YYYY-MM-DD HH:mm:ss
'
options
:
Intl
.
DateTimeFormatOptions
=
{
year
:
'
numeric
'
,
month
:
'
2-digit
'
,
day
:
'
2-digit
'
,
hour
:
'
2-digit
'
,
minute
:
'
2-digit
'
,
second
:
'
2-digit
'
,
hour12
:
false
}
):
string
{
if
(
!
date
)
return
''
const
d
=
new
Date
(
date
)
if
(
isNaN
(
d
.
getTime
()))
return
''
const
year
=
d
.
getFullYear
()
const
month
=
String
(
d
.
getMonth
()
+
1
).
padStart
(
2
,
'
0
'
)
const
day
=
String
(
d
.
getDate
()).
padStart
(
2
,
'
0
'
)
const
hours
=
String
(
d
.
getHours
()).
padStart
(
2
,
'
0
'
)
const
minutes
=
String
(
d
.
getMinutes
()).
padStart
(
2
,
'
0
'
)
const
seconds
=
String
(
d
.
getSeconds
()).
padStart
(
2
,
'
0
'
)
return
format
.
replace
(
'
YYYY
'
,
String
(
year
))
.
replace
(
'
MM
'
,
month
)
.
replace
(
'
DD
'
,
day
)
.
replace
(
'
HH
'
,
hours
)
.
replace
(
'
mm
'
,
minutes
)
.
replace
(
'
ss
'
,
seconds
)
const
locale
=
getLocale
()
return
new
Intl
.
DateTimeFormat
(
locale
,
options
).
format
(
d
)
}
/**
* 格式化日期(只显示日期部分)
* @param date 日期字符串或 Date 对象
* @returns 格式化后的日期字符串
,格式为 YYYY-MM-DD
* @returns 格式化后的日期字符串
*/
export
function
formatDateOnly
(
date
:
string
|
Date
|
null
|
undefined
):
string
{
return
formatDate
(
date
,
'
YYYY-MM-DD
'
)
return
formatDate
(
date
,
{
year
:
'
numeric
'
,
month
:
'
2-digit
'
,
day
:
'
2-digit
'
})
}
/**
* 格式化日期时间(完整格式)
* @param date 日期字符串或 Date 对象
* @returns 格式化后的日期时间字符串
,格式为 YYYY-MM-DD HH:mm:ss
* @returns 格式化后的日期时间字符串
*/
export
function
formatDateTime
(
date
:
string
|
Date
|
null
|
undefined
):
string
{
return
formatDate
(
date
,
'
YYYY-MM-DD HH:mm:ss
'
)
return
formatDate
(
date
)
}
/**
* 格式化时间(只显示时分)
* @param date 日期字符串或 Date 对象
* @returns 格式化后的时间字符串
,格式为 HH:mm
* @returns 格式化后的时间字符串
*/
export
function
formatTime
(
date
:
string
|
Date
|
null
|
undefined
):
string
{
return
formatDate
(
date
,
'
HH:mm
'
)
return
formatDate
(
date
,
{
hour
:
'
2-digit
'
,
minute
:
'
2-digit
'
,
hour12
:
false
})
}
/**
* 格式化数字(千分位分隔,不使用紧凑单位)
* @param num 数字
* @returns 格式化后的字符串,如 "12,345"
*/
export
function
formatNumberLocaleString
(
num
:
number
):
string
{
return
num
.
toLocaleString
()
}
/**
* 格式化金额(固定小数位,不带货币符号)
* @param amount 金额
* @param fractionDigits 小数位数,默认 4
* @returns 格式化后的字符串,如 "1.2345"
*/
export
function
formatCostFixed
(
amount
:
number
,
fractionDigits
:
number
=
4
):
string
{
return
amount
.
toFixed
(
fractionDigits
)
}
/**
* 格式化 token 数量(>=1000 显示为 K,保留 1 位小数)
* @param tokens token 数量
* @returns 格式化后的字符串,如 "950", "1.2K"
*/
export
function
formatTokensK
(
tokens
:
number
):
string
{
return
tokens
>=
1000
?
`
${(
tokens
/
1000
).
toFixed
(
1
)}
K`
:
tokens
.
toString
()
}
frontend/src/views/admin/AccountsView.vue
View file @
fb313356
<
template
>
<AppLayout>
<TablePageLayout>
<template
#actions
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"loadAccounts"
:disabled=
"loading"
class=
"btn btn-secondary"
:title=
"t('common.refresh')"
>
<svg
:class=
"['h-5 w-5', loading ? 'animate-spin' : '']"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M16.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.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
<button
@
click=
"showCrsSyncModal = true"
class=
"btn btn-secondary"
:title=
"t('admin.accounts.syncFromCrs')"
>
<svg
class=
"h-5 w-5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"
/>
</svg>
</button>
<button
@
click=
"showCreateModal = true"
class=
"btn btn-primary"
data-tour=
"accounts-create-btn"
>
<svg
class=
"mr-2 h-5 w-5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 4.5v15m7.5-7.5h-15"
/>
</svg>
{{
t
(
'
admin.accounts.createAccount
'
)
}}
</button>
</div>
</
template
>
<template
#filters
>
<div
class=
"flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"
>
<div
class=
"relative max-w-md flex-1"
>
<svg
class=
"absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
<div
class=
"flex flex-wrap-reverse items-start justify-between gap-3"
>
<div
class=
"min-w-0 flex-1"
>
<AccountTableFilters
v-model:searchQuery=
"params.search"
:filters=
"params"
@
change=
"reload"
@
update:searchQuery=
"debouncedReload"
/>
</svg>
<input
v-model=
"searchQuery"
type=
"text"
:placeholder=
"t('admin.accounts.searchAccounts')"
class=
"input pl-10"
@
input=
"handleSearch"
/>
</div>
<div
class=
"flex flex-wrap gap-3"
>
<Select
v-model=
"filters.platform"
:options=
"platformOptions"
:placeholder=
"t('admin.accounts.allPlatforms')"
class=
"w-40"
@
change=
"loadAccounts"
/>
<Select
v-model=
"filters.type"
:options=
"typeOptions"
:placeholder=
"t('admin.accounts.allTypes')"
class=
"w-40"
@
change=
"loadAccounts"
/>
<Select
v-model=
"filters.status"
:options=
"statusOptions"
:placeholder=
"t('admin.accounts.allStatus')"
class=
"w-36"
@
change=
"loadAccounts"
/>
<div
class=
"flex-shrink-0"
>
<AccountTableActions
:loading=
"loading"
@
refresh=
"load"
@
sync=
"showSync = true"
@
create=
"showCreate = true"
/>
</div>
</div>
</
template
>
<
template
#table
>
<!-- Bulk Actions Bar -->
<div
v-if=
"selectedAccountIds.length > 0"
class=
"mb-[5px] mt-[10px] px-5 py-1"
>
<div
class=
"flex flex-wrap items-center justify-between gap-3"
>
<div
class=
"flex flex-wrap items-center gap-2"
>
<span
class=
"text-sm font-medium text-primary-900 dark:text-primary-100"
>
{{
t
(
'
admin.accounts.bulkActions.selected
'
,
{
count
:
selectedAccountIds
.
length
}
)
}}
<
/span
>
<
button
@
click
=
"
selectCurrentPageAccounts
"
class
=
"
text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200
"
>
{{
t
(
'
admin.accounts.bulkActions.selectCurrentPage
'
)
}}
<
/button
>
<
span
class
=
"
text-gray-300 dark:text-primary-800
"
>
•
<
/span
>
<
button
@
click
=
"
selectedAccountIds = []
"
class
=
"
text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200
"
>
{{
t
(
'
admin.accounts.bulkActions.clear
'
)
}}
<
/button
>
<
/div
>
<
div
class
=
"
flex items-center gap-2
"
>
<
button
@
click
=
"
handleBulkDelete
"
class
=
"
btn btn-danger btn-sm
"
>
<
svg
class
=
"
mr-1.5 h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0
"
/>
<
/svg
>
{{
t
(
'
admin.accounts.bulkActions.delete
'
)
}}
<
/button
>
<
button
@
click
=
"
showBulkEditModal = true
"
class
=
"
btn btn-primary btn-sm
"
>
<
svg
class
=
"
mr-1.5 h-4 w-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
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
>
{{
t
(
'
admin.accounts.bulkActions.edit
'
)
}}
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<
DataTable
:
columns
=
"
columns
"
:
data
=
"
accounts
"
:
loading
=
"
loading
"
>
<AccountBulkActionsBar
:selected-ids=
"selIds"
@
delete=
"handleBulkDelete"
@
edit=
"showBulkEdit = true"
@
clear=
"selIds = []"
@
select-page=
"selectPage"
/>
<DataTable
:columns=
"cols"
:data=
"accounts"
:loading=
"loading"
>
<template
#cell-select
="
{ row }">
<
input
type
=
"
checkbox
"
:
checked
=
"
selectedAccountIds.includes(row.id)
"
@
change
=
"
toggleAccountSelection(row.id)
"
class
=
"
rounded border-gray-300 text-primary-600 focus:ring-primary-500
"
/>
<input
type=
"checkbox"
:checked=
"selIds.includes(row.id)"
@
change=
"toggleSel(row.id)"
class=
"rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</
template
>
<
template
#cell-name=
"{ value }"
>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
value
}}
</span>
</
template
>
<
template
#cell-platform_type=
"{ row }"
>
<PlatformTypeBadge
:platform=
"row.platform"
:type=
"row.type"
/>
</
template
>
<
template
#cell-concurrency=
"{ row }"
>
<div
class=
"flex items-center gap-1.5"
>
<
span
:
class
=
"
[
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium',
(row.current_concurrency || 0) >= row.concurrency
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
: (row.current_concurrency || 0) > 0
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
]
"
>
<
svg
class
=
"
h-3 w-3
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
2
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
d
=
"
M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z
"
/>
<
/svg
>
<span
:class=
"['inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium', (row.current_concurrency || 0) >= row.concurrency ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' : (row.current_concurrency || 0) > 0 ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400']"
>
<svg
class=
"h-3 w-3"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z"
/></svg>
<span
class=
"font-mono"
>
{{
row
.
current_concurrency
||
0
}}
</span>
<span
class=
"text-gray-400 dark:text-gray-500"
>
/
</span>
<span
class=
"font-mono"
>
{{
row
.
concurrency
}}
</span>
</span>
</div>
</
template
>
<
template
#cell-status=
"{ row }"
>
<AccountStatusIndicator
:account=
"row"
@
show-temp-unsched=
"handleShowTempUnsched"
/>
</
template
>
<
template
#cell-schedulable=
"{ row }"
>
<
button
@
click
=
"
handleToggleSchedulable(row)
"
:
disabled
=
"
togglingSchedulable === row.id
"
class
=
"
relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-dark-800
"
:
class
=
"
[
row.schedulable
? 'bg-primary-500 hover:bg-primary-600'
: 'bg-gray-200 hover:bg-gray-300 dark:bg-dark-600 dark:hover:bg-dark-500'
]
"
:
title
=
"
row.schedulable
? t('admin.accounts.schedulableEnabled')
: t('admin.accounts.schedulableDisabled')
"
>
<
span
class
=
"
pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out
"
:
class
=
"
[row.schedulable ? 'translate-x-4' : 'translate-x-0']
"
/>
<button
@
click=
"handleToggleSchedulable(row)"
:disabled=
"togglingSchedulable === row.id"
class=
"relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-dark-800"
:class=
"[row.schedulable ? 'bg-primary-500 hover:bg-primary-600' : 'bg-gray-200 hover:bg-gray-300 dark:bg-dark-600 dark:hover:bg-dark-500']"
:title=
"row.schedulable ? t('admin.accounts.schedulableEnabled') : t('admin.accounts.schedulableDisabled')"
>
<span
class=
"pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class=
"[row.schedulable ? 'translate-x-4' : 'translate-x-0']"
/>
</button>
</
template
>
<
template
#cell-today_stats=
"{ row }"
>
<AccountTodayStatsCell
:account=
"row"
/>
</
template
>
<
template
#cell-groups=
"{ row }"
>
<div
v-if=
"row.groups && row.groups.length > 0"
class=
"flex flex-wrap gap-1.5"
>
<
GroupBadge
v
-
for
=
"
group in row.groups
"
:
key
=
"
group.id
"
:
name
=
"
group.name
"
:
platform
=
"
group.platform
"
:
subscription
-
type
=
"
group.subscription_type
"
:
rate
-
multiplier
=
"
group.rate_multiplier
"
:
show
-
rate
=
"
false
"
/>
<GroupBadge
v-for=
"group in row.groups"
:key=
"group.id"
:name=
"group.name"
:platform=
"group.platform"
:subscription-type=
"group.subscription_type"
:rate-multiplier=
"group.rate_multiplier"
:show-rate=
"false"
/>
</div>
<span
v-else
class=
"text-sm text-gray-400 dark:text-dark-500"
>
-
</span>
</
template
>
<
template
#cell-usage=
"{ row }"
>
<AccountUsageCell
:account=
"row"
/>
</
template
>
<
template
#cell-priority=
"{ value }"
>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
value
}}
</span>
</
template
>
<
template
#cell-last_used_at=
"{ value }"
>
<
span
class
=
"
text-sm text-gray-500 dark:text-dark-400
"
>
{{
formatRelativeTime
(
value
)
}}
<
/span
>
<span
class=
"text-sm text-gray-500 dark:text-dark-400"
>
{{
formatRelativeTime
(
value
)
}}
</span>
</
template
>
<
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
"
>
<
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
=
"
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
>
<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"
>
<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=
"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>
<!--
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
"
>
<
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
=
"
M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0
"
/>
<
/svg
>
<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"
>
<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=
"M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/></svg>
<span
class=
"text-xs"
>
{{
t
(
'
common.delete
'
)
}}
</span>
</button>
<!--
More
Actions
Menu
Trigger
-->
<
button
:
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
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
1.5
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
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 0zM18.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0z
"
/>
<
/svg
>
<button
@
click=
"openMenu(row, $event)"
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-gray-900 dark:hover:bg-dark-700 dark:hover:text-white"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
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 0zM18.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0z"
/></svg>
<span
class=
"text-xs"
>
{{
t
(
'
common.more
'
)
}}
</span>
</button>
</div>
</
template
>
<
template
#
empty
>
<
EmptyState
:
title
=
"
t('admin.accounts.noAccountsYet')
"
:
description
=
"
t('admin.accounts.createFirstAccount')
"
:
action
-
text
=
"
t('admin.accounts.createAccount')
"
@
action
=
"
showCreateModal = true
"
/>
<
/template
>
</DataTable>
</template>
<
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
>
<
template
#pagination
><Pagination
v-if=
"pagination.total > 0"
:page=
"pagination.page"
:total=
"pagination.total"
:page-size=
"pagination.page_size"
@
update:page=
"handlePageChange"
/></
template
>
</TablePageLayout>
<!--
Create
Account
Modal
-->
<
CreateAccountModal
:
show
=
"
showCreateModal
"
:
proxies
=
"
proxies
"
:
groups
=
"
groups
"
@
close
=
"
showCreateModal = false
"
@
created
=
"
() => { loadAccounts(); if (onboardingStore.isCurrentStep(`[data-tour='account-form-submit']`)) onboardingStore.nextStep(500)
}
"
/>
<!--
Edit
Account
Modal
-->
<
EditAccountModal
:
show
=
"
showEditModal
"
:
account
=
"
editingAccount
"
:
proxies
=
"
proxies
"
:
groups
=
"
groups
"
@
close
=
"
closeEditModal
"
@
updated
=
"
loadAccounts
"
/>
<!--
Re
-
Auth
Modal
-->
<
ReAuthAccountModal
:
show
=
"
showReAuthModal
"
:
account
=
"
reAuthAccount
"
@
close
=
"
closeReAuthModal
"
@
reauthorized
=
"
loadAccounts
"
/>
<!--
Test
Account
Modal
-->
<
AccountTestModal
:
show
=
"
showTestModal
"
:
account
=
"
testingAccount
"
@
close
=
"
closeTestModal
"
/>
<!--
Account
Stats
Modal
-->
<
AccountStatsModal
:
show
=
"
showStatsModal
"
:
account
=
"
statsAccount
"
@
close
=
"
closeStatsModal
"
/>
<!--
Temp
Unschedulable
Status
Modal
-->
<
TempUnschedStatusModal
:
show
=
"
showTempUnschedModal
"
:
account
=
"
tempUnschedAccount
"
@
close
=
"
closeTempUnschedModal
"
@
reset
=
"
handleTempUnschedReset
"
/>
<!--
Delete
Confirmation
Dialog
-->
<
ConfirmDialog
:
show
=
"
showDeleteDialog
"
:
title
=
"
t('admin.accounts.deleteAccount')
"
:
message
=
"
t('admin.accounts.deleteConfirm', { name: deletingAccount?.name
}
)
"
:
confirm
-
text
=
"
t('common.delete')
"
:
cancel
-
text
=
"
t('common.cancel')
"
:
danger
=
"
true
"
@
confirm
=
"
confirmDelete
"
@
cancel
=
"
showDeleteDialog = false
"
/>
<
ConfirmDialog
:
show
=
"
showBulkDeleteDialog
"
:
title
=
"
t('admin.accounts.bulkDeleteTitle')
"
:
message
=
"
t('admin.accounts.bulkDeleteConfirm', { count: selectedAccountIds.length
}
)
"
:
confirm
-
text
=
"
t('common.delete')
"
:
cancel
-
text
=
"
t('common.cancel')
"
:
danger
=
"
true
"
@
confirm
=
"
confirmBulkDelete
"
@
cancel
=
"
showBulkDeleteDialog = false
"
/>
<
SyncFromCrsModal
:
show
=
"
showCrsSyncModal
"
@
close
=
"
showCrsSyncModal = false
"
@
synced
=
"
handleCrsSynced
"
/>
<!--
Bulk
Edit
Account
Modal
-->
<
BulkEditAccountModal
:
show
=
"
showBulkEditModal
"
:
account
-
ids
=
"
selectedAccountIds
"
:
proxies
=
"
proxies
"
:
groups
=
"
groups
"
@
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
>
<CreateAccountModal
:show=
"showCreate"
:proxies=
"proxies"
:groups=
"groups"
@
close=
"showCreate = false"
@
created=
"reload"
/>
<EditAccountModal
:show=
"showEdit"
:account=
"edAcc"
:proxies=
"proxies"
:groups=
"groups"
@
close=
"showEdit = false"
@
updated=
"load"
/>
<ReAuthAccountModal
:show=
"showReAuth"
:account=
"reAuthAcc"
@
close=
"closeReAuthModal"
@
reauthorized=
"load"
/>
<AccountTestModal
:show=
"showTest"
:account=
"testingAcc"
@
close=
"closeTestModal"
/>
<AccountStatsModal
:show=
"showStats"
:account=
"statsAcc"
@
close=
"closeStatsModal"
/>
<AccountActionMenu
:show=
"menu.show"
:account=
"menu.acc"
:position=
"menu.pos"
@
close=
"menu.show = false"
@
test=
"handleTest"
@
stats=
"handleViewStats"
@
reauth=
"handleReAuth"
@
refresh-token=
"handleRefresh"
@
reset-status=
"handleResetStatus"
@
clear-rate-limit=
"handleClearRateLimit"
/>
<SyncFromCrsModal
:show=
"showSync"
@
close=
"showSync = false"
@
synced=
"reload"
/>
<BulkEditAccountModal
:show=
"showBulkEdit"
:account-ids=
"selIds"
:proxies=
"proxies"
:groups=
"groups"
@
close=
"showBulkEdit = false"
@
updated=
"handleBulkUpdated"
/>
<TempUnschedStatusModal
:show=
"showTempUnsched"
:account=
"tempUnschedAcc"
@
close=
"showTempUnsched = false"
@
reset=
"handleTempUnschedReset"
/>
<ConfirmDialog
:show=
"showDeleteDialog"
:title=
"t('admin.accounts.deleteAccount')"
:message=
"t('admin.accounts.deleteConfirm', { name: deletingAcc?.name })"
:confirm-text=
"t('common.delete')"
:cancel-text=
"t('common.cancel')"
:danger=
"true"
@
confirm=
"confirmDelete"
@
cancel=
"showDeleteDialog = false"
/>
</AppLayout>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
,
type
ComponentPublicInstance
}
from
'
vue
'
import
{
ref
,
reactive
,
computed
,
onMounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
useOnboardingStore
}
from
'
@/stores/onboarding
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Account
,
Proxy
,
Group
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
{
useTableLoader
}
from
'
@/composables/useTableLoader
'
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
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
{
CreateAccountModal
,
EditAccountModal
,
BulkEditAccountModal
,
ReAuthAccountModal
,
AccountStatsModal
,
TempUnschedStatusModal
,
SyncFromCrsModal
}
from
'
@/components/account
'
import
{
CreateAccountModal
,
EditAccountModal
,
BulkEditAccountModal
,
SyncFromCrsModal
,
TempUnschedStatusModal
}
from
'
@/components/account
'
import
AccountTableActions
from
'
@/components/admin/account/AccountTableActions.vue
'
import
AccountTableFilters
from
'
@/components/admin/account/AccountTableFilters.vue
'
import
AccountBulkActionsBar
from
'
@/components/admin/account/AccountBulkActionsBar.vue
'
import
AccountActionMenu
from
'
@/components/admin/account/AccountActionMenu.vue
'
import
ReAuthAccountModal
from
'
@/components/admin/account/ReAuthAccountModal.vue
'
import
AccountTestModal
from
'
@/components/admin/account/AccountTestModal.vue
'
import
AccountStatsModal
from
'
@/components/admin/account/AccountStatsModal.vue
'
import
AccountStatusIndicator
from
'
@/components/account/AccountStatusIndicator.vue
'
import
AccountUsageCell
from
'
@/components/account/AccountUsageCell.vue
'
import
AccountTodayStatsCell
from
'
@/components/account/AccountTodayStatsCell.vue
'
import
AccountTestModal
from
'
@/components/account/AccountTestModal.vue
'
import
GroupBadge
from
'
@/components/common/GroupBadge.vue
'
import
PlatformTypeBadge
from
'
@/components/common/PlatformTypeBadge.vue
'
import
{
formatRelativeTime
}
from
'
@/utils/format
'
import
type
{
Account
,
Proxy
,
Group
}
from
'
@/types
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
authStore
=
useAuthStore
()
const
onboardingStore
=
useOnboardingStore
()
// Table columns
const
columns
=
computed
<
Column
[]
>
(()
=>
{
const
cols
:
Column
[]
=
[
const
proxies
=
ref
<
Proxy
[]
>
([])
const
groups
=
ref
<
Group
[]
>
([])
const
selIds
=
ref
<
number
[]
>
([])
const
showCreate
=
ref
(
false
)
const
showEdit
=
ref
(
false
)
const
showSync
=
ref
(
false
)
const
showBulkEdit
=
ref
(
false
)
const
showTempUnsched
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
const
showReAuth
=
ref
(
false
)
const
showTest
=
ref
(
false
)
const
showStats
=
ref
(
false
)
const
edAcc
=
ref
<
Account
|
null
>
(
null
)
const
tempUnschedAcc
=
ref
<
Account
|
null
>
(
null
)
const
deletingAcc
=
ref
<
Account
|
null
>
(
null
)
const
reAuthAcc
=
ref
<
Account
|
null
>
(
null
)
const
testingAcc
=
ref
<
Account
|
null
>
(
null
)
const
statsAcc
=
ref
<
Account
|
null
>
(
null
)
const
togglingSchedulable
=
ref
<
number
|
null
>
(
null
)
const
menu
=
reactive
<
{
show
:
boolean
,
acc
:
Account
|
null
,
pos
:{
top
:
number
,
left
:
number
}
|
null
}
>
({
show
:
false
,
acc
:
null
,
pos
:
null
})
const
{
items
:
accounts
,
loading
,
params
,
pagination
,
load
,
reload
,
debouncedReload
,
handlePageChange
}
=
useTableLoader
<
Account
,
any
>
({
fetchFn
:
adminAPI
.
accounts
.
list
,
initialParams
:
{
platform
:
''
,
type
:
''
,
status
:
''
,
search
:
''
}
})
const
cols
=
computed
(()
=>
{
const
c
=
[
{
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
},
...
...
@@ -547,428 +170,38 @@ const columns = computed<Column[]>(() => {
{
key
:
'
schedulable
'
,
label
:
t
(
'
admin.accounts.columns.schedulable
'
),
sortable
:
true
},
{
key
:
'
today_stats
'
,
label
:
t
(
'
admin.accounts.columns.todayStats
'
),
sortable
:
false
}
]
// 简易模式下不显示分组列
if
(
!
authStore
.
isSimpleMode
)
{
c
ols
.
push
({
key
:
'
groups
'
,
label
:
t
(
'
admin.accounts.columns.groups
'
),
sortable
:
false
}
)
c
.
push
({
key
:
'
groups
'
,
label
:
t
(
'
admin.accounts.columns.groups
'
),
sortable
:
false
})
}
cols
.
push
(
c
.
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
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.accounts.allPlatforms
'
)
}
,
{
value
:
'
anthropic
'
,
label
:
t
(
'
admin.accounts.platforms.anthropic
'
)
}
,
{
value
:
'
openai
'
,
label
:
t
(
'
admin.accounts.platforms.openai
'
)
}
,
{
value
:
'
gemini
'
,
label
:
t
(
'
admin.accounts.platforms.gemini
'
)
}
,
{
value
:
'
antigravity
'
,
label
:
t
(
'
admin.accounts.platforms.antigravity
'
)
}
])
const
typeOptions
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.accounts.allTypes
'
)
}
,
{
value
:
'
oauth
'
,
label
:
t
(
'
admin.accounts.oauthType
'
)
}
,
{
value
:
'
setup-token
'
,
label
:
t
(
'
admin.accounts.setupToken
'
)
}
,
{
value
:
'
apikey
'
,
label
:
t
(
'
admin.accounts.apiKey
'
)
}
])
const
statusOptions
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.accounts.allStatus
'
)
}
,
{
value
:
'
active
'
,
label
:
t
(
'
common.active
'
)
}
,
{
value
:
'
inactive
'
,
label
:
t
(
'
common.inactive
'
)
}
,
{
value
:
'
error
'
,
label
:
t
(
'
common.error
'
)
}
])
// State
const
accounts
=
ref
<
Account
[]
>
([])
const
proxies
=
ref
<
Proxy
[]
>
([])
const
groups
=
ref
<
Group
[]
>
([])
const
loading
=
ref
(
false
)
const
searchQuery
=
ref
(
''
)
const
filters
=
reactive
({
platform
:
''
,
type
:
''
,
status
:
''
}
)
const
pagination
=
reactive
({
page
:
1
,
page_size
:
20
,
total
:
0
,
pages
:
0
}
)
let
abortController
:
AbortController
|
null
=
null
// Modal states
const
showCreateModal
=
ref
(
false
)
const
showEditModal
=
ref
(
false
)
const
showReAuthModal
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
const
showBulkDeleteDialog
=
ref
(
false
)
const
showTestModal
=
ref
(
false
)
const
showStatsModal
=
ref
(
false
)
const
showTempUnschedModal
=
ref
(
false
)
const
showCrsSyncModal
=
ref
(
false
)
const
showBulkEditModal
=
ref
(
false
)
const
editingAccount
=
ref
<
Account
|
null
>
(
null
)
const
reAuthAccount
=
ref
<
Account
|
null
>
(
null
)
const
deletingAccount
=
ref
<
Account
|
null
>
(
null
)
const
testingAccount
=
ref
<
Account
|
null
>
(
null
)
const
statsAccount
=
ref
<
Account
|
null
>
(
null
)
const
tempUnschedAccount
=
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
=
()
=>
{
const
pageIds
=
accounts
.
value
.
map
((
account
)
=>
account
.
id
)
const
merged
=
new
Set
([...
selectedAccountIds
.
value
,
...
pageIds
])
selectedAccountIds
.
value
=
Array
.
from
(
merged
)
}
// Rate limit / Overload helpers
const
isRateLimited
=
(
account
:
Account
):
boolean
=>
{
if
(
!
account
.
rate_limit_reset_at
)
return
false
return
new
Date
(
account
.
rate_limit_reset_at
)
>
new
Date
()
}
const
isOverloaded
=
(
account
:
Account
):
boolean
=>
{
if
(
!
account
.
overload_until
)
return
false
return
new
Date
(
account
.
overload_until
)
>
new
Date
()
}
// 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
,
{
platform
:
filters
.
platform
||
undefined
,
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
()
=>
{
try
{
proxies
.
value
=
await
adminAPI
.
proxies
.
getAllWithCount
()
}
catch
(
error
)
{
console
.
error
(
'
Error loading proxies:
'
,
error
)
}
}
const
loadGroups
=
async
()
=>
{
try
{
// Load groups for all platforms to support both Anthropic and OpenAI accounts
groups
.
value
=
await
adminAPI
.
groups
.
getAll
()
}
catch
(
error
)
{
console
.
error
(
'
Error loading groups:
'
,
error
)
}
}
// Search handling
let
searchTimeout
:
ReturnType
<
typeof
setTimeout
>
const
handleSearch
=
()
=>
{
clearTimeout
(
searchTimeout
)
searchTimeout
=
setTimeout
(()
=>
{
pagination
.
page
=
1
loadAccounts
()
}
,
300
)
}
// Pagination
const
handlePageChange
=
(
page
:
number
)
=>
{
pagination
.
page
=
page
loadAccounts
()
}
const
handlePageSizeChange
=
(
pageSize
:
number
)
=>
{
pagination
.
page_size
=
pageSize
pagination
.
page
=
1
loadAccounts
()
}
const
handleCrsSynced
=
()
=>
{
showCrsSyncModal
.
value
=
false
loadAccounts
()
}
// Edit modal
const
handleEdit
=
(
account
:
Account
)
=>
{
editingAccount
.
value
=
account
showEditModal
.
value
=
true
}
const
closeEditModal
=
()
=>
{
showEditModal
.
value
=
false
editingAccount
.
value
=
null
}
// Re-Auth modal
const
handleReAuth
=
(
account
:
Account
)
=>
{
reAuthAccount
.
value
=
account
showReAuthModal
.
value
=
true
}
const
closeReAuthModal
=
()
=>
{
showReAuthModal
.
value
=
false
reAuthAccount
.
value
=
null
}
// Temp unschedulable modal
const
handleShowTempUnsched
=
(
account
:
Account
)
=>
{
tempUnschedAccount
.
value
=
account
showTempUnschedModal
.
value
=
true
}
const
closeTempUnschedModal
=
()
=>
{
showTempUnschedModal
.
value
=
false
tempUnschedAccount
.
value
=
null
}
const
handleTempUnschedReset
=
()
=>
{
loadAccounts
()
}
// Token refresh
const
handleRefreshToken
=
async
(
account
:
Account
)
=>
{
try
{
await
adminAPI
.
accounts
.
refreshCredentials
(
account
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.tokenRefreshed
'
))
loadAccounts
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.failedToRefresh
'
))
console
.
error
(
'
Error refreshing token:
'
,
error
)
}
}
// Delete
const
handleDelete
=
(
account
:
Account
)
=>
{
deletingAccount
.
value
=
account
showDeleteDialog
.
value
=
true
}
const
confirmDelete
=
async
()
=>
{
if
(
!
deletingAccount
.
value
)
return
try
{
await
adminAPI
.
accounts
.
delete
(
deletingAccount
.
value
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.accountDeleted
'
))
showDeleteDialog
.
value
=
false
deletingAccount
.
value
=
null
loadAccounts
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.failedToDelete
'
))
console
.
error
(
'
Error deleting account:
'
,
error
)
}
}
const
handleBulkDelete
=
()
=>
{
if
(
selectedAccountIds
.
value
.
length
===
0
)
return
showBulkDeleteDialog
.
value
=
true
}
const
confirmBulkDelete
=
async
()
=>
{
if
(
bulkDeleting
.
value
||
selectedAccountIds
.
value
.
length
===
0
)
return
bulkDeleting
.
value
=
true
const
ids
=
[...
selectedAccountIds
.
value
]
try
{
const
results
=
await
Promise
.
allSettled
(
ids
.
map
((
id
)
=>
adminAPI
.
accounts
.
delete
(
id
)))
const
success
=
results
.
filter
((
result
)
=>
result
.
status
===
'
fulfilled
'
).
length
const
failed
=
results
.
length
-
success
if
(
failed
===
0
)
{
appStore
.
showSuccess
(
t
(
'
admin.accounts.bulkDeleteSuccess
'
,
{
count
:
success
}
))
}
else
{
appStore
.
showError
(
t
(
'
admin.accounts.bulkDeletePartial
'
,
{
success
,
failed
}
))
}
showBulkDeleteDialog
.
value
=
false
selectedAccountIds
.
value
=
[]
loadAccounts
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.bulkDeleteFailed
'
))
console
.
error
(
'
Error deleting accounts:
'
,
error
)
}
finally
{
bulkDeleting
.
value
=
false
}
}
// Clear rate limit
const
handleClearRateLimit
=
async
(
account
:
Account
)
=>
{
try
{
await
adminAPI
.
accounts
.
clearRateLimit
(
account
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.rateLimitCleared
'
))
loadAccounts
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.failedToClearRateLimit
'
))
console
.
error
(
'
Error clearing rate limit:
'
,
error
)
}
}
// Reset account status (clear error and rate limit)
const
handleResetStatus
=
async
(
account
:
Account
)
=>
{
try
{
// Clear error status
await
adminAPI
.
accounts
.
clearError
(
account
.
id
)
// Also clear rate limit if exists
if
(
isRateLimited
(
account
)
||
isOverloaded
(
account
))
{
await
adminAPI
.
accounts
.
clearRateLimit
(
account
.
id
)
}
appStore
.
showSuccess
(
t
(
'
admin.accounts.statusReset
'
))
loadAccounts
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.failedToResetStatus
'
))
console
.
error
(
'
Error resetting account status:
'
,
error
)
}
}
// Toggle schedulable
const
handleToggleSchedulable
=
async
(
account
:
Account
)
=>
{
togglingSchedulable
.
value
=
account
.
id
try
{
const
updatedAccount
=
await
adminAPI
.
accounts
.
setSchedulable
(
account
.
id
,
!
account
.
schedulable
)
const
index
=
accounts
.
value
.
findIndex
((
a
)
=>
a
.
id
===
account
.
id
)
if
(
index
!==
-
1
)
{
accounts
.
value
[
index
]
=
updatedAccount
}
appStore
.
showSuccess
(
updatedAccount
.
schedulable
?
t
(
'
admin.accounts.schedulableEnabled
'
)
:
t
(
'
admin.accounts.schedulableDisabled
'
)
)
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.failedToToggleSchedulable
'
)
)
console
.
error
(
'
Error toggling schedulable:
'
,
error
)
}
finally
{
togglingSchedulable
.
value
=
null
}
}
// Test modal
const
handleTest
=
(
account
:
Account
)
=>
{
testingAccount
.
value
=
account
showTestModal
.
value
=
true
}
const
closeTestModal
=
()
=>
{
showTestModal
.
value
=
false
testingAccount
.
value
=
null
}
// Stats modal
const
handleViewStats
=
(
account
:
Account
)
=>
{
statsAccount
.
value
=
account
showStatsModal
.
value
=
true
}
const
closeStatsModal
=
()
=>
{
showStatsModal
.
value
=
false
statsAccount
.
value
=
null
}
// Bulk selection toggle
const
toggleAccountSelection
=
(
accountId
:
number
)
=>
{
const
index
=
selectedAccountIds
.
value
.
indexOf
(
accountId
)
if
(
index
===
-
1
)
{
selectedAccountIds
.
value
.
push
(
accountId
)
}
else
{
selectedAccountIds
.
value
.
splice
(
index
,
1
)
}
}
// Bulk update handler
const
handleBulkUpdated
=
()
=>
{
showBulkEditModal
.
value
=
false
selectedAccountIds
.
value
=
[]
loadAccounts
()
}
// Initialize
onMounted
(()
=>
{
loadAccounts
()
loadProxies
()
loadGroups
()
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
return
c
})
onUnmounted
(()
=>
{
abortController
?.
abort
()
abortController
=
null
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
}
)
const
handleEdit
=
(
a
:
Account
)
=>
{
edAcc
.
value
=
a
;
showEdit
.
value
=
true
}
const
openMenu
=
(
a
:
Account
,
e
:
MouseEvent
)
=>
{
menu
.
acc
=
a
;
menu
.
pos
=
{
top
:
e
.
clientY
,
left
:
e
.
clientX
-
200
};
menu
.
show
=
true
}
const
toggleSel
=
(
id
:
number
)
=>
{
const
i
=
selIds
.
value
.
indexOf
(
id
);
if
(
i
===
-
1
)
selIds
.
value
.
push
(
id
);
else
selIds
.
value
.
splice
(
i
,
1
)
}
const
selectPage
=
()
=>
{
selIds
.
value
=
[...
new
Set
([...
selIds
.
value
,
...
accounts
.
value
.
map
(
a
=>
a
.
id
)])]
}
const
handleBulkDelete
=
async
()
=>
{
if
(
!
confirm
(
t
(
'
common.confirm
'
)))
return
;
try
{
await
Promise
.
all
(
selIds
.
value
.
map
(
id
=>
adminAPI
.
accounts
.
delete
(
id
)));
selIds
.
value
=
[];
reload
()
}
catch
{}
}
const
handleBulkUpdated
=
()
=>
{
showBulkEdit
.
value
=
false
;
selIds
.
value
=
[];
reload
()
}
const
closeTestModal
=
()
=>
{
showTest
.
value
=
false
;
testingAcc
.
value
=
null
}
const
closeStatsModal
=
()
=>
{
showStats
.
value
=
false
;
statsAcc
.
value
=
null
}
const
closeReAuthModal
=
()
=>
{
showReAuth
.
value
=
false
;
reAuthAcc
.
value
=
null
}
const
handleTest
=
(
a
:
Account
)
=>
{
testingAcc
.
value
=
a
;
showTest
.
value
=
true
}
const
handleViewStats
=
(
a
:
Account
)
=>
{
statsAcc
.
value
=
a
;
showStats
.
value
=
true
}
const
handleReAuth
=
(
a
:
Account
)
=>
{
reAuthAcc
.
value
=
a
;
showReAuth
.
value
=
true
}
const
handleRefresh
=
async
(
a
:
Account
)
=>
{
try
{
await
adminAPI
.
accounts
.
refreshCredentials
(
a
.
id
);
load
()
}
catch
{}
}
const
handleResetStatus
=
async
(
a
:
Account
)
=>
{
try
{
await
adminAPI
.
accounts
.
clearError
(
a
.
id
);
appStore
.
showSuccess
(
t
(
'
common.success
'
));
load
()
}
catch
{}
}
const
handleClearRateLimit
=
async
(
a
:
Account
)
=>
{
try
{
await
adminAPI
.
accounts
.
clearError
(
a
.
id
);
appStore
.
showSuccess
(
t
(
'
common.success
'
));
load
()
}
catch
{}
}
const
handleDelete
=
(
a
:
Account
)
=>
{
deletingAcc
.
value
=
a
;
showDeleteDialog
.
value
=
true
}
const
confirmDelete
=
async
()
=>
{
if
(
!
deletingAcc
.
value
)
return
;
try
{
await
adminAPI
.
accounts
.
delete
(
deletingAcc
.
value
.
id
);
showDeleteDialog
.
value
=
false
;
deletingAcc
.
value
=
null
;
reload
()
}
catch
{}
}
const
handleToggleSchedulable
=
async
(
a
:
Account
)
=>
{
togglingSchedulable
.
value
=
a
.
id
;
try
{
await
adminAPI
.
accounts
.
update
(
a
.
id
,
{
schedulable
:
!
a
.
schedulable
});
load
()
}
finally
{
togglingSchedulable
.
value
=
null
}
}
const
handleShowTempUnsched
=
(
a
:
Account
)
=>
{
tempUnschedAcc
.
value
=
a
;
showTempUnsched
.
value
=
true
}
const
handleTempUnschedReset
=
async
()
=>
{
if
(
!
tempUnschedAcc
.
value
)
return
;
try
{
await
adminAPI
.
accounts
.
clearError
(
tempUnschedAcc
.
value
.
id
);
showTempUnsched
.
value
=
false
;
tempUnschedAcc
.
value
=
null
;
load
()
}
catch
{}
}
onMounted
(
async
()
=>
{
load
();
try
{
const
[
p
,
g
]
=
await
Promise
.
all
([
adminAPI
.
proxies
.
getAll
(),
adminAPI
.
groups
.
getAll
()]);
proxies
.
value
=
p
;
groups
.
value
=
g
}
catch
{}
})
</
script
>
frontend/src/views/admin/DashboardView.vue
View file @
fb313356
...
...
@@ -504,7 +504,7 @@ const userTrendChartData = computed(() => {
if
(
email
&&
email
.
includes
(
'
@
'
))
{
return
email
.
split
(
'
@
'
)[
0
]
}
return
`User #
${
userId
}
`
return
t
(
'
admin.redeem.userPrefix
'
,
{
id
:
userId
})
}
// Group by user
...
...
@@ -652,16 +652,4 @@ onMounted(() => {
</
script
>
<
style
scoped
>
/* Compact Select styling for dashboard */
:deep
(
.select-trigger
)
{
@apply
rounded-lg
px-3
py-1.5
text-sm;
}
:deep
(
.select-dropdown
)
{
@apply
rounded-lg;
}
:deep
(
.select-option
)
{
@apply
px-3
py-2
text-sm;
}
</
style
>
frontend/src/views/admin/GroupsView.vue
View file @
fb313356
<
template
>
<AppLayout>
<TablePageLayout>
<template
#actions
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"loadGroups"
:disabled=
"loading"
class=
"btn btn-secondary"
:title=
"t('common.refresh')"
>
<svg
:class=
"['h-5 w-5', loading ? 'animate-spin' : '']"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M16.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.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
<button
@
click=
"showCreateModal = true"
class=
"btn btn-primary"
data-tour=
"groups-create-btn"
>
<svg
class=
"mr-2 h-5 w-5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 4.5v15m7.5-7.5h-15"
/>
</svg>
{{
t
(
'
admin.groups.createGroup
'
)
}}
</button>
</div>
</
template
>
<template
#filters
>
<div
class=
"flex flex-wrap gap-3"
>
<div
class=
"flex flex-col justify-between gap-4 lg:flex-row lg:items-start"
>
<!-- Left: fuzzy search + filters (can wrap to multiple lines) -->
<div
class=
"flex flex-1 flex-wrap items-center gap-3"
>
<div
class=
"relative w-full sm:w-72 lg:w-80"
>
<svg
class=
"absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
<input
v-model=
"searchQuery"
type=
"text"
:placeholder=
"t('admin.groups.searchGroups')"
class=
"input pl-10"
/>
</div>
<Select
v-model=
"filters.platform"
:options=
"platformFilterOptions"
...
...
@@ -65,11 +47,56 @@
class=
"w-44"
@
change=
"loadGroups"
/>
</div>
<!-- Right: actions -->
<div
class=
"flex w-full flex-shrink-0 flex-wrap items-center justify-end gap-3 lg:w-auto"
>
<button
@
click=
"loadGroups"
:disabled=
"loading"
class=
"btn btn-secondary"
:title=
"t('common.refresh')"
>
<svg
:class=
"['h-5 w-5', loading ? 'animate-spin' : '']"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M16.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.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
<button
@
click=
"showCreateModal = true"
class=
"btn btn-primary"
data-tour=
"groups-create-btn"
>
<svg
class=
"mr-2 h-5 w-5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 4.5v15m7.5-7.5h-15"
/>
</svg>
{{
t
(
'
admin.groups.createGroup
'
)
}}
</button>
</div>
</div>
</
template
>
<
template
#table
>
<DataTable
:columns=
"columns"
:data=
"
g
roups"
:loading=
"loading"
>
<DataTable
:columns=
"columns"
:data=
"
displayedG
roups"
:loading=
"loading"
>
<template
#cell-name
="
{ value }">
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
value
}}
</span>
</
template
>
...
...
@@ -88,15 +115,7 @@
]"
>
<PlatformIcon
:platform=
"value"
size=
"xs"
/>
{{
value
===
'
anthropic
'
?
'
Anthropic
'
:
value
===
'
openai
'
?
'
OpenAI
'
:
value
===
'
antigravity
'
?
'
Antigravity
'
:
'
Gemini
'
}}
{{
t
(
'
admin.groups.platforms.
'
+
value
)
}}
</span>
</
template
>
...
...
@@ -172,7 +191,7 @@
<
template
#
cell
-
status
=
"
{ value
}
"
>
<
span
:
class
=
"
['badge', value === 'active' ? 'badge-success' : 'badge-danger']
"
>
{{
value
}}
{{
t
(
'
admin.accounts.status.
'
+
value
)
}}
<
/span
>
<
/template
>
...
...
@@ -691,8 +710,8 @@ const columns = computed<Column[]>(() => [
// Filter options
const
statusOptions
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.groups.allStatus
'
)
}
,
{
value
:
'
active
'
,
label
:
t
(
'
common
.active
'
)
}
,
{
value
:
'
inactive
'
,
label
:
t
(
'
common
.inactive
'
)
}
{
value
:
'
active
'
,
label
:
t
(
'
admin.accounts.status
.active
'
)
}
,
{
value
:
'
inactive
'
,
label
:
t
(
'
admin.accounts.status
.inactive
'
)
}
])
const
exclusiveOptions
=
computed
(()
=>
[
...
...
@@ -717,8 +736,8 @@ const platformFilterOptions = computed(() => [
])
const
editStatusOptions
=
computed
(()
=>
[
{
value
:
'
active
'
,
label
:
t
(
'
common
.active
'
)
}
,
{
value
:
'
inactive
'
,
label
:
t
(
'
common
.inactive
'
)
}
{
value
:
'
active
'
,
label
:
t
(
'
admin.accounts.status
.active
'
)
}
,
{
value
:
'
inactive
'
,
label
:
t
(
'
admin.accounts.status
.inactive
'
)
}
])
const
subscriptionTypeOptions
=
computed
(()
=>
[
...
...
@@ -728,6 +747,7 @@ const subscriptionTypeOptions = computed(() => [
const
groups
=
ref
<
Group
[]
>
([])
const
loading
=
ref
(
false
)
const
searchQuery
=
ref
(
''
)
const
filters
=
reactive
({
platform
:
''
,
status
:
''
,
...
...
@@ -742,6 +762,16 @@ const pagination = reactive({
let
abortController
:
AbortController
|
null
=
null
const
displayedGroups
=
computed
(()
=>
{
const
q
=
searchQuery
.
value
.
trim
().
toLowerCase
()
if
(
!
q
)
return
groups
.
value
return
groups
.
value
.
filter
((
group
)
=>
{
const
name
=
group
.
name
?.
toLowerCase
?.()
??
''
const
description
=
group
.
description
?.
toLowerCase
?.()
??
''
return
name
.
includes
(
q
)
||
description
.
includes
(
q
)
}
)
}
)
const
showCreateModal
=
ref
(
false
)
const
showEditModal
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
...
...
frontend/src/views/admin/ProxiesView.vue
View file @
fb313356
<
template
>
<AppLayout>
<TablePageLayout>
<template
#actions
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"loadProxies"
:disabled=
"loading"
class=
"btn btn-secondary"
:title=
"t('common.refresh')"
>
<svg
:class=
"['h-5 w-5', loading ? 'animate-spin' : '']"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M16.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.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
<button
@
click=
"showCreateModal = true"
class=
"btn btn-primary"
>
<svg
class=
"mr-2 h-5 w-5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 4.5v15m7.5-7.5h-15"
/>
</svg>
{{
t
(
'
admin.proxies.createProxy
'
)
}}
</button>
</div>
</
template
>
<template
#filters
>
<div
class=
"flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"
>
<div
class=
"relative max-w-md flex-1"
>
<svg
class=
"absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
<input
v-model=
"searchQuery"
type=
"text"
:placeholder=
"t('admin.proxies.searchProxies')"
class=
"input pl-10"
@
input=
"handleSearch"
/>
<!-- Top Toolbar: Left (search + filters) / Right (actions) -->
<div
class=
"flex flex-wrap items-start justify-between gap-4"
>
<!-- Left: Fuzzy search + filters (wrap to multiple lines) -->
<div
class=
"flex flex-1 flex-wrap items-center gap-3"
>
<!-- Search -->
<div
class=
"relative w-full sm:flex-1 sm:min-w-[14rem] sm:max-w-md"
>
<svg
class=
"absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
<input
v-model=
"searchQuery"
type=
"text"
:placeholder=
"t('admin.proxies.searchProxies')"
class=
"input pl-10"
@
input=
"handleSearch"
/>
</div>
<!-- Filters -->
<div
class=
"w-full sm:w-40"
>
<Select
v-model=
"filters.protocol"
:options=
"protocolOptions"
:placeholder=
"t('admin.proxies.allProtocols')"
@
change=
"loadProxies"
/>
</div>
<div
class=
"w-full sm:w-36"
>
<Select
v-model=
"filters.status"
:options=
"statusOptions"
:placeholder=
"t('admin.proxies.allStatus')"
@
change=
"loadProxies"
/>
</div>
</div>
<div
class=
"flex flex-wrap gap-3"
>
<Select
v-model=
"filters.protocol"
:options=
"protocolOptions"
:placeholder=
"t('admin.proxies.allProtocols')"
class=
"w-40"
@
change=
"loadProxies"
/>
<Select
v-model=
"filters.status"
:options=
"statusOptions"
:placeholder=
"t('admin.proxies.allStatus')"
class=
"w-36"
@
change=
"loadProxies"
/>
<!-- Right: Actions -->
<div
class=
"ml-auto flex flex-wrap items-center justify-end gap-3"
>
<button
@
click=
"loadProxies"
:disabled=
"loading"
class=
"btn btn-secondary"
:title=
"t('common.refresh')"
>
<svg
:class=
"['h-5 w-5', loading ? 'animate-spin' : '']"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M16.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.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
<button
@
click=
"showCreateModal = true"
class=
"btn btn-primary"
>
<svg
class=
"mr-2 h-5 w-5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 4.5v15m7.5-7.5h-15"
/>
</svg>
{{
t
(
'
admin.proxies.createProxy
'
)
}}
</button>
</div>
</div>
</
template
>
...
...
@@ -103,7 +113,7 @@
<
template
#cell-status=
"{ value }"
>
<span
:class=
"['badge', value === 'active' ? 'badge-success' : 'badge-danger']"
>
{{
value
}}
{{
t
(
'
admin.accounts.status.
'
+
value
)
}}
</span>
</
template
>
...
...
@@ -634,21 +644,21 @@ const protocolOptions = computed(() => [
const
statusOptions
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.proxies.allStatus
'
)
}
,
{
value
:
'
active
'
,
label
:
t
(
'
common
.active
'
)
}
,
{
value
:
'
inactive
'
,
label
:
t
(
'
common
.inactive
'
)
}
{
value
:
'
active
'
,
label
:
t
(
'
admin.accounts.status
.active
'
)
}
,
{
value
:
'
inactive
'
,
label
:
t
(
'
admin.accounts.status
.inactive
'
)
}
])
// Form options
const
protocolSelectOptions
=
[
{
value
:
'
http
'
,
label
:
'
HTTP
'
}
,
{
value
:
'
https
'
,
label
:
'
HTTPS
'
}
,
{
value
:
'
socks5
'
,
label
:
'
SOCKS
5
'
}
,
{
value
:
'
socks5h
'
,
label
:
'
SOCKS5H (服务端解析DNS)
'
}
]
const
protocolSelectOptions
=
computed
(()
=>
[
{
value
:
'
http
'
,
label
:
t
(
'
admin.proxies.protocols.http
'
)
}
,
{
value
:
'
https
'
,
label
:
t
(
'
admin.proxies.protocols.https
'
)
}
,
{
value
:
'
socks5
'
,
label
:
t
(
'
admin.proxies.protocols.socks
5
'
)
}
,
{
value
:
'
socks5h
'
,
label
:
t
(
'
admin.proxies.protocols.socks5h
'
)
}
]
)
const
editStatusOptions
=
computed
(()
=>
[
{
value
:
'
active
'
,
label
:
t
(
'
common
.active
'
)
}
,
{
value
:
'
inactive
'
,
label
:
t
(
'
common
.inactive
'
)
}
{
value
:
'
active
'
,
label
:
t
(
'
admin.accounts.status
.active
'
)
}
,
{
value
:
'
inactive
'
,
label
:
t
(
'
admin.accounts.status
.inactive
'
)
}
])
const
proxies
=
ref
<
Proxy
[]
>
([])
...
...
frontend/src/views/admin/RedeemView.vue
View file @
fb313356
...
...
@@ -112,7 +112,7 @@
: 'badge-primary'
]"
>
{{
value
}}
{{
t
(
'
admin.redeem.types.
'
+
value
)
}}
</span>
</
template
>
...
...
@@ -120,7 +120,7 @@
<span
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
<template
v-if=
"row.type === 'balance'"
>
$
{{
value
.
toFixed
(
2
)
}}
</
template
>
<
template
v-else-if=
"row.type === 'subscription'"
>
{{
row
.
validity_days
||
30
}}{{
t
(
'
admin.redeem.days
'
)
}}
{{
row
.
validity_days
||
30
}}
{{
t
(
'
admin.redeem.days
'
)
}}
<span
v-if=
"row.group"
class=
"ml-1 text-xs text-gray-500 dark:text-gray-400"
>
(
{{
row
.
group
.
name
}}
)
</span
>
...
...
@@ -140,7 +140,7 @@
: 'badge-danger'
]"
>
{{
value
}}
{{
t
(
'
admin.redeem.status.
'
+
value
)
}}
</span>
</
template
>
...
...
frontend/src/views/admin/SettingsView.vue
View file @
fb313356
...
...
@@ -775,7 +775,10 @@ const form = reactive<SettingsForm>({
turnstile_enabled
:
false
,
turnstile_site_key
:
''
,
turnstile_secret_key
:
''
,
turnstile_secret_key_configured
:
false
turnstile_secret_key_configured
:
false
,
// Identity patch (Claude -> Gemini)
enable_identity_patch
:
true
,
identity_patch_prompt
:
''
})
function
handleLogoUpload
(
event
:
Event
)
{
...
...
frontend/src/views/admin/SubscriptionsView.vue
View file @
fb313356
<
template
>
<AppLayout>
<TablePageLayout>
<!-- Page Header Actions -->
<template
#actions
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"loadSubscriptions"
:disabled=
"loading"
class=
"btn btn-secondary"
:title=
"t('common.refresh')"
>
<svg
:class=
"['h-5 w-5', loading ? 'animate-spin' : '']"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M16.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.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
<button
@
click=
"showAssignModal = true"
class=
"btn btn-primary"
>
<svg
class=
"mr-2 h-5 w-5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 4.5v15m7.5-7.5h-15"
/>
</svg>
{{
t
(
'
admin.subscriptions.assignSubscription
'
)
}}
</button>
</div>
</
template
>
<!-- Filters -->
<template
#filters
>
<div
class=
"flex flex-wrap gap-3"
>
<Select
v-model=
"filters.status"
:options=
"statusOptions"
:placeholder=
"t('admin.subscriptions.allStatus')"
class=
"w-40"
@
change=
"loadSubscriptions"
/>
<Select
v-model=
"filters.group_id"
:options=
"groupOptions"
:placeholder=
"t('admin.subscriptions.allGroups')"
class=
"w-48"
@
change=
"loadSubscriptions"
/>
</div>
<!-- Top Toolbar: Left (search + filters) / Right (actions) -->
<div
class=
"flex flex-wrap items-start justify-between gap-4"
>
<!-- Left: Fuzzy user search + filters (wrap to multiple lines) -->
<div
class=
"flex flex-1 flex-wrap items-center gap-3"
>
<!-- User Search -->
<div
class=
"relative w-full sm:flex-1 sm:min-w-[14rem] sm:max-w-md"
data-filter-user-search
>
<svg
class=
"absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
<input
v-model=
"filterUserKeyword"
type=
"text"
:placeholder=
"t('admin.users.searchUsers')"
class=
"input pl-10 pr-8"
@
input=
"debounceSearchFilterUsers"
@
focus=
"showFilterUserDropdown = true"
/>
<button
v-if=
"selectedFilterUser"
@
click=
"clearFilterUser"
type=
"button"
class=
"absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
:title=
"t('common.clear')"
>
<svg
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<!-- User Dropdown -->
<div
v-if=
"showFilterUserDropdown && (filterUserResults.length > 0 || filterUserKeyword)"
class=
"absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
>
<div
v-if=
"filterUserLoading"
class=
"px-4 py-3 text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
common.loading
'
)
}}
</div>
<div
v-else-if=
"filterUserResults.length === 0 && filterUserKeyword"
class=
"px-4 py-3 text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
common.noOptionsFound
'
)
}}
</div>
<button
v-for=
"user in filterUserResults"
:key=
"user.id"
type=
"button"
@
click=
"selectFilterUser(user)"
class=
"w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
user
.
email
}}
</span>
<span
class=
"ml-2 text-gray-500 dark:text-gray-400"
>
#
{{
user
.
id
}}
</span>
</button>
</div>
</div>
<!-- Filters -->
<div
class=
"w-full sm:w-40"
>
<Select
v-model=
"filters.status"
:options=
"statusOptions"
:placeholder=
"t('admin.subscriptions.allStatus')"
@
change=
"applyFilters"
/>
</div>
<div
class=
"w-full sm:w-48"
>
<Select
v-model=
"filters.group_id"
:options=
"groupOptions"
:placeholder=
"t('admin.subscriptions.allGroups')"
@
change=
"applyFilters"
/>
</div>
</div>
<!-- Right: Actions -->
<div
class=
"ml-auto flex flex-wrap items-center justify-end gap-3"
>
<button
@
click=
"loadSubscriptions"
:disabled=
"loading"
class=
"btn btn-secondary"
:title=
"t('common.refresh')"
>
<svg
:class=
"['h-5 w-5', loading ? 'animate-spin' : '']"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M16.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.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
<button
@
click=
"showAssignModal = true"
class=
"btn btn-primary"
>
<svg
class=
"mr-2 h-5 w-5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 4.5v15m7.5-7.5h-15"
/>
</svg>
{{
t
(
'
admin.subscriptions.assignSubscription
'
)
}}
</button>
</div>
</div>
</
template
>
<!-- Subscriptions Table -->
...
...
@@ -72,7 +153,7 @@
</span>
</div>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
row
.
user
?.
email
||
`User #${
row.user_id
}
`
row
.
user
?.
email
||
t
(
'
admin.redeem.userPrefix
'
,
{
id
:
row
.
user_id
}
)
}}
<
/span
>
<
/div
>
<
/template
>
...
...
@@ -338,7 +419,7 @@
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.subscriptions.form.user
'
)
}}
<
/label
>
<
div
class
=
"
relative
"
>
<
div
class
=
"
relative
"
data
-
assign
-
user
-
search
>
<
input
v
-
model
=
"
userSearchKeyword
"
type
=
"
text
"
...
...
@@ -555,6 +636,14 @@ const groups = ref<Group[]>([])
const
loading
=
ref
(
false
)
let
abortController
:
AbortController
|
null
=
null
// Toolbar user filter (fuzzy search -> select user_id)
const
filterUserKeyword
=
ref
(
''
)
const
filterUserResults
=
ref
<
SimpleUser
[]
>
([])
const
filterUserLoading
=
ref
(
false
)
const
showFilterUserDropdown
=
ref
(
false
)
const
selectedFilterUser
=
ref
<
SimpleUser
|
null
>
(
null
)
let
filterUserSearchTimeout
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
// User search state
const
userSearchKeyword
=
ref
(
''
)
const
userSearchResults
=
ref
<
SimpleUser
[]
>
([])
...
...
@@ -565,7 +654,8 @@ let userSearchTimeout: ReturnType<typeof setTimeout> | null = null
const
filters
=
reactive
({
status
:
''
,
group_id
:
''
group_id
:
''
,
user_id
:
null
as
number
|
null
}
)
const
pagination
=
reactive
({
page
:
1
,
...
...
@@ -604,6 +694,11 @@ const subscriptionGroupOptions = computed(() =>
.
map
((
g
)
=>
({
value
:
g
.
id
,
label
:
g
.
name
}
))
)
const
applyFilters
=
()
=>
{
pagination
.
page
=
1
loadSubscriptions
()
}
const
loadSubscriptions
=
async
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
...
...
@@ -614,12 +709,18 @@ const loadSubscriptions = async () => {
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
}
)
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
,
user_id
:
filters
.
user_id
||
undefined
}
,
{
signal
}
)
if
(
signal
.
aborted
||
abortController
!==
requestController
)
return
subscriptions
.
value
=
response
.
items
pagination
.
total
=
response
.
total
...
...
@@ -646,6 +747,57 @@ const loadGroups = async () => {
}
}
// Toolbar user filter search with debounce
const
debounceSearchFilterUsers
=
()
=>
{
if
(
filterUserSearchTimeout
)
{
clearTimeout
(
filterUserSearchTimeout
)
}
filterUserSearchTimeout
=
setTimeout
(
searchFilterUsers
,
300
)
}
const
searchFilterUsers
=
async
()
=>
{
const
keyword
=
filterUserKeyword
.
value
.
trim
()
// Clear active user filter if user modified the search keyword
if
(
selectedFilterUser
.
value
&&
keyword
!==
selectedFilterUser
.
value
.
email
)
{
selectedFilterUser
.
value
=
null
filters
.
user_id
=
null
applyFilters
()
}
if
(
!
keyword
)
{
filterUserResults
.
value
=
[]
return
}
filterUserLoading
.
value
=
true
try
{
filterUserResults
.
value
=
await
adminAPI
.
usage
.
searchUsers
(
keyword
)
}
catch
(
error
)
{
console
.
error
(
'
Failed to search users:
'
,
error
)
filterUserResults
.
value
=
[]
}
finally
{
filterUserLoading
.
value
=
false
}
}
const
selectFilterUser
=
(
user
:
SimpleUser
)
=>
{
selectedFilterUser
.
value
=
user
filterUserKeyword
.
value
=
user
.
email
showFilterUserDropdown
.
value
=
false
filters
.
user_id
=
user
.
id
applyFilters
()
}
const
clearFilterUser
=
()
=>
{
selectedFilterUser
.
value
=
null
filterUserKeyword
.
value
=
''
filterUserResults
.
value
=
[]
showFilterUserDropdown
.
value
=
false
filters
.
user_id
=
null
applyFilters
()
}
// User search with debounce
const
debounceSearchUsers
=
()
=>
{
if
(
userSearchTimeout
)
{
...
...
@@ -856,9 +1008,8 @@ const formatResetTime = (windowStart: string, period: 'daily' | 'weekly' | 'mont
// Handle click outside to close user dropdown
const
handleClickOutside
=
(
event
:
MouseEvent
)
=>
{
const
target
=
event
.
target
as
HTMLElement
if
(
!
target
.
closest
(
'
.relative
'
))
{
showUserDropdown
.
value
=
false
}
if
(
!
target
.
closest
(
'
[data-assign-user-search]
'
))
showUserDropdown
.
value
=
false
if
(
!
target
.
closest
(
'
[data-filter-user-search]
'
))
showFilterUserDropdown
.
value
=
false
}
onMounted
(()
=>
{
...
...
@@ -869,6 +1020,9 @@ onMounted(() => {
onUnmounted
(()
=>
{
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
if
(
filterUserSearchTimeout
)
{
clearTimeout
(
filterUserSearchTimeout
)
}
if
(
userSearchTimeout
)
{
clearTimeout
(
userSearchTimeout
)
}
...
...
frontend/src/views/admin/UsageView.vue
View file @
fb313356
<
template
>
<AppLayout>
<div
class=
"space-y-6"
>
<!-- Stats Cards -->
<div
class=
"grid grid-cols-2 gap-4 lg:grid-cols-4"
>
<!-- Total Requests -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30"
>
<svg
class=
"h-5 w-5 text-blue-600 dark:text-blue-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.totalRequests
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
usageStats
?.
total_requests
?.
toLocaleString
()
||
'
0
'
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.inSelectedRange
'
)
}}
</p>
</div>
</div>
</div>
<!-- Total Tokens -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30"
>
<svg
class=
"h-5 w-5 text-amber-600 dark:text-amber-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"
/>
</svg>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.totalTokens
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
formatTokens
(
usageStats
?.
total_tokens
||
0
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.in
'
)
}}
:
{{
formatTokens
(
usageStats
?.
total_input_tokens
||
0
)
}}
/
{{
t
(
'
usage.out
'
)
}}
:
{{
formatTokens
(
usageStats
?.
total_output_tokens
||
0
)
}}
</p>
</div>
</div>
</div>
<!-- Total Cost -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-green-100 p-2 dark:bg-green-900/30"
>
<svg
class=
"h-5 w-5 text-green-600 dark:text-green-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div
class=
"min-w-0 flex-1"
>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.totalCost
'
)
}}
</p>
<p
class=
"text-xl font-bold text-green-600 dark:text-green-400"
>
$
{{
(
usageStats
?.
total_actual_cost
||
0
).
toFixed
(
4
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
<span
class=
"line-through"
>
$
{{
(
usageStats
?.
total_cost
||
0
).
toFixed
(
4
)
}}
</span>
{{
t
(
'
usage.standardCost
'
)
}}
</p>
</div>
</div>
</div>
<!-- Average Duration -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30"
>
<svg
class=
"h-5 w-5 text-purple-600 dark:text-purple-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.avgDuration
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
formatDuration
(
usageStats
?.
average_duration_ms
||
0
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.perRequest
'
)
}}
</p>
</div>
</div>
</div>
</div>
<!-- Charts Section -->
<div
class=
"space-y-4"
>
<!-- Chart Controls -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-4"
>
<span
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.dashboard.granularity
'
)
}}
:
</span
>
<div
class=
"w-28"
>
<Select
v-model=
"granularity"
:options=
"granularityOptions"
@
change=
"onGranularityChange"
/>
</div>
</div>
</div>
<!-- Charts Grid -->
<div
class=
"grid grid-cols-1 gap-6 lg:grid-cols-2"
>
<ModelDistributionChart
:model-stats=
"modelStats"
:loading=
"chartsLoading"
/>
<TokenUsageTrend
:trend-data=
"trendData"
:loading=
"chartsLoading"
/>
</div>
</div>
<!-- Filters Section -->
<div
class=
"card"
>
<div
class=
"px-6 py-4"
>
<div
class=
"flex flex-wrap items-end gap-4"
>
<!-- User Search -->
<div
class=
"min-w-[200px]"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.usage.userFilter
'
)
}}
</label>
<div
class=
"relative"
>
<input
v-model=
"userSearchKeyword"
type=
"text"
class=
"input pr-8"
:placeholder=
"t('admin.usage.searchUserPlaceholder')"
@
input=
"debounceSearchUsers"
@
focus=
"showUserDropdown = true"
/>
<button
v-if=
"selectedUser"
@
click=
"clearUserFilter"
class=
"absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<!-- User Dropdown -->
<div
v-if=
"showUserDropdown && (userSearchResults.length > 0 || userSearchKeyword)"
class=
"absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
>
<div
v-if=
"userSearchLoading"
class=
"px-4 py-3 text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
common.loading
'
)
}}
</div>
<div
v-else-if=
"userSearchResults.length === 0 && userSearchKeyword"
class=
"px-4 py-3 text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
common.noOptionsFound
'
)
}}
</div>
<button
v-for=
"user in userSearchResults"
:key=
"user.id"
@
click=
"selectUser(user)"
class=
"w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
user
.
email
}}
</span>
<span
class=
"ml-2 text-gray-500 dark:text-gray-400"
>
#
{{
user
.
id
}}
</span>
</button>
</div>
</div>
</div>
<!-- API Key Filter -->
<div
class=
"min-w-[180px]"
>
<label
class=
"input-label"
>
{{
t
(
'
usage.apiKeyFilter
'
)
}}
</label>
<Select
v-model=
"filters.api_key_id"
:options=
"apiKeyOptions"
:placeholder=
"t('usage.allApiKeys')"
searchable
@
change=
"applyFilters"
/>
</div>
<!-- Model Filter -->
<div
class=
"min-w-[180px]"
>
<label
class=
"input-label"
>
{{
t
(
'
usage.model
'
)
}}
</label>
<Select
v-model=
"filters.model"
:options=
"modelOptions"
:placeholder=
"t('admin.usage.allModels')"
searchable
@
change=
"applyFilters"
/>
</div>
<!-- Account Filter -->
<div
class=
"min-w-[180px]"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.usage.account
'
)
}}
</label>
<Select
v-model=
"filters.account_id"
:options=
"accountOptions"
:placeholder=
"t('admin.usage.allAccounts')"
@
change=
"applyFilters"
/>
</div>
<!-- Stream Type Filter -->
<div
class=
"min-w-[150px]"
>
<label
class=
"input-label"
>
{{
t
(
'
usage.type
'
)
}}
</label>
<Select
v-model=
"filters.stream"
:options=
"streamOptions"
:placeholder=
"t('admin.usage.allTypes')"
@
change=
"applyFilters"
/>
</div>
<!-- Billing Type Filter -->
<div
class=
"min-w-[150px]"
>
<label
class=
"input-label"
>
{{
t
(
'
usage.billingType
'
)
}}
</label>
<Select
v-model=
"filters.billing_type"
:options=
"billingTypeOptions"
:placeholder=
"t('admin.usage.allBillingTypes')"
@
change=
"applyFilters"
/>
</div>
<!-- Group Filter -->
<div
class=
"min-w-[150px]"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.usage.group
'
)
}}
</label>
<Select
v-model=
"filters.group_id"
:options=
"groupOptions"
:placeholder=
"t('admin.usage.allGroups')"
@
change=
"applyFilters"
/>
</div>
<!-- Date Range Filter -->
<div>
<label
class=
"input-label"
>
{{
t
(
'
usage.timeRange
'
)
}}
</label>
<DateRangePicker
v-model:start-date=
"startDate"
v-model:end-date=
"endDate"
@
change=
"onDateRangeChange"
/>
</div>
<!-- Actions -->
<div
class=
"ml-auto flex items-center gap-3"
>
<button
@
click=
"resetFilters"
class=
"btn btn-secondary"
>
{{
t
(
'
common.reset
'
)
}}
</button>
<button
@
click=
"exportToExcel"
:disabled=
"exporting"
class=
"btn btn-primary"
>
{{
t
(
'
usage.exportExcel
'
)
}}
</button>
</div>
</div>
</div>
</div>
<!-- Table Section -->
<div
class=
"card overflow-hidden"
>
<div
class=
"overflow-auto"
>
<DataTable
:columns=
"columns"
:data=
"usageLogs"
:loading=
"loading"
>
<template
#cell-user
="
{ row }">
<div
class=
"text-sm"
>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
row
.
user
?.
email
||
'
-
'
}}
</span>
<span
class=
"ml-1 text-gray-500 dark:text-gray-400"
>
#
{{
row
.
user_id
}}
</span>
</div>
</
template
>
<
template
#cell-api_key=
"{ row }"
>
<span
class=
"text-sm text-gray-900 dark:text-white"
>
{{
row
.
api_key
?.
name
||
'
-
'
}}
</span>
</
template
>
<
template
#cell-account=
"{ row }"
>
<span
class=
"text-sm text-gray-900 dark:text-white"
>
{{
row
.
account
?.
name
||
'
-
'
}}
</span>
</
template
>
<
template
#cell-model=
"{ value }"
>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
value
}}
</span>
</
template
>
<
template
#cell-group=
"{ row }"
>
<span
v-if=
"row.group"
class=
"inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200"
>
{{
row
.
group
.
name
}}
</span>
<span
v-else
class=
"text-sm text-gray-400 dark:text-gray-500"
>
-
</span>
</
template
>
<
template
#cell-stream=
"{ row }"
>
<span
class=
"inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
:class=
"
row.stream
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
"
>
{{
row
.
stream
?
t
(
'
usage.stream
'
)
:
t
(
'
usage.sync
'
)
}}
</span>
</
template
>
<
template
#cell-tokens=
"{ row }"
>
<div
class=
"flex items-center gap-1.5"
>
<div
class=
"space-y-1.5 text-sm"
>
<!-- Input / Output Tokens -->
<div
class=
"flex items-center gap-2"
>
<!-- Input -->
<div
class=
"inline-flex items-center gap-1"
>
<svg
class=
"h-3.5 w-3.5 text-emerald-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M19 14l-7 7m0 0l-7-7m7 7V3"
/>
</svg>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
row
.
input_tokens
.
toLocaleString
()
}}
</span>
</div>
<!-- Output -->
<div
class=
"inline-flex items-center gap-1"
>
<svg
class=
"h-3.5 w-3.5 text-violet-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M5 10l7-7m0 0l7 7m-7-7v18"
/>
</svg>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
row
.
output_tokens
.
toLocaleString
()
}}
</span>
</div>
</div>
<!-- Cache Tokens (Read + Write) -->
<div
v-if=
"row.cache_read_tokens > 0 || row.cache_creation_tokens > 0"
class=
"flex items-center gap-2"
>
<!-- Cache Read -->
<div
v-if=
"row.cache_read_tokens > 0"
class=
"inline-flex items-center gap-1"
>
<svg
class=
"h-3.5 w-3.5 text-sky-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
/>
</svg>
<span
class=
"font-medium text-sky-600 dark:text-sky-400"
>
{{
formatCacheTokens
(
row
.
cache_read_tokens
)
}}
</span>
</div>
<!-- Cache Write -->
<div
v-if=
"row.cache_creation_tokens > 0"
class=
"inline-flex items-center gap-1"
>
<svg
class=
"h-3.5 w-3.5 text-amber-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
<span
class=
"font-medium text-amber-600 dark:text-amber-400"
>
{{
formatCacheTokens
(
row
.
cache_creation_tokens
)
}}
</span>
</div>
</div>
</div>
<!-- Token Detail Tooltip -->
<div
class=
"group relative"
@
mouseenter=
"showTokenTooltip($event, row)"
@
mouseleave=
"hideTokenTooltip"
>
<div
class=
"flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50"
>
<svg
class=
"h-3 w-3 text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400"
fill=
"currentColor"
viewBox=
"0 0 20 20"
>
<path
fill-rule=
"evenodd"
d=
"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clip-rule=
"evenodd"
/>
</svg>
</div>
</div>
</div>
</
template
>
<
template
#cell-cost=
"{ row }"
>
<div
class=
"flex items-center gap-1.5 text-sm"
>
<span
class=
"font-medium text-green-600 dark:text-green-400"
>
$
{{
row
.
actual_cost
.
toFixed
(
6
)
}}
</span>
<!-- Cost Detail Tooltip -->
<div
class=
"group relative"
@
mouseenter=
"showTooltip($event, row)"
@
mouseleave=
"hideTooltip"
>
<div
class=
"flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50"
>
<svg
class=
"h-3 w-3 text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400"
fill=
"currentColor"
viewBox=
"0 0 20 20"
>
<path
fill-rule=
"evenodd"
d=
"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clip-rule=
"evenodd"
/>
</svg>
</div>
</div>
</div>
</
template
>
<
template
#cell-billing_type=
"{ row }"
>
<span
class=
"inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
:class=
"
row.billing_type === 1
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'
"
>
{{
row
.
billing_type
===
1
?
t
(
'
usage.subscription
'
)
:
t
(
'
usage.balance
'
)
}}
</span>
</
template
>
<
template
#cell-first_token=
"{ row }"
>
<span
v-if=
"row.first_token_ms != null"
class=
"text-sm text-gray-600 dark:text-gray-400"
>
{{
formatDuration
(
row
.
first_token_ms
)
}}
</span>
<span
v-else
class=
"text-sm text-gray-400 dark:text-gray-500"
>
-
</span>
</
template
>
<
template
#cell-duration=
"{ row }"
>
<span
class=
"text-sm text-gray-600 dark:text-gray-400"
>
{{
formatDuration
(
row
.
duration_ms
)
}}
</span>
</
template
>
<
template
#cell-created_at=
"{ value }"
>
<span
class=
"text-sm text-gray-600 dark:text-gray-400"
>
{{
formatDateTime
(
value
)
}}
</span>
</
template
>
<
template
#cell-request_id=
"{ row }"
>
<div
v-if=
"row.request_id"
class=
"flex items-center gap-1.5 max-w-[120px]"
>
<span
class=
"font-mono text-xs text-gray-500 dark:text-gray-400 truncate"
:title=
"row.request_id"
>
{{
row
.
request_id
}}
</span>
<button
@
click=
"copyRequestId(row.request_id)"
class=
"flex-shrink-0 rounded p-0.5 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700"
:class=
"
copiedRequestId === row.request_id
? 'text-green-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
"
:title=
"copiedRequestId === row.request_id ? t('keys.copied') : t('keys.copyToClipboard')"
>
<svg
v-if=
"copiedRequestId === row.request_id"
class=
"h-3.5 w-3.5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M5 13l4 4L19 7"
/>
</svg>
<svg
v-else
class=
"h-3.5 w-3.5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</button>
</div>
<span
v-else
class=
"text-gray-400 dark:text-gray-500"
>
-
</span>
</
template
>
<
template
#empty
>
<EmptyState
:message=
"t('usage.noRecords')"
/>
</
template
>
</DataTable>
</div>
</div>
<!-- Pagination -->
<Pagination
v-if=
"pagination.total > 0"
:page=
"pagination.page"
:total=
"pagination.total"
:page-size=
"pagination.page_size"
@
update:page=
"handlePageChange"
@
update:pageSize=
"handlePageSizeChange"
/>
<UsageStatsCards
:stats=
"usageStats"
/>
<UsageFilters
v-model=
"filters"
v-model:startDate=
"startDate"
v-model:endDate=
"endDate"
:exporting=
"exporting"
@
change=
"applyFilters"
@
reset=
"resetFilters"
@
export=
"exportToExcel"
/>
<UsageTable
:data=
"usageLogs"
:loading=
"loading"
/>
<Pagination
v-if=
"pagination.total > 0"
:page=
"pagination.page"
:total=
"pagination.total"
:page-size=
"pagination.page_size"
@
update:page=
"handlePageChange"
@
update:pageSize=
"handlePageSizeChange"
/>
</div>
</AppLayout>
<ExportProgressDialog
:show=
"exportProgress.show"
:progress=
"exportProgress.progress"
:current=
"exportProgress.current"
:total=
"exportProgress.total"
:estimated-time=
"exportProgress.estimatedTime"
@
cancel=
"cancelExport"
/>
<!-- Token Tooltip Portal -->
<Teleport
to=
"body"
>
<div
v-if=
"tokenTooltipVisible"
class=
"fixed z-[9999] pointer-events-none -translate-y-1/2"
:style=
"{
left: tokenTooltipPosition.x + 'px',
top: tokenTooltipPosition.y + 'px'
}"
>
<div
class=
"whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
>
<div
class=
"space-y-1.5"
>
<!-- Token Breakdown -->
<div
class=
"mb-2 border-b border-gray-700 pb-1.5"
>
<div
class=
"text-xs font-semibold text-gray-300 mb-1"
>
Token 明细
</div>
<div
v-if=
"tokenTooltipData && tokenTooltipData.input_tokens > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{ t('admin.usage.inputTokens') }}
</span>
<span
class=
"font-medium text-white"
>
{{ tokenTooltipData.input_tokens.toLocaleString() }}
</span>
</div>
<div
v-if=
"tokenTooltipData && tokenTooltipData.output_tokens > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{ t('admin.usage.outputTokens') }}
</span>
<span
class=
"font-medium text-white"
>
{{ tokenTooltipData.output_tokens.toLocaleString() }}
</span>
</div>
<div
v-if=
"tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{ t('admin.usage.cacheCreationTokens') }}
</span>
<span
class=
"font-medium text-white"
>
{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}
</span>
</div>
<div
v-if=
"tokenTooltipData && tokenTooltipData.cache_read_tokens > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{ t('admin.usage.cacheReadTokens') }}
</span>
<span
class=
"font-medium text-white"
>
{{ tokenTooltipData.cache_read_tokens.toLocaleString() }}
</span>
</div>
</div>
<!-- Total -->
<div
class=
"flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5"
>
<span
class=
"text-gray-400"
>
{{ t('usage.totalTokens') }}
</span>
<span
class=
"font-semibold text-blue-400"
>
{{ ((tokenTooltipData?.input_tokens || 0) + (tokenTooltipData?.output_tokens || 0) + (tokenTooltipData?.cache_creation_tokens || 0) + (tokenTooltipData?.cache_read_tokens || 0)).toLocaleString() }}
</span>
</div>
</div>
<!-- Tooltip Arrow (left side) -->
<div
class=
"absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"
></div>
</div>
</div>
</Teleport>
<!-- Tooltip Portal -->
<Teleport
to=
"body"
>
<div
v-if=
"tooltipVisible"
class=
"fixed z-[9999] pointer-events-none -translate-y-1/2"
:style=
"{
left: tooltipPosition.x + 'px',
top: tooltipPosition.y + 'px'
}"
>
<div
class=
"whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
>
<div
class=
"space-y-1.5"
>
<!-- Cost Breakdown -->
<div
class=
"mb-2 border-b border-gray-700 pb-1.5"
>
<div
class=
"text-xs font-semibold text-gray-300 mb-1"
>
成本明细
</div>
<div
v-if=
"tooltipData && tooltipData.input_cost > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{ t('admin.usage.inputCost') }}
</span>
<span
class=
"font-medium text-white"
>
${{ tooltipData.input_cost.toFixed(6) }}
</span>
</div>
<div
v-if=
"tooltipData && tooltipData.output_cost > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{ t('admin.usage.outputCost') }}
</span>
<span
class=
"font-medium text-white"
>
${{ tooltipData.output_cost.toFixed(6) }}
</span>
</div>
<div
v-if=
"tooltipData && tooltipData.cache_creation_cost > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{ t('admin.usage.cacheCreationCost') }}
</span>
<span
class=
"font-medium text-white"
>
${{ tooltipData.cache_creation_cost.toFixed(6) }}
</span>
</div>
<div
v-if=
"tooltipData && tooltipData.cache_read_cost > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{ t('admin.usage.cacheReadCost') }}
</span>
<span
class=
"font-medium text-white"
>
${{ tooltipData.cache_read_cost.toFixed(6) }}
</span>
</div>
</div>
<!-- Rate and Summary -->
<div
class=
"flex items-center justify-between gap-6"
>
<span
class=
"text-gray-400"
>
{{ t('usage.rate') }}
</span>
<span
class=
"font-semibold text-blue-400"
>
{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x
</span
>
</div>
<div
class=
"flex items-center justify-between gap-6"
>
<span
class=
"text-gray-400"
>
{{ t('usage.original') }}
</span>
<span
class=
"font-medium text-white"
>
${{ tooltipData?.total_cost.toFixed(6) }}
</span>
</div>
<div
class=
"flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5"
>
<span
class=
"text-gray-400"
>
{{ t('usage.billed') }}
</span>
<span
class=
"font-semibold text-green-400"
>
${{ tooltipData?.actual_cost.toFixed(6) }}
</span
>
</div>
</div>
<!-- Tooltip Arrow (left side) -->
<div
class=
"absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"
></div>
</div>
</div>
</Teleport>
<UsageExportProgress
:show=
"exportProgress.show"
:progress=
"exportProgress.progress"
:current=
"exportProgress.current"
:total=
"exportProgress.total"
:estimated-time=
"exportProgress.estimatedTime"
@
cancel=
"cancelExport"
/>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
reactive
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
*
as
XLSX
from
'
xlsx
'
import
{
saveAs
}
from
'
file-saver
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
adminUsageAPI
}
from
'
@/api/admin/usage
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
{
formatDateTime
}
from
'
@/utils/format
'
import
Select
from
'
@/components/common/Select.vue
'
import
DateRangePicker
from
'
@/components/common/DateRangePicker.vue
'
import
ModelDistributionChart
from
'
@/components/charts/ModelDistributionChart.vue
'
import
TokenUsageTrend
from
'
@/components/charts/TokenUsageTrend.vue
'
import
ExportProgressDialog
from
'
@/components/common/ExportProgressDialog.vue
'
import
type
{
UsageLog
,
TrendDataPoint
,
ModelStat
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
type
{
SimpleUser
,
SimpleApiKey
,
AdminUsageStatsResponse
,
AdminUsageQueryParams
}
from
'
@/api/admin/usage
'
import
{
ref
,
reactive
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
*
as
XLSX
from
'
xlsx
'
;
import
{
saveAs
}
from
'
file-saver
'
import
{
useAppStore
}
from
'
@/stores/app
'
;
import
{
adminAPI
}
from
'
@/api/admin
'
;
import
{
adminUsageAPI
}
from
'
@/api/admin/usage
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
;
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
UsageStatsCards
from
'
@/components/admin/usage/UsageStatsCards.vue
'
;
import
UsageFilters
from
'
@/components/admin/usage/UsageFilters.vue
'
import
UsageTable
from
'
@/components/admin/usage/UsageTable.vue
'
;
import
UsageExportProgress
from
'
@/components/admin/usage/UsageExportProgress.vue
'
import
type
{
UsageLog
}
from
'
@/types
'
;
import
type
{
AdminUsageStatsResponse
,
AdminUsageQueryParams
}
from
'
@/api/admin/usage
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
{
copyToClipboard
:
clipboardCopy
}
=
useClipboard
()
// Tooltip state
const
tooltipVisible
=
ref
(
false
)
const
tooltipPosition
=
ref
({
x
:
0
,
y
:
0
})
const
tooltipData
=
ref
<
UsageLog
|
null
>
(
null
)
// Token tooltip state
const
tokenTooltipVisible
=
ref
(
false
)
const
tokenTooltipPosition
=
ref
({
x
:
0
,
y
:
0
})
const
tokenTooltipData
=
ref
<
UsageLog
|
null
>
(
null
)
// Request ID copy state
const
copiedRequestId
=
ref
<
string
|
null
>
(
null
)
// Usage stats from API
const
usageStats
=
ref
<
AdminUsageStatsResponse
|
null
>
(
null
)
// Chart data
const
trendData
=
ref
<
TrendDataPoint
[]
>
([])
const
modelStats
=
ref
<
ModelStat
[]
>
([])
const
chartsLoading
=
ref
(
false
)
const
granularity
=
ref
<
'
day
'
|
'
hour
'
>
(
'
day
'
)
// Granularity options for Select component
const
granularityOptions
=
computed
(()
=>
[
{
value
:
'
day
'
,
label
:
t
(
'
admin.dashboard.day
'
)
},
{
value
:
'
hour
'
,
label
:
t
(
'
admin.dashboard.hour
'
)
}
])
const
columns
=
computed
<
Column
[]
>
(()
=>
[
{
key
:
'
user
'
,
label
:
t
(
'
admin.usage.user
'
),
sortable
:
false
},
{
key
:
'
api_key
'
,
label
:
t
(
'
usage.apiKeyFilter
'
),
sortable
:
false
},
{
key
:
'
account
'
,
label
:
t
(
'
admin.usage.account
'
),
sortable
:
false
},
{
key
:
'
model
'
,
label
:
t
(
'
usage.model
'
),
sortable
:
true
},
{
key
:
'
group
'
,
label
:
t
(
'
admin.usage.group
'
),
sortable
:
false
},
{
key
:
'
stream
'
,
label
:
t
(
'
usage.type
'
),
sortable
:
false
},
{
key
:
'
tokens
'
,
label
:
t
(
'
usage.tokens
'
),
sortable
:
false
},
{
key
:
'
cost
'
,
label
:
t
(
'
usage.cost
'
),
sortable
:
false
},
{
key
:
'
billing_type
'
,
label
:
t
(
'
usage.billingType
'
),
sortable
:
false
},
{
key
:
'
first_token
'
,
label
:
t
(
'
usage.firstToken
'
),
sortable
:
false
},
{
key
:
'
duration
'
,
label
:
t
(
'
usage.duration
'
),
sortable
:
false
},
{
key
:
'
created_at
'
,
label
:
t
(
'
usage.time
'
),
sortable
:
true
},
{
key
:
'
request_id
'
,
label
:
t
(
'
admin.usage.requestId
'
),
sortable
:
false
}
])
const
usageLogs
=
ref
<
UsageLog
[]
>
([])
const
apiKeys
=
ref
<
SimpleApiKey
[]
>
([])
const
models
=
ref
<
string
[]
>
([])
const
accounts
=
ref
<
any
[]
>
([])
const
groups
=
ref
<
any
[]
>
([])
const
loading
=
ref
(
false
)
let
abortController
:
AbortController
|
null
=
null
let
exportAbortController
:
AbortController
|
null
=
null
const
exporting
=
ref
(
false
)
const
exportProgress
=
reactive
({
show
:
false
,
progress
:
0
,
current
:
0
,
total
:
0
,
estimatedTime
:
''
})
// User search state
const
userSearchKeyword
=
ref
(
''
)
const
userSearchResults
=
ref
<
SimpleUser
[]
>
([])
const
userSearchLoading
=
ref
(
false
)
const
showUserDropdown
=
ref
(
false
)
const
selectedUser
=
ref
<
SimpleUser
|
null
>
(
null
)
let
searchTimeout
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
// API Key options computed from loaded keys
const
apiKeyOptions
=
computed
(()
=>
{
return
[
{
value
:
null
,
label
:
t
(
'
usage.allApiKeys
'
)
},
...
apiKeys
.
value
.
map
((
key
)
=>
({
value
:
key
.
id
,
label
:
key
.
name
}))
]
})
// Model options
const
modelOptions
=
computed
(()
=>
{
return
[
{
value
:
null
,
label
:
t
(
'
admin.usage.allModels
'
)
},
...
models
.
value
.
map
((
model
)
=>
({
value
:
model
,
label
:
model
}))
]
})
// Account options
const
accountOptions
=
computed
(()
=>
{
return
[
{
value
:
null
,
label
:
t
(
'
admin.usage.allAccounts
'
)
},
...
accounts
.
value
.
map
((
account
)
=>
({
value
:
account
.
id
,
label
:
account
.
name
}))
]
})
// Stream type options
const
streamOptions
=
computed
(()
=>
[
{
value
:
null
,
label
:
t
(
'
admin.usage.allTypes
'
)
},
{
value
:
true
,
label
:
t
(
'
usage.stream
'
)
},
{
value
:
false
,
label
:
t
(
'
usage.sync
'
)
}
])
// Billing type options
const
billingTypeOptions
=
computed
(()
=>
[
{
value
:
null
,
label
:
t
(
'
admin.usage.allBillingTypes
'
)
},
{
value
:
0
,
label
:
t
(
'
usage.balance
'
)
},
{
value
:
1
,
label
:
t
(
'
usage.subscription
'
)
}
])
// Group options
const
groupOptions
=
computed
(()
=>
{
return
[
{
value
:
null
,
label
:
t
(
'
admin.usage.allGroups
'
)
},
...
groups
.
value
.
map
((
group
)
=>
({
value
:
group
.
id
,
label
:
group
.
name
}))
]
})
// 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
// Use tomorrow as end date to handle timezone differences between client and server
// e.g., when server is in Asia/Shanghai and client is in America/Chicago
const
now
=
new
Date
()
const
tomorrow
=
new
Date
(
now
)
tomorrow
.
setDate
(
tomorrow
.
getDate
()
+
1
)
const
weekAgo
=
new
Date
(
now
)
weekAgo
.
setDate
(
weekAgo
.
getDate
()
-
6
)
// Date range state
const
startDate
=
ref
(
formatLocalDate
(
weekAgo
))
const
endDate
=
ref
(
formatLocalDate
(
tomorrow
))
const
filters
=
ref
<
AdminUsageQueryParams
>
({
user_id
:
undefined
,
api_key_id
:
undefined
,
account_id
:
undefined
,
group_id
:
undefined
,
model
:
undefined
,
stream
:
undefined
,
billing_type
:
undefined
,
start_date
:
undefined
,
end_date
:
undefined
})
// Initialize filters with date range
filters
.
value
.
start_date
=
startDate
.
value
filters
.
value
.
end_date
=
endDate
.
value
// User search with debounce
const
debounceSearchUsers
=
()
=>
{
if
(
searchTimeout
)
{
clearTimeout
(
searchTimeout
)
}
searchTimeout
=
setTimeout
(
searchUsers
,
300
)
}
const
searchUsers
=
async
()
=>
{
const
keyword
=
userSearchKeyword
.
value
.
trim
()
if
(
!
keyword
)
{
userSearchResults
.
value
=
[]
return
}
userSearchLoading
.
value
=
true
try
{
userSearchResults
.
value
=
await
adminAPI
.
usage
.
searchUsers
(
keyword
)
}
catch
(
error
)
{
console
.
error
(
'
Failed to search users:
'
,
error
)
userSearchResults
.
value
=
[]
}
finally
{
userSearchLoading
.
value
=
false
}
}
const
selectUser
=
async
(
user
:
SimpleUser
)
=>
{
selectedUser
.
value
=
user
userSearchKeyword
.
value
=
user
.
email
showUserDropdown
.
value
=
false
filters
.
value
.
user_id
=
user
.
id
filters
.
value
.
api_key_id
=
undefined
// Load API keys for selected user
await
loadApiKeys
(
user
.
id
)
applyFilters
()
}
const
clearUserFilter
=
()
=>
{
selectedUser
.
value
=
null
userSearchKeyword
.
value
=
''
userSearchResults
.
value
=
[]
filters
.
value
.
user_id
=
undefined
filters
.
value
.
api_key_id
=
undefined
apiKeys
.
value
=
[]
loadApiKeys
()
applyFilters
()
}
const
loadApiKeys
=
async
(
userId
?:
number
)
=>
{
try
{
apiKeys
.
value
=
await
adminAPI
.
usage
.
searchApiKeys
(
userId
)
}
catch
(
error
)
{
console
.
error
(
'
Failed to load API keys:
'
,
error
)
apiKeys
.
value
=
[]
}
}
// Handle date range change from DateRangePicker
const
onDateRangeChange
=
(
range
:
{
startDate
:
string
endDate
:
string
preset
:
string
|
null
})
=>
{
filters
.
value
.
start_date
=
range
.
startDate
filters
.
value
.
end_date
=
range
.
endDate
applyFilters
()
}
const
pagination
=
ref
({
page
:
1
,
page_size
:
20
,
total
:
0
,
pages
:
0
})
const
formatDuration
=
(
ms
:
number
):
string
=>
{
if
(
ms
<
1000
)
return
`
${
ms
.
toFixed
(
0
)}
ms`
return
`
${(
ms
/
1000
).
toFixed
(
2
)}
s`
}
const
formatTokens
=
(
value
:
number
):
string
=>
{
if
(
value
>=
1
_000_000_000
)
{
return
`
${(
value
/
1
_000_000_000
).
toFixed
(
2
)}
B`
}
else
if
(
value
>=
1
_000_000
)
{
return
`
${(
value
/
1
_000_000
).
toFixed
(
2
)}
M`
}
else
if
(
value
>=
1
_000
)
{
return
`
${(
value
/
1
_000
).
toFixed
(
2
)}
K`
}
return
value
.
toLocaleString
()
}
// Compact format for cache tokens in table cells
const
formatCacheTokens
=
(
value
:
number
):
string
=>
{
if
(
value
>=
1
_000_000
)
{
return
`
${(
value
/
1
_000_000
).
toFixed
(
1
)}
M`
}
else
if
(
value
>=
1
_000
)
{
return
`
${(
value
/
1
_000
).
toFixed
(
1
)}
K`
}
return
value
.
toLocaleString
()
}
const
copyRequestId
=
async
(
requestId
:
string
)
=>
{
const
success
=
await
clipboardCopy
(
requestId
,
t
(
'
admin.usage.requestIdCopied
'
))
if
(
success
)
{
copiedRequestId
.
value
=
requestId
setTimeout
(()
=>
{
copiedRequestId
.
value
=
null
},
800
)
}
}
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
formatExportTimestamp
=
(
date
:
Date
):
string
=>
{
const
pad
=
(
value
:
number
)
=>
String
(
value
).
padStart
(
2
,
'
0
'
)
return
`
${
date
.
getFullYear
()}
-
${
pad
(
date
.
getMonth
()
+
1
)}
-
${
pad
(
date
.
getDate
())}
_
${
pad
(
date
.
getHours
())}
-
${
pad
(
date
.
getMinutes
())}
-
${
pad
(
date
.
getSeconds
())}
`
}
const
formatRemainingTime
=
(
ms
:
number
):
string
=>
{
const
totalSeconds
=
Math
.
max
(
0
,
Math
.
round
(
ms
/
1000
))
const
hours
=
Math
.
floor
(
totalSeconds
/
3600
)
const
minutes
=
Math
.
floor
((
totalSeconds
%
3600
)
/
60
)
const
seconds
=
totalSeconds
%
60
const
parts
=
[]
if
(
hours
>
0
)
{
parts
.
push
(
`
${
hours
}
h`
)
}
if
(
minutes
>
0
||
hours
>
0
)
{
parts
.
push
(
`
${
minutes
}
m`
)
}
parts
.
push
(
`
${
seconds
}
s`
)
return
parts
.
join
(
'
'
)
}
const
updateExportProgress
=
(
current
:
number
,
total
:
number
,
startedAt
:
number
)
=>
{
exportProgress
.
current
=
current
exportProgress
.
total
=
total
exportProgress
.
progress
=
total
>
0
?
Math
.
min
(
100
,
Math
.
round
((
current
/
total
)
*
100
))
:
0
if
(
current
>
0
&&
total
>
0
)
{
const
elapsedMs
=
Date
.
now
()
-
startedAt
const
remainingMs
=
Math
.
max
(
0
,
Math
.
round
((
elapsedMs
/
current
)
*
(
total
-
current
)))
exportProgress
.
estimatedTime
=
formatRemainingTime
(
remainingMs
)
}
else
{
exportProgress
.
estimatedTime
=
''
}
}
const
loadUsageLogs
=
async
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
}
const
controller
=
new
AbortController
()
abortController
=
controller
const
{
signal
}
=
controller
loading
.
value
=
true
try
{
const
params
:
AdminUsageQueryParams
=
{
page
:
pagination
.
value
.
page
,
page_size
:
pagination
.
value
.
page_size
,
...
filters
.
value
}
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
}
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
()
=>
{
try
{
const
stats
=
await
adminAPI
.
usage
.
getStats
({
user_id
:
filters
.
value
.
user_id
,
api_key_id
:
filters
.
value
.
api_key_id
?
Number
(
filters
.
value
.
api_key_id
)
:
undefined
,
start_date
:
filters
.
value
.
start_date
||
startDate
.
value
,
end_date
:
filters
.
value
.
end_date
||
endDate
.
value
})
usageStats
.
value
=
stats
}
catch
(
error
)
{
console
.
error
(
'
Failed to load usage stats:
'
,
error
)
}
}
const
loadChartData
=
async
()
=>
{
chartsLoading
.
value
=
true
const
usageStats
=
ref
<
AdminUsageStatsResponse
|
null
>
(
null
);
const
usageLogs
=
ref
<
UsageLog
[]
>
([]);
const
loading
=
ref
(
false
);
const
exporting
=
ref
(
false
)
let
abortController
:
AbortController
|
null
=
null
;
let
exportAbortController
:
AbortController
|
null
=
null
const
exportProgress
=
reactive
({
show
:
false
,
progress
:
0
,
current
:
0
,
total
:
0
,
estimatedTime
:
''
})
const
formatLD
=
(
d
:
Date
)
=>
d
.
toISOString
().
split
(
'
T
'
)[
0
]
const
now
=
new
Date
();
const
weekAgo
=
new
Date
(
Date
.
now
()
-
6
*
86400000
)
const
startDate
=
ref
(
formatLD
(
weekAgo
));
const
endDate
=
ref
(
formatLD
(
now
))
const
filters
=
ref
<
AdminUsageQueryParams
>
({
user_id
:
undefined
,
model
:
undefined
,
group_id
:
undefined
,
start_date
:
startDate
.
value
,
end_date
:
endDate
.
value
})
const
pagination
=
reactive
({
page
:
1
,
page_size
:
20
,
total
:
0
})
const
loadLogs
=
async
()
=>
{
abortController
?.
abort
();
const
c
=
new
AbortController
();
abortController
=
c
;
loading
.
value
=
true
try
{
const
params
=
{
start_date
:
filters
.
value
.
start_date
||
startDate
.
value
,
end_date
:
filters
.
value
.
end_date
||
endDate
.
value
,
granularity
:
granularity
.
value
,
user_id
:
filters
.
value
.
user_id
,
api_key_id
:
filters
.
value
.
api_key_id
?
Number
(
filters
.
value
.
api_key_id
)
:
undefined
}
const
[
trendResponse
,
modelResponse
]
=
await
Promise
.
all
([
adminAPI
.
dashboard
.
getUsageTrend
(
params
),
adminAPI
.
dashboard
.
getModelStats
({
start_date
:
params
.
start_date
,
end_date
:
params
.
end_date
,
user_id
:
params
.
user_id
,
api_key_id
:
params
.
api_key_id
})
])
trendData
.
value
=
trendResponse
.
trend
||
[]
modelStats
.
value
=
modelResponse
.
models
||
[]
}
catch
(
error
)
{
console
.
error
(
'
Failed to load chart data:
'
,
error
)
}
finally
{
chartsLoading
.
value
=
false
}
}
const
onGranularityChange
=
()
=>
{
loadChartData
()
}
const
applyFilters
=
()
=>
{
pagination
.
value
.
page
=
1
loadUsageLogs
()
loadUsageStats
()
loadChartData
()
}
// Load filter options
const
loadFilterOptions
=
async
()
=>
{
try
{
const
[
accountsResponse
,
groupsResponse
]
=
await
Promise
.
all
([
adminAPI
.
accounts
.
list
(
1
,
1000
),
adminAPI
.
groups
.
list
(
1
,
1000
)
])
accounts
.
value
=
accountsResponse
.
items
||
[]
groups
.
value
=
groupsResponse
.
items
||
[]
}
catch
(
error
)
{
console
.
error
(
'
Failed to load filter options:
'
,
error
)
}
await
loadModelOptions
()
}
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
>
()
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
=
()
=>
{
selectedUser
.
value
=
null
userSearchKeyword
.
value
=
''
userSearchResults
.
value
=
[]
apiKeys
.
value
=
[]
filters
.
value
=
{
user_id
:
undefined
,
api_key_id
:
undefined
,
account_id
:
undefined
,
group_id
:
undefined
,
model
:
undefined
,
stream
:
undefined
,
billing_type
:
undefined
,
start_date
:
undefined
,
end_date
:
undefined
}
granularity
.
value
=
'
day
'
// Reset date range to default (last 7 days, with tomorrow as end to handle timezone differences)
const
now
=
new
Date
()
const
tomorrowDate
=
new
Date
(
now
)
tomorrowDate
.
setDate
(
tomorrowDate
.
getDate
()
+
1
)
const
weekAgo
=
new
Date
(
now
)
weekAgo
.
setDate
(
weekAgo
.
getDate
()
-
6
)
startDate
.
value
=
formatLocalDate
(
weekAgo
)
endDate
.
value
=
formatLocalDate
(
tomorrowDate
)
filters
.
value
.
start_date
=
startDate
.
value
filters
.
value
.
end_date
=
endDate
.
value
pagination
.
value
.
page
=
1
loadApiKeys
()
loadUsageLogs
()
loadUsageStats
()
loadChartData
()
}
const
handlePageChange
=
(
page
:
number
)
=>
{
pagination
.
value
.
page
=
page
loadUsageLogs
()
}
const
handlePageSizeChange
=
(
pageSize
:
number
)
=>
{
pagination
.
value
.
page_size
=
pageSize
pagination
.
value
.
page
=
1
loadUsageLogs
()
}
const
cancelExport
=
()
=>
{
if
(
!
exporting
.
value
)
{
return
}
exportAbortController
?.
abort
()
const
res
=
await
adminAPI
.
usage
.
list
({
page
:
pagination
.
page
,
page_size
:
pagination
.
page_size
,
...
filters
.
value
},
{
signal
:
c
.
signal
})
if
(
!
c
.
signal
.
aborted
)
{
usageLogs
.
value
=
res
.
items
;
pagination
.
total
=
res
.
total
}
}
catch
{}
finally
{
if
(
abortController
===
c
)
loading
.
value
=
false
}
}
const
loadStats
=
async
()
=>
{
try
{
const
s
=
await
adminAPI
.
usage
.
getStats
(
filters
.
value
);
usageStats
.
value
=
s
}
catch
{}
}
const
applyFilters
=
()
=>
{
pagination
.
page
=
1
;
loadLogs
();
loadStats
()
}
const
resetFilters
=
()
=>
{
startDate
.
value
=
formatLD
(
weekAgo
);
endDate
.
value
=
formatLD
(
now
);
filters
.
value
=
{
start_date
:
startDate
.
value
,
end_date
:
endDate
.
value
};
applyFilters
()
}
const
handlePageChange
=
(
p
:
number
)
=>
{
pagination
.
page
=
p
;
loadLogs
()
}
const
handlePageSizeChange
=
(
s
:
number
)
=>
{
pagination
.
page_size
=
s
;
pagination
.
page
=
1
;
loadLogs
()
}
const
cancelExport
=
()
=>
exportAbortController
?.
abort
()
const
exportToExcel
=
async
()
=>
{
if
(
pagination
.
value
.
total
===
0
)
{
appStore
.
showWarning
(
t
(
'
usage.noDataToExport
'
))
return
}
if
(
exporting
.
value
)
{
return
}
exporting
.
value
=
true
exportProgress
.
show
=
true
exportProgress
.
progress
=
0
exportProgress
.
current
=
0
exportProgress
.
total
=
pagination
.
value
.
total
exportProgress
.
estimatedTime
=
''
const
startedAt
=
Date
.
now
()
const
controller
=
new
AbortController
()
exportAbortController
=
controller
if
(
exporting
.
value
)
return
;
exporting
.
value
=
true
;
exportProgress
.
show
=
true
const
c
=
new
AbortController
();
exportAbortController
=
c
try
{
const
allLogs
:
UsageLog
[]
=
[]
const
pageSize
=
100
let
page
=
1
let
total
=
pagination
.
value
.
total
const
all
:
UsageLog
[]
=
[];
let
p
=
1
;
let
total
=
pagination
.
total
while
(
true
)
{
const
params
:
AdminUsageQueryParams
=
{
page
,
page_size
:
pageSize
,
...
filters
.
value
}
const
response
=
await
adminUsageAPI
.
list
(
params
,
{
signal
:
controller
.
signal
})
if
(
controller
.
signal
.
aborted
)
{
break
}
if
(
page
===
1
)
{
total
=
response
.
total
exportProgress
.
total
=
total
}
if
(
response
.
items
?.
length
)
{
allLogs
.
push
(...
response
.
items
)
}
updateExportProgress
(
allLogs
.
length
,
total
,
startedAt
)
if
(
allLogs
.
length
>=
total
||
response
.
items
.
length
<
pageSize
)
{
break
}
page
+=
1
}
if
(
controller
.
signal
.
aborted
)
{
appStore
.
showInfo
(
t
(
'
usage.exportCancelled
'
))
return
const
res
=
await
adminUsageAPI
.
list
({
page
:
p
,
page_size
:
100
,
...
filters
.
value
},
{
signal
:
c
.
signal
})
if
(
c
.
signal
.
aborted
)
break
;
if
(
p
===
1
)
{
total
=
res
.
total
;
exportProgress
.
total
=
total
}
if
(
res
.
items
?.
length
)
all
.
push
(...
res
.
items
)
exportProgress
.
current
=
all
.
length
;
exportProgress
.
progress
=
total
>
0
?
Math
.
min
(
100
,
Math
.
round
(
all
.
length
/
total
*
100
))
:
0
if
(
all
.
length
>=
total
||
res
.
items
.
length
<
100
)
break
;
p
++
}
if
(
allLogs
.
length
===
0
)
{
appStore
.
showWarning
(
t
(
'
usage.noDataToExport
'
)
)
return
if
(
!
c
.
signal
.
aborted
)
{
const
ws
=
XLSX
.
utils
.
json_to_sheet
(
all
);
const
wb
=
XLSX
.
utils
.
book_new
();
XLSX
.
utils
.
book_append_sheet
(
wb
,
ws
,
'
Usage
'
)
saveAs
(
new
Blob
([
XLSX
.
write
(
wb
,
{
bookType
:
'
xlsx
'
,
type
:
'
array
'
})],
{
type
:
'
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
'
}),
`usage_
${
Date
.
now
()}
.xlsx`
)
appStore
.
showSuccess
(
'
Export Success
'
)
}
const
headers
=
[
'
User
'
,
'
API Key
'
,
'
Model
'
,
'
Type
'
,
'
Input Tokens
'
,
'
Output Tokens
'
,
'
Cache Read Tokens
'
,
'
Cache Write Tokens
'
,
'
Total Cost
'
,
'
Billing Type
'
,
'
Duration (ms)
'
,
'
Time
'
]
const
rows
=
allLogs
.
map
((
log
)
=>
[
log
.
user
?.
email
||
''
,
log
.
api_key
?.
name
||
''
,
log
.
model
,
log
.
stream
?
'
Stream
'
:
'
Sync
'
,
log
.
input_tokens
,
log
.
output_tokens
,
log
.
cache_read_tokens
,
log
.
cache_creation_tokens
,
Number
(
log
.
total_cost
.
toFixed
(
6
)),
log
.
billing_type
===
1
?
'
Subscription
'
:
'
Balance
'
,
log
.
duration_ms
,
log
.
created_at
])
const
worksheet
=
XLSX
.
utils
.
aoa_to_sheet
([
headers
,
...
rows
])
const
workbook
=
XLSX
.
utils
.
book_new
()
XLSX
.
utils
.
book_append_sheet
(
workbook
,
worksheet
,
'
Usage
'
)
const
excelBuffer
=
XLSX
.
write
(
workbook
,
{
bookType
:
'
xlsx
'
,
type
:
'
array
'
})
const
blob
=
new
Blob
([
excelBuffer
],
{
type
:
'
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
'
})
saveAs
(
blob
,
`admin_usage_
${
formatExportTimestamp
(
new
Date
())}
.xlsx`
)
appStore
.
showSuccess
(
t
(
'
usage.exportExcelSuccess
'
))
}
catch
(
error
)
{
if
(
controller
.
signal
.
aborted
||
isAbortError
(
error
))
{
appStore
.
showInfo
(
t
(
'
usage.exportCancelled
'
))
return
}
appStore
.
showError
(
t
(
'
usage.exportExcelFailed
'
))
console
.
error
(
'
Excel export failed:
'
,
error
)
}
finally
{
if
(
exportAbortController
===
controller
)
{
exportAbortController
=
null
}
exporting
.
value
=
false
exportProgress
.
show
=
false
}
}
// Click outside to close dropdown
const
handleClickOutside
=
(
event
:
MouseEvent
)
=>
{
const
target
=
event
.
target
as
HTMLElement
if
(
!
target
.
closest
(
'
.relative
'
))
{
showUserDropdown
.
value
=
false
}
}
// Tooltip functions
const
showTooltip
=
(
event
:
MouseEvent
,
row
:
UsageLog
)
=>
{
const
target
=
event
.
currentTarget
as
HTMLElement
const
rect
=
target
.
getBoundingClientRect
()
tooltipData
.
value
=
row
tooltipPosition
.
value
.
x
=
rect
.
right
+
8
tooltipPosition
.
value
.
y
=
rect
.
top
+
rect
.
height
/
2
tooltipVisible
.
value
=
true
}
const
hideTooltip
=
()
=>
{
tooltipVisible
.
value
=
false
tooltipData
.
value
=
null
}
catch
{
appStore
.
showError
(
'
Export Failed
'
)
}
finally
{
if
(
exportAbortController
===
c
)
{
exportAbortController
=
null
;
exporting
.
value
=
false
;
exportProgress
.
show
=
false
}
}
}
// Token tooltip functions
const
showTokenTooltip
=
(
event
:
MouseEvent
,
row
:
UsageLog
)
=>
{
const
target
=
event
.
currentTarget
as
HTMLElement
const
rect
=
target
.
getBoundingClientRect
()
tokenTooltipData
.
value
=
row
tokenTooltipPosition
.
value
.
x
=
rect
.
right
+
8
tokenTooltipPosition
.
value
.
y
=
rect
.
top
+
rect
.
height
/
2
tokenTooltipVisible
.
value
=
true
}
const
hideTokenTooltip
=
()
=>
{
tokenTooltipVisible
.
value
=
false
tokenTooltipData
.
value
=
null
}
onMounted
(()
=>
{
loadFilterOptions
()
loadApiKeys
()
loadUsageLogs
()
loadUsageStats
()
loadChartData
()
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
})
onUnmounted
(()
=>
{
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
if
(
searchTimeout
)
{
clearTimeout
(
searchTimeout
)
}
if
(
abortController
)
{
abortController
.
abort
()
}
if
(
exportAbortController
)
{
exportAbortController
.
abort
()
}
})
</
script
>
onMounted
(()
=>
{
loadLogs
();
loadStats
()
})
onUnmounted
(()
=>
{
abortController
?.
abort
();
exportAbortController
?.
abort
()
})
</
script
>
\ No newline at end of file
frontend/src/views/admin/UsersView.vue
View file @
fb313356
...
...
@@ -3,11 +3,11 @@
<TablePageLayout>
<!-- Single Row: Search, Filters, and Actions -->
<template
#filters
>
<div
class=
"flex
flex-col gap-4 lg:flex-row lg:
items-center
lg:
justify-between"
>
<div
class=
"flex
w-full flex-wrap-reverse
items-center justify-between
gap-4
"
>
<!-- Left: Search + Active Filters -->
<div
class=
"flex flex-1 flex-wrap items-center gap-3"
>
<div
class=
"flex
min-w-[280px]
flex-1 flex-wrap
content-start
items-center gap-3"
>
<!-- Search Box -->
<div
class=
"relative w-64"
>
<div
class=
"relative
w-full sm:
w-64"
>
<svg
class=
"absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
fill=
"none"
...
...
@@ -31,52 +31,37 @@
</div>
<!-- Role Filter (visible when enabled) -->
<div
v-if=
"visibleFilters.has('role')"
class=
"
relative
"
>
<
s
elect
<div
v-if=
"visibleFilters.has('role')"
class=
"
w-full sm:w-32
"
>
<
S
elect
v-model=
"filters.role"
:options=
"[
{ value: '', label: t('admin.users.allRoles') },
{ value: 'admin', label: t('admin.users.admin') },
{ value: 'user', label: t('admin.users.user') }
]"
@change="applyFilter"
class=
"input w-32 cursor-pointer appearance-none pr-8"
>
<option
value=
""
>
{{
t
(
'
admin.users.allRoles
'
)
}}
</option>
<option
value=
"admin"
>
{{
t
(
'
admin.users.admin
'
)
}}
</option>
<option
value=
"user"
>
{{
t
(
'
admin.users.user
'
)
}}
</option>
</select>
<svg
class=
"pointer-events-none absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
/>
</div>
<!-- Status Filter (visible when enabled) -->
<div
v-if=
"visibleFilters.has('status')"
class=
"
relative
"
>
<
s
elect
<div
v-if=
"visibleFilters.has('status')"
class=
"
w-full sm:w-32
"
>
<
S
elect
v-model=
"filters.status"
:options=
"[
{ value: '', label: t('admin.users.allStatus') },
{ value: 'active', label: t('common.active') },
{ value: 'disabled', label: t('admin.users.disabled') }
]"
@change="applyFilter"
class=
"input w-32 cursor-pointer appearance-none pr-8"
>
<option
value=
""
>
{{
t
(
'
admin.users.allStatus
'
)
}}
</option>
<option
value=
"active"
>
{{
t
(
'
common.active
'
)
}}
</option>
<option
value=
"disabled"
>
{{
t
(
'
admin.users.disabled
'
)
}}
</option>
</select>
<svg
class=
"pointer-events-none absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
/>
</div>
<!-- Dynamic Attribute Filters -->
<template
v-for=
"(value, attrId) in activeAttributeFilters"
:key=
"attrId"
>
<div
v-if=
"visibleFilters.has(`attr_$
{attrId}`)" class="relative">
<div
v-if=
"visibleFilters.has(`attr_$
{attrId}`)"
class="relative w-full sm:w-36"
>
<!-- Text/Email/URL/Textarea/Date type: styled input -->
<input
v-if=
"['text', 'textarea', 'email', 'url', 'date'].includes(getAttributeDefinition(Number(attrId))?.type || 'text')"
...
...
@@ -84,7 +69,7 @@
@
input=
"(e) => updateAttributeFilter(Number(attrId), (e.target as HTMLInputElement).value)"
@
keyup.enter=
"applyFilter"
:placeholder=
"getAttributeDefinitionName(Number(attrId))"
class=
"input w-
36
"
class=
"input w-
full
"
/>
<!-- Number type: number input -->
<input
...
...
@@ -94,33 +79,20 @@
@
input=
"(e) => updateAttributeFilter(Number(attrId), (e.target as HTMLInputElement).value)"
@
keyup.enter=
"applyFilter"
:placeholder=
"getAttributeDefinitionName(Number(attrId))"
class=
"input w-
32
"
class=
"input w-
full
"
/>
<!-- Select/Multi-select type -->
<template
v-else-if=
"['select', 'multi_select'].includes(getAttributeDefinition(Number(attrId))?.type || '')"
>
<select
:value=
"value"
@
change=
"(e) =>
{ updateAttributeFilter(Number(attrId), (e.target as HTMLSelectElement).value); applyFilter() }"
class="input w-36 cursor-pointer appearance-none pr-8"
>
<option
value=
""
>
{{
getAttributeDefinitionName
(
Number
(
attrId
))
}}
</option>
<option
v-for=
"opt in getAttributeDefinition(Number(attrId))?.options || []"
:key=
"opt.value"
:value=
"opt.value"
>
{{
opt
.
label
}}
</option>
</select>
<svg
class=
"pointer-events-none absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
<div
class=
"w-full"
>
<Select
:model-value=
"value"
:options=
"[
{ value: '', label: getAttributeDefinitionName(Number(attrId)) },
...(getAttributeDefinition(Number(attrId))?.options || [])
]"
@update:model-value="(val) => { updateAttributeFilter(Number(attrId), String(val ?? '')); applyFilter() }"
/>
</div>
</
template
>
<!-- Fallback -->
<input
...
...
@@ -129,14 +101,14 @@
@
input=
"(e) => updateAttributeFilter(Number(attrId), (e.target as HTMLInputElement).value)"
@
keyup.enter=
"applyFilter"
:placeholder=
"getAttributeDefinitionName(Number(attrId))"
class=
"input w-
36
"
class=
"input w-
full
"
/>
</div>
</template>
</div>
<!-- Right: Actions and Settings -->
<div
class=
"
flex items-center
gap-3"
>
<div
class=
"
ml-auto flex max-w-full flex-wrap items-center justify-end
gap-3"
>
<!-- Refresh Button -->
<button
@
click=
"loadUsers"
...
...
@@ -337,7 +309,7 @@
<
template
#cell-role=
"{ value }"
>
<span
:class=
"['badge', value === 'admin' ? 'badge-purple' : 'badge-gray']"
>
{{
value
}}
{{
t
(
'
admin.users.roles.
'
+
value
)
}}
</span>
</
template
>
...
...
@@ -426,8 +398,7 @@
<!-- Edit Button -->
<button
@
click=
"handleEdit(row)"
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')"
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"
>
<svg
class=
"h-4 w-4"
...
...
@@ -442,17 +413,60 @@
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>
<!-- Toggle Status Button (not for admin) -->
<button
v-if=
"row.role !== 'admin'"
@
click=
"handleToggleStatus(row)"
:class=
"[
'flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors',
row.status === 'active'
? 'hover:bg-orange-50 hover:text-orange-600 dark:hover:bg-orange-900/20 dark:hover:text-orange-400'
: '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"
/>
</svg>
<svg
v-else
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span
class=
"text-xs"
>
{{
row
.
status
===
'
active
'
?
t
(
'
admin.users.disable
'
)
:
t
(
'
admin.users.enable
'
)
}}
</span>
</button>
<!-- More Actions Menu Trigger -->
<button
: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=
"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-
5
w-
5
"
class=
"h-
4
w-
4
"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
...
...
@@ -464,6 +478,7 @@
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 0zM18.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0z"
/>
</svg>
<span
class=
"text-xs"
>
{{
t
(
'
common.more
'
)
}}
</span>
</button>
</div>
</
template
>
...
...
@@ -550,33 +565,6 @@
<div
class=
"my-1 border-t border-gray-100 dark:border-dark-700"
></div>
<!-- Toggle Status (not for admin) -->
<button
v-if=
"user.role !== 'admin'"
@
click=
"handleToggleStatus(user); closeActionMenu()"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<svg
v-if=
"user.status === 'active'"
class=
"h-4 w-4 text-orange-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg>
<svg
v-else
class=
"h-4 w-4 text-green-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{{
user
.
status
===
'
active
'
?
t
(
'
admin.users.disable
'
)
:
t
(
'
admin.users.enable
'
)
}}
</button>
<!-- Delete (not for admin) -->
<button
v-if=
"user.role !== 'admin'"
...
...
@@ -594,808 +582,13 @@
</div>
</Teleport>
<!-- Create User Modal -->
<BaseDialog
:show=
"showCreateModal"
:title=
"t('admin.users.createUser')"
width=
"normal"
@
close=
"closeCreateModal"
>
<form
id=
"create-user-form"
@
submit.prevent=
"handleCreateUser"
class=
"space-y-5"
>
<div>
<label
class=
"input-label"
>
{{ t('admin.users.email') }}
</label>
<input
v-model=
"createForm.email"
type=
"email"
required
class=
"input"
:placeholder=
"t('admin.users.enterEmail')"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.users.password') }}
</label>
<div
class=
"flex gap-2"
>
<div
class=
"relative flex-1"
>
<input
v-model=
"createForm.password"
type=
"text"
required
class=
"input pr-10"
:placeholder=
"t('admin.users.enterPassword')"
/>
<!-- Copy Password Button -->
<button
v-if=
"createForm.password"
type=
"button"
@
click=
"copyPassword"
class=
"absolute right-2 top-1/2 -translate-y-1/2 rounded-lg p-1 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700"
:class=
"
passwordCopied
? 'text-green-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
"
:title=
"passwordCopied ? t('keys.copied') : t('admin.users.copyPassword')"
>
<svg
v-if=
"passwordCopied"
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M5 13l4 4L19 7"
/>
</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=
"M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
</svg>
</button>
</div>
<!-- Generate Random Password Button -->
<button
type=
"button"
@
click=
"generateRandomPassword"
class=
"btn btn-secondary px-3"
:title=
"t('admin.users.generatePassword')"
>
<svg
class=
"h-5 w-5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M16.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.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
</div>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.users.username') }}
</label>
<input
v-model=
"createForm.username"
type=
"text"
class=
"input"
:placeholder=
"t('admin.users.enterUsername')"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.users.notes') }}
</label>
<textarea
v-model=
"createForm.notes"
rows=
"3"
class=
"input"
:placeholder=
"t('admin.users.enterNotes')"
></textarea>
<p
class=
"input-hint"
>
{{ t('admin.users.notesHint') }}
</p>
</div>
<div
class=
"grid grid-cols-2 gap-4"
>
<div>
<label
class=
"input-label"
>
{{ t('admin.users.columns.balance') }}
</label>
<input
v-model.number=
"createForm.balance"
type=
"number"
step=
"any"
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.users.columns.concurrency') }}
</label>
<input
v-model.number=
"createForm.concurrency"
type=
"number"
class=
"input"
/>
</div>
</div>
</form>
<
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"
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"
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.users.creating
'
)
:
t
(
'
common.create
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
<!-- Edit User Modal -->
<BaseDialog
:show=
"showEditModal"
:title=
"t('admin.users.editUser')"
width=
"normal"
@
close=
"closeEditModal"
>
<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"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.users.password') }}
</label>
<p
class=
"mb-1 text-xs text-gray-500 dark:text-dark-400"
>
{{ t('admin.users.leaveEmptyToKeep') }}
</p>
<div
class=
"flex gap-2"
>
<div
class=
"relative flex-1"
>
<input
v-model=
"editForm.password"
type=
"text"
class=
"input pr-10"
:placeholder=
"t('admin.users.enterNewPassword')"
/>
<!-- Copy Password Button -->
<button
v-if=
"editForm.password"
type=
"button"
@
click=
"copyEditPassword"
class=
"absolute right-2 top-1/2 -translate-y-1/2 rounded-lg p-1 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700"
:class=
"
editPasswordCopied
? 'text-green-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
"
:title=
"editPasswordCopied ? t('keys.copied') : t('admin.users.copyPassword')"
>
<svg
v-if=
"editPasswordCopied"
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M5 13l4 4L19 7"
/>
</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=
"M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
</svg>
</button>
</div>
<!-- Generate Random Password Button -->
<button
type=
"button"
@
click=
"generateEditPassword"
class=
"btn btn-secondary px-3"
:title=
"t('admin.users.generatePassword')"
>
<svg
class=
"h-5 w-5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M16.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.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
</div>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.users.username') }}
</label>
<input
v-model=
"editForm.username"
type=
"text"
class=
"input"
:placeholder=
"t('admin.users.enterUsername')"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.users.notes') }}
</label>
<textarea
v-model=
"editForm.notes"
rows=
"3"
class=
"input"
:placeholder=
"t('admin.users.enterNotes')"
></textarea>
<p
class=
"input-hint"
>
{{ t('admin.users.notesHint') }}
</p>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.users.columns.concurrency') }}
</label>
<input
v-model.number=
"editForm.concurrency"
type=
"number"
class=
"input"
/>
</div>
<!-- Custom Attributes -->
<UserAttributeForm
v-model=
"editForm.customAttributes"
:user-id=
"editingUser?.id"
/>
</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"
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"
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.users.updating
'
)
:
t
(
'
common.update
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
<!-- View API Keys Modal -->
<BaseDialog
:show=
"showApiKeysModal"
:title=
"t('admin.users.userApiKeys')"
width=
"wide"
@
close=
"closeApiKeysModal"
>
<div
v-if=
"viewingUser"
class=
"space-y-4"
>
<!-- User Info Header -->
<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"
>
<span
class=
"text-lg font-medium text-primary-700 dark:text-primary-300"
>
{{ viewingUser.email.charAt(0).toUpperCase() }}
</span>
</div>
<div>
<p
class=
"font-medium text-gray-900 dark:text-white"
>
{{ viewingUser.email }}
</p>
<p
class=
"text-sm text-gray-500 dark:text-dark-400"
>
{{ viewingUser.username }}
</p>
</div>
</div>
<!-- API Keys List -->
<div
v-if=
"loadingApiKeys"
class=
"flex justify-center py-8"
>
<svg
class=
"h-8 w-8 animate-spin text-primary-500"
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>
</div>
<div
v-else-if=
"userApiKeys.length === 0"
class=
"py-8 text-center"
>
<svg
class=
"mx-auto h-12 w-12 text-gray-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1"
>
<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>
<p
class=
"mt-2 text-sm text-gray-500 dark:text-dark-400"
>
{{ t('admin.users.noApiKeys') }}
</p>
</div>
<div
v-else
class=
"max-h-96 space-y-3 overflow-y-auto"
>
<div
v-for=
"key in userApiKeys"
:key=
"key.id"
class=
"rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-600 dark:bg-dark-800"
>
<div
class=
"flex items-start justify-between"
>
<div
class=
"min-w-0 flex-1"
>
<div
class=
"mb-1 flex items-center gap-2"
>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{ key.name }}
</span>
<span
:class=
"[
'badge text-xs',
key.status === 'active' ? 'badge-success' : 'badge-danger'
]"
>
{{ key.status }}
</span>
</div>
<p
class=
"truncate font-mono text-sm text-gray-500 dark:text-dark-400"
>
{{ key.key.substring(0, 20) }}...{{ key.key.substring(key.key.length - 8) }}
</p>
</div>
</div>
<div
class=
"mt-3 flex flex-wrap gap-4 text-xs text-gray-500 dark:text-dark-400"
>
<div
class=
"flex items-center gap-1"
>
<svg
class=
"h-3.5 w-3.5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
/>
</svg>
<span
>
{{ t('admin.users.group') }}:
{{ key.group?.name || t('admin.users.none') }}
</span
>
</div>
<div
class=
"flex items-center gap-1"
>
<svg
class=
"h-3.5 w-3.5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
/>
</svg>
<span
>
{{ t('admin.users.columns.created') }}: {{ formatDateTime(key.created_at) }}
</span
>
</div>
</div>
</div>
</div>
</div>
<
template
#footer
>
<div
class=
"flex justify-end"
>
<button
@
click=
"closeApiKeysModal"
type=
"button"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
<!-- Allowed Groups Modal -->
<BaseDialog
:show=
"showAllowedGroupsModal"
:title=
"t('admin.users.setAllowedGroups')"
width=
"normal"
@
close=
"closeAllowedGroupsModal"
>
<div
v-if=
"allowedGroupsUser"
class=
"space-y-4"
>
<!-- User Info Header -->
<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"
>
<span
class=
"text-lg font-medium text-primary-700 dark:text-primary-300"
>
{{ allowedGroupsUser.email.charAt(0).toUpperCase() }}
</span>
</div>
<div>
<p
class=
"font-medium text-gray-900 dark:text-white"
>
{{ allowedGroupsUser.email }}
</p>
</div>
</div>
<!-- Loading State -->
<div
v-if=
"loadingGroups"
class=
"flex justify-center py-8"
>
<svg
class=
"h-8 w-8 animate-spin text-primary-500"
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>
</div>
<!-- Groups Selection -->
<div
v-else
>
<p
class=
"mb-3 text-sm text-gray-600 dark:text-dark-400"
>
{{ t('admin.users.allowedGroupsHint') }}
</p>
<!-- Empty State -->
<div
v-if=
"standardGroups.length === 0"
class=
"py-6 text-center"
>
<svg
class=
"mx-auto h-12 w-12 text-gray-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1"
>
<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>
<p
class=
"mt-2 text-sm text-gray-500 dark:text-dark-400"
>
{{ t('admin.users.noStandardGroups') }}
</p>
</div>
<!-- Groups List -->
<div
v-else
class=
"max-h-64 space-y-2 overflow-y-auto"
>
<label
v-for=
"group in standardGroups"
:key=
"group.id"
class=
"flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 transition-colors hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700"
:class=
"{
'border-primary-300 bg-primary-50 dark:border-primary-700 dark:bg-primary-900/20':
selectedGroupIds.includes(group.id)
}"
>
<input
type=
"checkbox"
:value=
"group.id"
v-model=
"selectedGroupIds"
class=
"h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<div
class=
"min-w-0 flex-1"
>
<p
class=
"font-medium text-gray-900 dark:text-white"
>
{{ group.name }}
</p>
<p
v-if=
"group.description"
class=
"truncate text-sm text-gray-500 dark:text-dark-400"
>
{{ group.description }}
</p>
</div>
<div
class=
"flex items-center gap-2"
>
<span
class=
"badge badge-gray text-xs"
>
{{ group.platform }}
</span>
<span
v-if=
"group.is_exclusive"
class=
"badge badge-purple text-xs"
>
{{
t('admin.groups.exclusive')
}}
</span>
</div>
</label>
</div>
<!-- Clear Selection -->
<div
class=
"mt-4 border-t border-gray-200 pt-4 dark:border-dark-600"
>
<label
class=
"flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 transition-colors hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700"
:class=
"{
'border-green-300 bg-green-50 dark:border-green-700 dark:bg-green-900/20':
selectedGroupIds.length === 0
}"
>
<input
type=
"radio"
:checked=
"selectedGroupIds.length === 0"
@
change=
"selectedGroupIds = []"
class=
"h-4 w-4 border-gray-300 text-green-600 focus:ring-green-500"
/>
<div
class=
"flex-1"
>
<p
class=
"font-medium text-gray-900 dark:text-white"
>
{{ t('admin.users.allowAllGroups') }}
</p>
<p
class=
"text-sm text-gray-500 dark:text-dark-400"
>
{{ t('admin.users.allowAllGroupsHint') }}
</p>
</div>
</label>
</div>
</div>
</div>
<
template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"closeAllowedGroupsModal"
type=
"button"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
@
click=
"handleSaveAllowedGroups"
:disabled=
"savingAllowedGroups"
class=
"btn btn-primary"
>
<svg
v-if=
"savingAllowedGroups"
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>
{{
savingAllowedGroups
?
t
(
'
common.saving
'
)
:
t
(
'
common.save
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
<!-- Deposit/Withdraw Modal -->
<BaseDialog
:show=
"showBalanceModal"
:title=
"balanceOperation === 'add' ? t('admin.users.deposit') : t('admin.users.withdraw')"
width=
"narrow"
@
close=
"closeBalanceModal"
>
<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"
>
<span
class=
"text-lg font-medium text-primary-700 dark:text-primary-300"
>
{{ balanceUser.email.charAt(0).toUpperCase() }}
</span>
</div>
<div
class=
"flex-1"
>
<p
class=
"font-medium text-gray-900 dark:text-white"
>
{{ balanceUser.email }}
</p>
<p
class=
"text-sm text-gray-500 dark:text-dark-400"
>
{{ t('admin.users.currentBalance') }}: ${{ balanceUser.balance.toFixed(2) }}
</p>
</div>
</div>
<div>
<label
class=
"input-label"
>
{{
balanceOperation === 'add'
? t('admin.users.depositAmount')
: t('admin.users.withdrawAmount')
}}
</label>
<div
class=
"relative"
>
<div
class=
"absolute left-3 top-1/2 -translate-y-1/2 font-medium text-gray-500 dark:text-dark-400"
>
$
</div>
<input
v-model.number=
"balanceForm.amount"
type=
"number"
step=
"0.01"
min=
"0.01"
required
class=
"input pl-8"
:placeholder=
"balanceOperation === 'add' ? '10.00' : '5.00'"
/>
</div>
<p
class=
"input-hint"
>
{{ t('admin.users.amountHint') }}
</p>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.users.notes') }}
</label>
<textarea
v-model=
"balanceForm.notes"
rows=
"3"
class=
"input"
:placeholder=
"
balanceOperation === 'add'
? t('admin.users.depositNotesPlaceholder')
: t('admin.users.withdrawNotesPlaceholder')
"
></textarea>
<p
class=
"input-hint"
>
{{ t('admin.users.notesOptional') }}
</p>
</div>
<div
v-if=
"balanceForm.amount > 0"
class=
"rounded-xl border border-blue-200 bg-blue-50 p-4 dark:border-blue-800/50 dark:bg-blue-900/20"
>
<div
class=
"flex items-center justify-between text-sm"
>
<span
class=
"text-blue-700 dark:text-blue-300"
>
{{ t('admin.users.newBalance') }}:
</span>
<span
class=
"font-bold text-blue-900 dark:text-blue-100"
>
${{ calculateNewBalance().toFixed(2) }}
</span>
</div>
</div>
<div
v-if=
"balanceOperation === 'subtract' && calculateNewBalance() < 0"
class=
"rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
>
<div
class=
"flex items-center gap-2 text-sm text-red-700 dark:text-red-300"
>
<svg
class=
"h-5 w-5 flex-shrink-0"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
<span>
{{ t('admin.users.insufficientBalance') }}
</span>
</div>
</div>
</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 ||
balanceForm.amount
<
=
0
||
(balanceOperation =
==
'
subtract
'
&&
calculateNewBalance
()
<
0)
"
class=
"btn"
:class=
"
balanceOperation === 'add'
? 'bg-emerald-600 text-white hover:bg-emerald-700'
: 'btn-danger'
"
>
<svg
v-if=
"balanceSubmitting"
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>
{{
balanceSubmitting
?
balanceOperation
===
'
add
'
?
t
(
'
admin.users.depositing
'
)
:
t
(
'
admin.users.withdrawing
'
)
:
balanceOperation
===
'
add
'
?
t
(
'
admin.users.confirmDeposit
'
)
:
t
(
'
admin.users.confirmWithdraw
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
<!-- Delete Confirmation Dialog -->
<ConfirmDialog
:show=
"showDeleteDialog"
:title=
"t('admin.users.deleteUser')"
:message=
"t('admin.users.deleteConfirm', { email: deletingUser?.email })"
:confirm-text=
"t('common.delete')"
:cancel-text=
"t('common.cancel')"
:danger=
"true"
@
confirm=
"confirmDelete"
@
cancel=
"showDeleteDialog = false"
/>
<!-- User Attributes Config Modal -->
<UserAttributesConfigModal
:show=
"showAttributesModal"
@
close=
"handleAttributesModalClose"
/>
<ConfirmDialog
:show=
"showDeleteDialog"
:title=
"t('admin.users.deleteUser')"
:message=
"t('admin.users.deleteConfirm', { email: deletingUser?.email })"
:danger=
"true"
@
confirm=
"confirmDelete"
@
cancel=
"showDeleteDialog = false"
/>
<UserCreateModal
:show=
"showCreateModal"
@
close=
"showCreateModal = false"
@
success=
"loadUsers"
/>
<UserEditModal
:show=
"showEditModal"
:user=
"editingUser"
@
close=
"closeEditModal"
@
success=
"loadUsers"
/>
<UserApiKeysModal
:show=
"showApiKeysModal"
:user=
"viewingUser"
@
close=
"closeApiKeysModal"
/>
<UserAllowedGroupsModal
:show=
"showAllowedGroupsModal"
:user=
"allowedGroupsUser"
@
close=
"closeAllowedGroupsModal"
@
success=
"loadUsers"
/>
<UserBalanceModal
:show=
"showBalanceModal"
:user=
"balanceUser"
:operation=
"balanceOperation"
@
close=
"closeBalanceModal"
@
success=
"loadUsers"
/>
<UserAttributesConfigModal
:show=
"showAttributesModal"
@
close=
"handleAttributesModalClose"
/>
</AppLayout>
</template>
...
...
@@ -1403,27 +596,29 @@
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
'
import
{
formatDateTime
}
from
'
@/utils/format
'
const
{
t
}
=
useI18n
()
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
User
,
ApiKey
,
Group
,
UserAttributeValuesMap
,
UserAttributeDefinition
}
from
'
@/types
'
import
type
{
User
,
UserAttributeDefinition
}
from
'
@/types
'
import
type
{
BatchUserUsageStats
}
from
'
@/api/admin/dashboard
'
import
type
{
Column
}
from
'
@/components/common/types
'
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
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
GroupBadge
from
'
@/components/common/GroupBadge.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
UserAttributesConfigModal
from
'
@/components/user/UserAttributesConfigModal.vue
'
import
UserAttributeForm
from
'
@/components/user/UserAttributeForm.vue
'
import
UserCreateModal
from
'
@/components/admin/user/UserCreateModal.vue
'
import
UserEditModal
from
'
@/components/admin/user/UserEditModal.vue
'
import
UserApiKeysModal
from
'
@/components/admin/user/UserApiKeysModal.vue
'
import
UserAllowedGroupsModal
from
'
@/components/admin/user/UserAllowedGroupsModal.vue
'
import
UserBalanceModal
from
'
@/components/admin/user/UserBalanceModal.vue
'
const
appStore
=
useAppStore
()
const
{
copyToClipboard
:
clipboardCopy
}
=
useClipboard
()
// Generate dynamic attribute columns from enabled definitions
const
attributeColumns
=
computed
<
Column
[]
>
(()
=>
...
...
@@ -1648,13 +843,9 @@ const showEditModal = ref(false)
const
showDeleteDialog
=
ref
(
false
)
const
showApiKeysModal
=
ref
(
false
)
const
showAttributesModal
=
ref
(
false
)
const
submitting
=
ref
(
false
)
const
editingUser
=
ref
<
User
|
null
>
(
null
)
const
deletingUser
=
ref
<
User
|
null
>
(
null
)
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
...
...
@@ -1724,39 +915,11 @@ const handleClickOutside = (event: MouseEvent) => {
// Allowed groups modal state
const
showAllowedGroupsModal
=
ref
(
false
)
const
allowedGroupsUser
=
ref
<
User
|
null
>
(
null
)
const
standardGroups
=
ref
<
Group
[]
>
([])
const
selectedGroupIds
=
ref
<
number
[]
>
([])
const
loadingGroups
=
ref
(
false
)
const
savingAllowedGroups
=
ref
(
false
)
// Balance (Deposit/Withdraw) modal state
const
showBalanceModal
=
ref
(
false
)
const
balanceUser
=
ref
<
User
|
null
>
(
null
)
const
balanceOperation
=
ref
<
'
add
'
|
'
subtract
'
>
(
'
add
'
)
const
balanceSubmitting
=
ref
(
false
)
const
balanceForm
=
reactive
({
amount
:
0
,
notes
:
''
})
const
createForm
=
reactive
({
email
:
''
,
password
:
''
,
username
:
''
,
notes
:
''
,
balance
:
0
,
concurrency
:
1
})
const
editForm
=
reactive
({
email
:
''
,
password
:
''
,
username
:
''
,
notes
:
''
,
concurrency
:
1
,
customAttributes
:
{}
as
UserAttributeValuesMap
})
const
editPasswordCopied
=
ref
(
false
)
// 计算剩余天数
const
getDaysRemaining
=
(
expiresAt
:
string
):
number
=>
{
...
...
@@ -1766,45 +929,6 @@ const getDaysRemaining = (expiresAt: string): number => {
return
Math
.
ceil
(
diffMs
/
(
1000
*
60
*
60
*
24
))
}
const
generateRandomPasswordStr
=
()
=>
{
const
chars
=
'
ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*
'
let
password
=
''
for
(
let
i
=
0
;
i
<
16
;
i
++
)
{
password
+=
chars
.
charAt
(
Math
.
floor
(
Math
.
random
()
*
chars
.
length
))
}
return
password
}
const
generateRandomPassword
=
()
=>
{
createForm
.
password
=
generateRandomPasswordStr
()
}
const
generateEditPassword
=
()
=>
{
editForm
.
password
=
generateRandomPasswordStr
()
}
const
copyPassword
=
async
()
=>
{
if
(
!
createForm
.
password
)
return
const
success
=
await
clipboardCopy
(
createForm
.
password
,
t
(
'
admin.users.passwordCopied
'
))
if
(
success
)
{
passwordCopied
.
value
=
true
setTimeout
(()
=>
{
passwordCopied
.
value
=
false
},
2000
)
}
}
const
copyEditPassword
=
async
()
=>
{
if
(
!
editForm
.
password
)
return
const
success
=
await
clipboardCopy
(
editForm
.
password
,
t
(
'
admin.users.passwordCopied
'
))
if
(
success
)
{
editPasswordCopied
.
value
=
true
setTimeout
(()
=>
{
editPasswordCopied
.
value
=
false
},
2000
)
}
}
const
loadAttributeDefinitions
=
async
()
=>
{
try
{
attributeDefinitions
.
value
=
await
adminAPI
.
userAttributes
.
listEnabledDefinitions
()
...
...
@@ -1962,90 +1086,14 @@ const applyFilter = () => {
loadUsers
()
}
const
closeCreateModal
=
()
=>
{
showCreateModal
.
value
=
false
createForm
.
email
=
''
createForm
.
password
=
''
createForm
.
username
=
''
createForm
.
notes
=
''
createForm
.
balance
=
0
createForm
.
concurrency
=
1
passwordCopied
.
value
=
false
}
const
handleCreateUser
=
async
()
=>
{
submitting
.
value
=
true
try
{
await
adminAPI
.
users
.
create
(
createForm
)
appStore
.
showSuccess
(
t
(
'
admin.users.userCreated
'
))
closeCreateModal
()
loadUsers
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
message
||
error
.
response
?.
data
?.
detail
||
t
(
'
admin.users.failedToCreate
'
)
)
console
.
error
(
'
Error creating user:
'
,
error
)
}
finally
{
submitting
.
value
=
false
}
}
const
handleEdit
=
(
user
:
User
)
=>
{
editingUser
.
value
=
user
editForm
.
email
=
user
.
email
editForm
.
password
=
''
editForm
.
username
=
user
.
username
||
''
editForm
.
notes
=
user
.
notes
||
''
editForm
.
concurrency
=
user
.
concurrency
editForm
.
customAttributes
=
{}
editPasswordCopied
.
value
=
false
showEditModal
.
value
=
true
}
const
closeEditModal
=
()
=>
{
showEditModal
.
value
=
false
editingUser
.
value
=
null
editForm
.
password
=
''
editForm
.
customAttributes
=
{}
editPasswordCopied
.
value
=
false
}
const
handleUpdateUser
=
async
()
=>
{
if
(
!
editingUser
.
value
)
return
submitting
.
value
=
true
try
{
const
updateData
:
Record
<
string
,
any
>
=
{
email
:
editForm
.
email
,
username
:
editForm
.
username
,
notes
:
editForm
.
notes
,
concurrency
:
editForm
.
concurrency
}
if
(
editForm
.
password
.
trim
())
{
updateData
.
password
=
editForm
.
password
.
trim
()
}
await
adminAPI
.
users
.
update
(
editingUser
.
value
.
id
,
updateData
)
// Save custom attributes if any
if
(
Object
.
keys
(
editForm
.
customAttributes
).
length
>
0
)
{
await
adminAPI
.
userAttributes
.
updateUserAttributeValues
(
editingUser
.
value
.
id
,
editForm
.
customAttributes
)
}
appStore
.
showSuccess
(
t
(
'
admin.users.userUpdated
'
))
closeEditModal
()
loadUsers
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.users.failedToUpdate
'
))
console
.
error
(
'
Error updating user:
'
,
error
)
}
finally
{
submitting
.
value
=
false
}
}
const
handleToggleStatus
=
async
(
user
:
User
)
=>
{
...
...
@@ -2062,75 +1110,24 @@ const handleToggleStatus = async (user: User) => {
}
}
const
handleViewApiKeys
=
async
(
user
:
User
)
=>
{
const
handleViewApiKeys
=
(
user
:
User
)
=>
{
viewingUser
.
value
=
user
showApiKeysModal
.
value
=
true
loadingApiKeys
.
value
=
true
userApiKeys
.
value
=
[]
try
{
const
response
=
await
adminAPI
.
users
.
getUserApiKeys
(
user
.
id
)
userApiKeys
.
value
=
response
.
items
||
[]
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.users.failedToLoadApiKeys
'
))
console
.
error
(
'
Error loading user API keys:
'
,
error
)
}
finally
{
loadingApiKeys
.
value
=
false
}
}
const
closeApiKeysModal
=
()
=>
{
showApiKeysModal
.
value
=
false
viewingUser
.
value
=
null
userApiKeys
.
value
=
[]
}
// Allowed Groups functions
const
handleAllowedGroups
=
async
(
user
:
User
)
=>
{
const
handleAllowedGroups
=
(
user
:
User
)
=>
{
allowedGroupsUser
.
value
=
user
showAllowedGroupsModal
.
value
=
true
loadingGroups
.
value
=
true
standardGroups
.
value
=
[]
selectedGroupIds
.
value
=
user
.
allowed_groups
?
[...
user
.
allowed_groups
]
:
[]
try
{
const
allGroups
=
await
adminAPI
.
groups
.
getAll
()
// Only show standard type groups (subscription type groups are managed in /admin/subscriptions)
standardGroups
.
value
=
allGroups
.
filter
(
(
g
)
=>
g
.
subscription_type
===
'
standard
'
&&
g
.
status
===
'
active
'
)
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.users.failedToLoadGroups
'
))
console
.
error
(
'
Error loading groups:
'
,
error
)
}
finally
{
loadingGroups
.
value
=
false
}
}
const
closeAllowedGroupsModal
=
()
=>
{
showAllowedGroupsModal
.
value
=
false
allowedGroupsUser
.
value
=
null
standardGroups
.
value
=
[]
selectedGroupIds
.
value
=
[]
}
const
handleSaveAllowedGroups
=
async
()
=>
{
if
(
!
allowedGroupsUser
.
value
)
return
savingAllowedGroups
.
value
=
true
try
{
// null means allow all non-exclusive groups, empty array also means allow all
const
allowedGroups
=
selectedGroupIds
.
value
.
length
>
0
?
selectedGroupIds
.
value
:
null
await
adminAPI
.
users
.
update
(
allowedGroupsUser
.
value
.
id
,
{
allowed_groups
:
allowedGroups
})
appStore
.
showSuccess
(
t
(
'
admin.users.allowedGroupsUpdated
'
))
closeAllowedGroupsModal
()
loadUsers
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.users.failedToUpdateAllowedGroups
'
))
console
.
error
(
'
Error updating allowed groups:
'
,
error
)
}
finally
{
savingAllowedGroups
.
value
=
false
}
}
const
handleDelete
=
(
user
:
User
)
=>
{
...
...
@@ -2140,19 +1137,14 @@ const handleDelete = (user: User) => {
const
confirmDelete
=
async
()
=>
{
if
(
!
deletingUser
.
value
)
return
try
{
await
adminAPI
.
users
.
delete
(
deletingUser
.
value
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.users.userDeleted
'
))
appStore
.
showSuccess
(
t
(
'
common.success
'
))
showDeleteDialog
.
value
=
false
deletingUser
.
value
=
null
loadUsers
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
message
||
error
.
response
?.
data
?.
detail
||
t
(
'
admin.users.failedToDelete
'
)
)
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.users.failedToDelete
'
))
console
.
error
(
'
Error deleting user:
'
,
error
)
}
}
...
...
@@ -2160,68 +1152,19 @@ const confirmDelete = async () => {
const
handleDeposit
=
(
user
:
User
)
=>
{
balanceUser
.
value
=
user
balanceOperation
.
value
=
'
add
'
balanceForm
.
amount
=
0
balanceForm
.
notes
=
''
showBalanceModal
.
value
=
true
}
const
handleWithdraw
=
(
user
:
User
)
=>
{
balanceUser
.
value
=
user
balanceOperation
.
value
=
'
subtract
'
balanceForm
.
amount
=
0
balanceForm
.
notes
=
''
showBalanceModal
.
value
=
true
}
const
closeBalanceModal
=
()
=>
{
showBalanceModal
.
value
=
false
balanceUser
.
value
=
null
balanceForm
.
amount
=
0
balanceForm
.
notes
=
''
}
const
calculateNewBalance
=
()
=>
{
if
(
!
balanceUser
.
value
)
return
0
if
(
balanceOperation
.
value
===
'
add
'
)
{
return
balanceUser
.
value
.
balance
+
balanceForm
.
amount
}
else
{
return
balanceUser
.
value
.
balance
-
balanceForm
.
amount
}
}
const
handleBalanceSubmit
=
async
()
=>
{
if
(
!
balanceUser
.
value
||
balanceForm
.
amount
<=
0
)
return
balanceSubmitting
.
value
=
true
try
{
await
adminAPI
.
users
.
updateBalance
(
balanceUser
.
value
.
id
,
balanceForm
.
amount
,
balanceOperation
.
value
,
balanceForm
.
notes
)
const
successMsg
=
balanceOperation
.
value
===
'
add
'
?
t
(
'
admin.users.depositSuccess
'
)
:
t
(
'
admin.users.withdrawSuccess
'
)
appStore
.
showSuccess
(
successMsg
)
closeBalanceModal
()
loadUsers
()
}
catch
(
error
:
any
)
{
const
errorMsg
=
balanceOperation
.
value
===
'
add
'
?
t
(
'
admin.users.failedToDeposit
'
)
:
t
(
'
admin.users.failedToWithdraw
'
)
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
errorMsg
)
console
.
error
(
'
Error updating balance:
'
,
error
)
}
finally
{
balanceSubmitting
.
value
=
false
}
}
onMounted
(
async
()
=>
{
await
loadAttributeDefinitions
()
loadSavedFilters
()
...
...
frontend/src/views/setup/SetupWizardView.vue
View file @
fb313356
...
...
@@ -87,7 +87,7 @@
{{ t('setup.database.title') }}
</h2>
<p
class=
"mt-1 text-sm text-gray-500 dark:text-dark-400"
>
Connect to your PostgreSQL database
{{ t('setup.database.description') }}
</p>
</div>
...
...
@@ -145,12 +145,15 @@
</div>
<div>
<label
class=
"input-label"
>
{{ t('setup.database.sslMode') }}
</label>
<select
v-model=
"formData.database.sslmode"
class=
"input"
>
<option
value=
"disable"
>
{{ t('setup.database.ssl.disable') }}
</option>
<option
value=
"require"
>
{{ t('setup.database.ssl.require') }}
</option>
<option
value=
"verify-ca"
>
{{ t('setup.database.ssl.verifyCa') }}
</option>
<option
value=
"verify-full"
>
{{ t('setup.database.ssl.verifyFull') }}
</option>
</select>
<Select
v-model=
"formData.database.sslmode"
:options=
"[
{ value: 'disable', label: t('setup.database.ssl.disable') },
{ value: 'require', label: t('setup.database.ssl.require') },
{ value: 'verify-ca', label: t('setup.database.ssl.verifyCa') },
{ value: 'verify-full', label: t('setup.database.ssl.verifyFull') }
]"
/>
</div>
</div>
...
...
@@ -190,7 +193,11 @@
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M4.5 12.75l6 6 9-13.5"
/>
</svg>
{{
testingDb ? 'Testing...' : dbConnected ? 'Connection Successful' : 'Test Connection'
testingDb
? t('setup.status.testing')
: dbConnected
? t('setup.status.success')
: t('setup.status.testConnection')
}}
</button>
</div>
...
...
@@ -202,7 +209,7 @@
{{ t('setup.redis.title') }}
</h2>
<p
class=
"mt-1 text-sm text-gray-500 dark:text-dark-400"
>
Connect to your Redis server
{{ t('setup.redis.description') }}
</p>
</div>
...
...
@@ -285,10 +292,10 @@
</svg>
{{
testingRedis
?
'T
esting
...
'
?
t('setup.status.t
esting'
)
: redisConnected
?
'Connection S
uccess
ful
'
:
'T
est
Connection'
?
t('setup.status.s
uccess'
)
:
t('setup.status.t
estConnection'
)
}}
</button>
</div>
...
...
@@ -300,7 +307,7 @@
{{ t('setup.admin.title') }}
</h2>
<p
class=
"mt-1 text-sm text-gray-500 dark:text-dark-400"
>
Create your administrator account
{{ t('setup.admin.description') }}
</p>
</div>
...
...
@@ -348,7 +355,7 @@
{{ t('setup.ready.title') }}
</h2>
<p
class=
"mt-1 text-sm text-gray-500 dark:text-dark-400"
>
Review your configuration and complete setup
{{ t('setup.ready.description') }}
</p>
</div>
...
...
@@ -447,13 +454,13 @@
</svg>
<div>
<p
class=
"text-sm font-medium text-green-700 dark:text-green-400"
>
Installation
completed
!
{{ t('setup.status.
completed
') }}
</p>
<p
class=
"mt-1 text-sm text-green-600 dark:text-green-500"
>
{{
serviceReady
?
'Redirecting to login page...'
:
'Service is restarting, please wait...'
?
t('setup.status.redirecting')
:
t('setup.status.restarting')
}}
</p>
</div>
...
...
@@ -480,7 +487,7 @@
d=
"M15.75 19.5L8.25 12l7.5-7.5"
/>
</svg>
Previous
{{ t('common.back') }}
</button>
<div
v-else
></div>
...
...
@@ -490,7 +497,7 @@
:disabled=
"!canProceed"
class=
"btn btn-primary"
>
Next
{{ t('common.next') }}
<svg
class=
"ml-2 h-4 w-4"
fill=
"none"
...
...
@@ -528,7 +535,7 @@
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>
{{ installing ?
'Installing...' : 'C
omplete
Installation' }}
{{ installing ?
t('setup.status.installing') : t('setup.status.c
ompleteInstallation'
)
}}
</button>
</div>
</div>
...
...
@@ -540,15 +547,16 @@
import
{
ref
,
reactive
,
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
testDatabase
,
testRedis
,
install
,
type
InstallRequest
}
from
'
@/api/setup
'
import
Select
from
'
@/components/common/Select.vue
'
const
{
t
}
=
useI18n
()
const
steps
=
[
{
id
:
'
database
'
,
title
:
'
Database
'
},
{
id
:
'
redis
'
,
title
:
'
Redis
'
},
{
id
:
'
admin
'
,
title
:
'
Admin
'
},
{
id
:
'
complete
'
,
title
:
'
Complet
e
'
}
]
const
steps
=
computed
(()
=>
[
{
id
:
'
database
'
,
title
:
t
(
'
setup.database.title
'
)
},
{
id
:
'
redis
'
,
title
:
t
(
'
setup.redis.title
'
)
},
{
id
:
'
admin
'
,
title
:
t
(
'
setup.admin.title
'
)
},
{
id
:
'
complete
'
,
title
:
t
(
'
setup.ready.titl
e
'
)
}
]
)
const
currentStep
=
ref
(
0
)
const
errorMessage
=
ref
(
''
)
...
...
@@ -710,7 +718,6 @@ async function waitForServiceRestart() {
// If we reach here, service didn't restart in time
// Show a message to refresh manually
errorMessage
.
value
=
'
Service restart is taking longer than expected. Please refresh the page manually.
'
errorMessage
.
value
=
t
(
'
setup.status.timeout
'
)
}
</
script
>
frontend/src/views/user/DashboardView.vue
View file @
fb313356
<
template
>
<AppLayout>
<div
class=
"space-y-6"
>
<!-- Loading State -->
<div
v-if=
"loading"
class=
"flex items-center justify-center py-12"
>
<LoadingSpinner
/>
</div>
<div
v-if=
"loading"
class=
"flex items-center justify-center py-12"
><LoadingSpinner
/></div>
<template
v-else-if=
"stats"
>
<!-- Row 1: Core Stats -->
<div
class=
"grid grid-cols-2 gap-4 lg:grid-cols-4"
>
<!-- Balance -->
<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
class=
"h-5 w-5 text-emerald-600 dark:text-emerald-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z"
/>
</svg>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.balance
'
)
}}
</p>
<p
class=
"text-xl font-bold text-emerald-600 dark:text-emerald-400"
>
$
{{
formatBalance
(
user
?.
balance
||
0
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
common.available
'
)
}}
</p>
</div>
</div>
</div>
<!-- API Keys -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30"
>
<svg
class=
"h-5 w-5 text-blue-600 dark:text-blue-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
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>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.apiKeys
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
stats
.
total_api_keys
}}
</p>
<p
class=
"text-xs text-green-600 dark:text-green-400"
>
{{
stats
.
active_api_keys
}}
{{
t
(
'
common.active
'
)
}}
</p>
</div>
</div>
</div>
<!-- Today Requests -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-green-100 p-2 dark:bg-green-900/30"
>
<svg
class=
"h-5 w-5 text-green-600 dark:text-green-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
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>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.todayRequests
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
stats
.
today_requests
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
common.total
'
)
}}
:
{{
formatNumber
(
stats
.
total_requests
)
}}
</p>
</div>
</div>
</div>
<!-- Today Cost -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30"
>
<svg
class=
"h-5 w-5 text-purple-600 dark:text-purple-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.todayCost
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
<span
class=
"text-purple-600 dark:text-purple-400"
:title=
"t('dashboard.actual')"
>
$
{{
formatCost
(
stats
.
today_actual_cost
)
}}
</span
>
<span
class=
"text-sm font-normal text-gray-400 dark:text-gray-500"
:title=
"t('dashboard.standard')"
>
/ $
{{
formatCost
(
stats
.
today_cost
)
}}
</span
>
</p>
<p
class=
"text-xs"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
common.total
'
)
}}
:
</span>
<span
class=
"text-purple-600 dark:text-purple-400"
:title=
"t('dashboard.actual')"
>
$
{{
formatCost
(
stats
.
total_actual_cost
)
}}
</span
>
<span
class=
"text-gray-400 dark:text-gray-500"
:title=
"t('dashboard.standard')"
>
/ $
{{
formatCost
(
stats
.
total_cost
)
}}
</span
>
</p>
</div>
</div>
</div>
</div>
<!-- Row 2: Token Stats -->
<div
class=
"grid grid-cols-2 gap-4 lg:grid-cols-4"
>
<!-- Today Tokens -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30"
>
<svg
class=
"h-5 w-5 text-amber-600 dark:text-amber-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"
/>
</svg>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.todayTokens
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
formatTokens
(
stats
.
today_tokens
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.input
'
)
}}
:
{{
formatTokens
(
stats
.
today_input_tokens
)
}}
/
{{
t
(
'
dashboard.output
'
)
}}
:
{{
formatTokens
(
stats
.
today_output_tokens
)
}}
</p>
</div>
</div>
</div>
<!-- Total Tokens -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-indigo-100 p-2 dark:bg-indigo-900/30"
>
<svg
class=
"h-5 w-5 text-indigo-600 dark:text-indigo-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"
/>
</svg>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.totalTokens
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
formatTokens
(
stats
.
total_tokens
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.input
'
)
}}
:
{{
formatTokens
(
stats
.
total_input_tokens
)
}}
/
{{
t
(
'
dashboard.output
'
)
}}
:
{{
formatTokens
(
stats
.
total_output_tokens
)
}}
</p>
</div>
</div>
</div>
<!-- Performance (RPM/TPM) -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-violet-100 p-2 dark:bg-violet-900/30"
>
<svg
class=
"h-5 w-5 text-violet-600 dark:text-violet-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
<div
class=
"flex-1"
>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.performance
'
)
}}
</p>
<div
class=
"flex items-baseline gap-2"
>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
formatTokens
(
stats
.
rpm
)
}}
</p>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
RPM
</span>
</div>
<div
class=
"flex items-baseline gap-2"
>
<p
class=
"text-sm font-semibold text-violet-600 dark:text-violet-400"
>
{{
formatTokens
(
stats
.
tpm
)
}}
</p>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
TPM
</span>
</div>
</div>
</div>
</div>
<!-- Avg Response Time -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-rose-100 p-2 dark:bg-rose-900/30"
>
<svg
class=
"h-5 w-5 text-rose-600 dark:text-rose-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.avgResponse
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
formatDuration
(
stats
.
average_duration_ms
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.averageTime
'
)
}}
</p>
</div>
</div>
</div>
</div>
<!-- Charts Section -->
<div
class=
"space-y-6"
>
<!-- Date Range Filter -->
<div
class=
"card p-4"
>
<div
class=
"flex flex-wrap items-center gap-4"
>
<div
class=
"flex items-center gap-2"
>
<span
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
dashboard.timeRange
'
)
}}
:
</span
>
<DateRangePicker
v-model:start-date=
"startDate"
v-model:end-date=
"endDate"
@
change=
"onDateRangeChange"
/>
</div>
<div
class=
"ml-auto flex items-center gap-2"
>
<span
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
dashboard.granularity
'
)
}}
:
</span
>
<div
class=
"w-28"
>
<Select
v-model=
"granularity"
:options=
"granularityOptions"
@
change=
"loadChartData"
/>
</div>
</div>
</div>
</div>
<!-- Charts Grid -->
<div
class=
"grid grid-cols-1 gap-6 lg:grid-cols-2"
>
<!-- Model Distribution Chart -->
<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>
<div
class=
"flex items-center gap-6"
>
<div
class=
"h-48 w-48"
>
<Doughnut
v-if=
"modelChartData"
ref=
"modelChartRef"
:data=
"modelChartData"
:options=
"doughnutOptions"
/>
<div
v-else
class=
"flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
dashboard.noDataAvailable
'
)
}}
</div>
</div>
<div
class=
"max-h-48 flex-1 overflow-y-auto"
>
<table
class=
"w-full text-xs"
>
<thead>
<tr
class=
"text-gray-500 dark:text-gray-400"
>
<th
class=
"pb-2 text-left"
>
{{
t
(
'
dashboard.model
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
dashboard.requests
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
dashboard.tokens
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
dashboard.actual
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
dashboard.standard
'
)
}}
</th>
</tr>
</thead>
<tbody>
<tr
v-for=
"model in modelStats"
:key=
"model.model"
class=
"border-t border-gray-100 dark:border-gray-700"
>
<td
class=
"max-w-[100px] truncate py-1.5 font-medium text-gray-900 dark:text-white"
:title=
"model.model"
>
{{
model
.
model
}}
</td>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
{{
formatNumber
(
model
.
requests
)
}}
</td>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
{{
formatTokens
(
model
.
total_tokens
)
}}
</td>
<td
class=
"py-1.5 text-right text-green-600 dark:text-green-400"
>
$
{{
formatCost
(
model
.
actual_cost
)
}}
</td>
<td
class=
"py-1.5 text-right text-gray-400 dark:text-gray-500"
>
$
{{
formatCost
(
model
.
cost
)
}}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Token Usage Trend Chart -->
<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"
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"
>
{{
t
(
'
dashboard.noDataAvailable
'
)
}}
</div>
</div>
</div>
</div>
</div>
<!-- Main Content Grid -->
<UserDashboardStats
:stats=
"stats"
:balance=
"user?.balance || 0"
:is-simple=
"authStore.isSimpleMode"
/>
<UserDashboardCharts
v-model:startDate=
"startDate"
v-model:endDate=
"endDate"
v-model:granularity=
"granularity"
:loading=
"loadingCharts"
:trend=
"trendData"
:models=
"modelStats"
@
dateRangeChange=
"loadCharts"
@
granularityChange=
"loadCharts"
/>
<div
class=
"grid grid-cols-1 gap-6 lg:grid-cols-3"
>
<!-- Recent Usage - Takes 2 columns -->
<div
class=
"lg:col-span-2"
>
<div
class=
"card"
>
<div
class=
"flex items-center justify-between border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h2
class=
"text-lg font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
dashboard.recentUsage
'
)
}}
</h2>
<span
class=
"badge badge-gray"
>
{{
t
(
'
dashboard.last7Days
'
)
}}
</span>
</div>
<div
class=
"p-6"
>
<div
v-if=
"loadingUsage"
class=
"flex items-center justify-center py-12"
>
<LoadingSpinner
size=
"lg"
/>
</div>
<div
v-else-if=
"recentUsage.length === 0"
class=
"py-8"
>
<EmptyState
:title=
"t('dashboard.noUsageRecords')"
:description=
"t('dashboard.startUsingApi')"
/>
</div>
<div
v-else
class=
"space-y-3"
>
<div
v-for=
"log in recentUsage"
:key=
"log.id"
class=
"flex items-center justify-between rounded-xl bg-gray-50 p-4 transition-colors hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800"
>
<div
class=
"flex items-center gap-4"
>
<div
class=
"flex h-10 w-10 items-center justify-center rounded-xl bg-primary-100 dark:bg-primary-900/30"
>
<svg
class=
"h-5 w-5 text-primary-600 dark:text-primary-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5"
/>
</svg>
</div>
<div>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
log
.
model
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
formatDateTime
(
log
.
created_at
)
}}
</p>
</div>
</div>
<div
class=
"text-right"
>
<p
class=
"text-sm font-semibold"
>
<span
class=
"text-green-600 dark:text-green-400"
:title=
"t('dashboard.actual')"
>
$
{{
formatCost
(
log
.
actual_cost
)
}}
</span
>
<span
class=
"font-normal text-gray-400 dark:text-gray-500"
:title=
"t('dashboard.standard')"
>
/ $
{{
formatCost
(
log
.
total_cost
)
}}
</span
>
</p>
<p
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
(
log
.
input_tokens
+
log
.
output_tokens
).
toLocaleString
()
}}
tokens
</p>
</div>
</div>
<router-link
to=
"/usage"
class=
"flex items-center justify-center gap-2 py-3 text-sm font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
{{
t
(
'
dashboard.viewAllUsage
'
)
}}
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
/>
</svg>
</router-link>
</div>
</div>
</div>
</div>
<!-- Quick Actions - Takes 1 column -->
<div
class=
"lg:col-span-1"
>
<div
class=
"card"
>
<div
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h2
class=
"text-lg font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
dashboard.quickActions
'
)
}}
</h2>
</div>
<div
class=
"space-y-3 p-4"
>
<button
@
click=
"navigateTo('/keys')"
class=
"group flex w-full items-center gap-4 rounded-xl bg-gray-50 p-4 text-left transition-all duration-200 hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800"
>
<div
class=
"flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-primary-100 transition-transform group-hover:scale-105 dark:bg-primary-900/30"
>
<svg
class=
"h-6 w-6 text-primary-600 dark:text-primary-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"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>
</div>
<div
class=
"min-w-0 flex-1"
>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
dashboard.createApiKey
'
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
t
(
'
dashboard.generateNewKey
'
)
}}
</p>
</div>
<svg
class=
"h-5 w-5 text-gray-400 transition-colors group-hover:text-primary-500 dark:text-dark-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</button>
<button
@
click=
"navigateTo('/usage')"
class=
"group flex w-full items-center gap-4 rounded-xl bg-gray-50 p-4 text-left transition-all duration-200 hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800"
>
<div
class=
"flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-emerald-100 transition-transform group-hover:scale-105 dark:bg-emerald-900/30"
>
<svg
class=
"h-6 w-6 text-emerald-600 dark:text-emerald-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
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>
</div>
<div
class=
"min-w-0 flex-1"
>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
dashboard.viewUsage
'
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
t
(
'
dashboard.checkDetailedLogs
'
)
}}
</p>
</div>
<svg
class=
"h-5 w-5 text-gray-400 transition-colors group-hover:text-emerald-500 dark:text-dark-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</button>
<button
@
click=
"navigateTo('/redeem')"
class=
"group flex w-full items-center gap-4 rounded-xl bg-gray-50 p-4 text-left transition-all duration-200 hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800"
>
<div
class=
"flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-amber-100 transition-transform group-hover:scale-105 dark:bg-amber-900/30"
>
<svg
class=
"h-6 w-6 text-amber-600 dark:text-amber-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
</div>
<div
class=
"min-w-0 flex-1"
>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
dashboard.redeemCode
'
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
t
(
'
dashboard.addBalanceWithCode
'
)
}}
</p>
</div>
<svg
class=
"h-5 w-5 text-gray-400 transition-colors group-hover:text-amber-500 dark:text-dark-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</button>
</div>
</div>
</div>
<div
class=
"lg:col-span-2"
><UserDashboardRecentUsage
:data=
"recentUsage"
:loading=
"loadingUsage"
/></div>
<div
class=
"lg:col-span-1"
><UserDashboardQuickActions
/></div>
</div>
</
template
>
</div>
...
...
@@ -663,405 +15,22 @@
</template>
<
script
setup
lang=
"ts"
>
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
()
import
{
usageAPI
,
type
UserDashboardStats
}
from
'
@/api/usage
'
import
{
ref
,
computed
,
onMounted
}
from
'
vue
'
;
import
{
useAuthStore
}
from
'
@/stores/auth
'
;
import
{
usageAPI
,
type
UserDashboardStats
as
UserStatsType
}
from
'
@/api/usage
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
;
import
LoadingSpinner
from
'
@/components/common/LoadingSpinner.vue
'
import
UserDashboardStats
from
'
@/components/user/dashboard/UserDashboardStats.vue
'
;
import
UserDashboardCharts
from
'
@/components/user/dashboard/UserDashboardCharts.vue
'
import
UserDashboardRecentUsage
from
'
@/components/user/dashboard/UserDashboardRecentUsage.vue
'
;
import
UserDashboardQuickActions
from
'
@/components/user/dashboard/UserDashboardQuickActions.vue
'
import
type
{
UsageLog
,
TrendDataPoint
,
ModelStat
}
from
'
@/types
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
LoadingSpinner
from
'
@/components/common/LoadingSpinner.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
DateRangePicker
from
'
@/components/common/DateRangePicker.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
{
Chart
as
ChartJS
,
CategoryScale
,
LinearScale
,
PointElement
,
LineElement
,
ArcElement
,
Title
,
Tooltip
,
Legend
,
Filler
}
from
'
chart.js
'
import
{
Line
,
Doughnut
}
from
'
vue-chartjs
'
// Register Chart.js components
ChartJS
.
register
(
CategoryScale
,
LinearScale
,
PointElement
,
LineElement
,
ArcElement
,
Title
,
Tooltip
,
Legend
,
Filler
)
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
(
formatLocalDate
(
weekAgo
))
const
endDate
=
ref
(
formatLocalDate
(
now
))
const
authStore
=
useAuthStore
();
const
user
=
computed
(()
=>
authStore
.
user
)
const
stats
=
ref
<
UserStatsType
|
null
>
(
null
);
const
loading
=
ref
(
false
);
const
loadingUsage
=
ref
(
false
);
const
loadingCharts
=
ref
(
false
)
const
trendData
=
ref
<
TrendDataPoint
[]
>
([]);
const
modelStats
=
ref
<
ModelStat
[]
>
([]);
const
recentUsage
=
ref
<
UsageLog
[]
>
([])
// Granularity options for Select component
const
granularityOptions
=
computed
(()
=>
[
{
value
:
'
day
'
,
label
:
t
(
'
dashboard.day
'
)
},
{
value
:
'
hour
'
,
label
:
t
(
'
dashboard.hour
'
)
}
])
const
formatLD
=
(
d
:
Date
)
=>
d
.
toISOString
().
split
(
'
T
'
)[
0
]
const
startDate
=
ref
(
formatLD
(
new
Date
(
Date
.
now
()
-
6
*
86400000
)));
const
endDate
=
ref
(
formatLD
(
new
Date
()));
const
granularity
=
ref
(
'
day
'
)
// Dark mode detection
const
isDarkMode
=
computed
(()
=>
{
return
document
.
documentElement
.
classList
.
contains
(
'
dark
'
)
})
const
loadStats
=
async
()
=>
{
loading
.
value
=
true
;
try
{
await
authStore
.
refreshUser
();
stats
.
value
=
await
usageAPI
.
getDashboardStats
()
}
catch
{}
finally
{
loading
.
value
=
false
}
}
const
loadCharts
=
async
()
=>
{
loadingCharts
.
value
=
true
;
try
{
const
res
=
await
Promise
.
all
([
usageAPI
.
getDashboardTrend
({
start_date
:
startDate
.
value
,
end_date
:
endDate
.
value
,
granularity
:
granularity
.
value
as
any
}),
usageAPI
.
getDashboardModels
({
start_date
:
startDate
.
value
,
end_date
:
endDate
.
value
})]);
trendData
.
value
=
res
[
0
].
trend
||
[];
modelStats
.
value
=
res
[
1
].
models
||
[]
}
catch
{}
finally
{
loadingCharts
.
value
=
false
}
}
const
loadRecent
=
async
()
=>
{
loadingUsage
.
value
=
true
;
try
{
const
res
=
await
usageAPI
.
getByDateRange
(
startDate
.
value
,
endDate
.
value
);
recentUsage
.
value
=
res
.
items
.
slice
(
0
,
5
)
}
catch
{}
finally
{
loadingUsage
.
value
=
false
}
}
// Chart colors
const
chartColors
=
computed
(()
=>
({
text
:
isDarkMode
.
value
?
'
#e5e7eb
'
:
'
#374151
'
,
grid
:
isDarkMode
.
value
?
'
#374151
'
:
'
#e5e7eb
'
,
input
:
'
#3b82f6
'
,
output
:
'
#10b981
'
,
cache
:
'
#f59e0b
'
}))
// Doughnut chart options
const
doughnutOptions
=
computed
(()
=>
({
responsive
:
true
,
maintainAspectRatio
:
false
,
plugins
:
{
legend
:
{
display
:
false
},
tooltip
:
{
callbacks
:
{
label
:
(
context
:
any
)
=>
{
const
value
=
context
.
raw
as
number
const
total
=
context
.
dataset
.
data
.
reduce
((
a
:
number
,
b
:
number
)
=>
a
+
b
,
0
)
const
percentage
=
((
value
/
total
)
*
100
).
toFixed
(
1
)
return
`
${
context
.
label
}
:
${
formatTokens
(
value
)}
(
${
percentage
}
%)`
}
}
}
}
}))
// Line chart options
const
lineOptions
=
computed
(()
=>
({
responsive
:
true
,
maintainAspectRatio
:
false
,
interaction
:
{
intersect
:
false
,
mode
:
'
index
'
as
const
},
plugins
:
{
legend
:
{
position
:
'
top
'
as
const
,
labels
:
{
color
:
chartColors
.
value
.
text
,
usePointStyle
:
true
,
pointStyle
:
'
circle
'
,
padding
:
15
,
font
:
{
size
:
11
}
}
},
tooltip
:
{
callbacks
:
{
label
:
(
context
:
any
)
=>
{
return
`
${
context
.
dataset
.
label
}
:
${
formatTokens
(
context
.
raw
)}
`
},
footer
:
(
tooltipItems
:
any
)
=>
{
const
dataIndex
=
tooltipItems
[
0
]?.
dataIndex
if
(
dataIndex
!==
undefined
&&
trendData
.
value
[
dataIndex
])
{
const
data
=
trendData
.
value
[
dataIndex
]
return
`Actual: $
${
formatCost
(
data
.
actual_cost
)}
| Standard: $
${
formatCost
(
data
.
cost
)}
`
}
return
''
}
}
}
},
scales
:
{
x
:
{
grid
:
{
color
:
chartColors
.
value
.
grid
},
ticks
:
{
color
:
chartColors
.
value
.
text
,
font
:
{
size
:
10
}
}
},
y
:
{
grid
:
{
color
:
chartColors
.
value
.
grid
},
ticks
:
{
color
:
chartColors
.
value
.
text
,
font
:
{
size
:
10
},
callback
:
(
value
:
string
|
number
)
=>
formatTokens
(
Number
(
value
))
}
}
}
}))
// Model chart data
const
modelChartData
=
computed
(()
=>
{
if
(
!
modelStats
.
value
?.
length
)
return
null
const
colors
=
[
'
#3b82f6
'
,
'
#10b981
'
,
'
#f59e0b
'
,
'
#ef4444
'
,
'
#8b5cf6
'
,
'
#ec4899
'
,
'
#14b8a6
'
,
'
#f97316
'
,
'
#6366f1
'
,
'
#84cc16
'
]
return
{
labels
:
modelStats
.
value
.
map
((
m
)
=>
m
.
model
),
datasets
:
[
{
data
:
modelStats
.
value
.
map
((
m
)
=>
m
.
total_tokens
),
backgroundColor
:
colors
.
slice
(
0
,
modelStats
.
value
.
length
),
borderWidth
:
0
}
]
}
})
// Trend chart data
const
trendChartData
=
computed
(()
=>
{
if
(
!
trendData
.
value
?.
length
)
return
null
return
{
labels
:
trendData
.
value
.
map
((
d
)
=>
d
.
date
),
datasets
:
[
{
label
:
'
Input
'
,
data
:
trendData
.
value
.
map
((
d
)
=>
d
.
input_tokens
),
borderColor
:
chartColors
.
value
.
input
,
backgroundColor
:
`
${
chartColors
.
value
.
input
}
20`
,
fill
:
true
,
tension
:
0.3
},
{
label
:
'
Output
'
,
data
:
trendData
.
value
.
map
((
d
)
=>
d
.
output_tokens
),
borderColor
:
chartColors
.
value
.
output
,
backgroundColor
:
`
${
chartColors
.
value
.
output
}
20`
,
fill
:
true
,
tension
:
0.3
},
{
label
:
'
Cache
'
,
data
:
trendData
.
value
.
map
((
d
)
=>
d
.
cache_tokens
),
borderColor
:
chartColors
.
value
.
cache
,
backgroundColor
:
`
${
chartColors
.
value
.
cache
}
20`
,
fill
:
true
,
tension
:
0.3
}
]
}
})
// Format helpers
const
formatTokens
=
(
value
:
number
|
undefined
):
string
=>
{
if
(
value
===
undefined
||
value
===
null
)
return
'
0
'
if
(
value
>=
1
_000_000_000
)
{
return
`
${(
value
/
1
_000_000_000
).
toFixed
(
2
)}
B`
}
else
if
(
value
>=
1
_000_000
)
{
return
`
${(
value
/
1
_000_000
).
toFixed
(
2
)}
M`
}
else
if
(
value
>=
1
_000
)
{
return
`
${(
value
/
1
_000
).
toFixed
(
2
)}
K`
}
return
value
.
toLocaleString
()
}
const
formatNumber
=
(
value
:
number
):
string
=>
{
return
value
.
toLocaleString
()
}
const
formatBalance
=
(
balance
:
number
):
string
=>
{
return
balance
.
toFixed
(
2
)
}
const
formatCost
=
(
value
:
number
):
string
=>
{
if
(
value
>=
1000
)
{
return
(
value
/
1000
).
toFixed
(
2
)
+
'
K
'
}
else
if
(
value
>=
1
)
{
return
value
.
toFixed
(
2
)
}
else
if
(
value
>=
0.01
)
{
return
value
.
toFixed
(
3
)
}
return
value
.
toFixed
(
4
)
}
const
formatDuration
=
(
ms
:
number
):
string
=>
{
if
(
ms
>=
1000
)
{
return
`
${(
ms
/
1000
).
toFixed
(
2
)}
s`
}
return
`
${
Math
.
round
(
ms
)}
ms`
}
const
navigateTo
=
(
path
:
string
)
=>
{
router
.
push
(
path
)
}
// Date range change handler
const
onDateRangeChange
=
(
range
:
{
startDate
:
string
endDate
:
string
preset
:
string
|
null
})
=>
{
const
start
=
new
Date
(
range
.
startDate
)
const
end
=
new
Date
(
range
.
endDate
)
const
daysDiff
=
Math
.
ceil
((
end
.
getTime
()
-
start
.
getTime
())
/
(
1000
*
60
*
60
*
24
))
if
(
daysDiff
<=
1
)
{
granularity
.
value
=
'
hour
'
}
else
{
granularity
.
value
=
'
day
'
}
loadChartData
()
}
// Load data
const
loadDashboardStats
=
async
()
=>
{
loading
.
value
=
true
try
{
await
authStore
.
refreshUser
()
stats
.
value
=
await
usageAPI
.
getDashboardStats
()
}
catch
(
error
)
{
console
.
error
(
'
Error loading dashboard stats:
'
,
error
)
}
finally
{
loading
.
value
=
false
}
}
const
loadChartData
=
async
()
=>
{
loadingCharts
.
value
=
true
try
{
const
params
=
{
start_date
:
startDate
.
value
,
end_date
:
endDate
.
value
,
granularity
:
granularity
.
value
}
const
[
trendResponse
,
modelResponse
]
=
await
Promise
.
all
([
usageAPI
.
getDashboardTrend
(
params
),
usageAPI
.
getDashboardModels
({
start_date
:
startDate
.
value
,
end_date
:
endDate
.
value
})
])
// Ensure we always have arrays, even if API returns null
trendData
.
value
=
trendResponse
.
trend
||
[]
modelStats
.
value
=
modelResponse
.
models
||
[]
}
catch
(
error
)
{
console
.
error
(
'
Error loading chart data:
'
,
error
)
}
finally
{
loadingCharts
.
value
=
false
}
}
const
loadRecentUsage
=
async
()
=>
{
loadingUsage
.
value
=
true
try
{
// Use local timezone instead of UTC
const
now
=
new
Date
()
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
)
{
console
.
error
(
'
Failed to load recent usage:
'
,
error
)
}
finally
{
loadingUsage
.
value
=
false
}
}
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
,
()
=>
{
nextTick
(()
=>
{
modelChartRef
.
value
?.
chart
?.
update
()
trendChartRef
.
value
?.
chart
?.
update
()
})
})
onMounted
(()
=>
{
loadStats
();
loadCharts
();
loadRecent
()
})
</
script
>
<
style
scoped
>
/* Compact Select styling for dashboard */
:deep
(
.select-trigger
)
{
@apply
rounded-lg
px-3
py-1.5
text-sm;
}
:deep
(
.select-dropdown
)
{
@apply
rounded-lg;
}
:deep
(
.select-option
)
{
@apply
px-3
py-2
text-sm;
}
</
style
>
Prev
1
2
3
4
5
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