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
fd29fe11
Commit
fd29fe11
authored
Jan 05, 2026
by
shaw
Browse files
Merge PR #149: Fix/multi platform - 安全稳定性修复和前端架构优化
parents
07d80f76
eef12cb9
Changes
70
Show whitespace changes
Inline
Side-by-side
frontend/src/views/admin/ProxiesView.vue
View file @
fd29fe11
<
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"
>
<!-- 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"
...
...
@@ -62,23 +29,66 @@
@
input=
"handleSearch"
/>
</div>
<div
class=
"flex flex-wrap gap-3"
>
<!-- Filters -->
<div
class=
"w-full sm:w-40"
>
<Select
v-model=
"filters.protocol"
:options=
"protocolOptions"
:placeholder=
"t('admin.proxies.allProtocols')"
class=
"w-40"
@
change=
"loadProxies"
/>
</div>
<div
class=
"w-full sm:w-36"
>
<Select
v-model=
"filters.status"
:options=
"statusOptions"
:placeholder=
"t('admin.proxies.allStatus')"
class=
"w-36"
@
change=
"loadProxies"
/>
</div>
</div>
<!-- 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
>
<
template
#table
>
...
...
@@ -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 @
fd29fe11
...
...
@@ -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 @
fd29fe11
...
...
@@ -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 @
fd29fe11
<
template
>
<AppLayout>
<TablePageLayout>
<!-- Page Header Actions -->
<template
#actions
>
<div
class=
"flex justify-end gap-3"
>
<template
#filters
>
<!-- 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"
...
...
@@ -32,30 +128,15 @@
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 4.5v15m7.5-7.5h-15"
/>
<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>
</
template
>
...
...
@@ -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
,
{
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
}
,
{
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 @
fd29fe11
<
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
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
()
=>
{
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
[
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
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
(
page
===
1
)
{
total
=
response
.
total
exportProgress
.
total
=
total
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
'
)
}
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
}
if
(
allLogs
.
length
===
0
)
{
appStore
.
showWarning
(
t
(
'
usage.noDataToExport
'
))
return
}
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
}
// 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
}
catch
{
appStore
.
showError
(
'
Export Failed
'
)
}
finally
{
if
(
exportAbortController
===
c
)
{
exportAbortController
=
null
;
exporting
.
value
=
false
;
exportProgress
.
show
=
false
}
}
}
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
()
}
})
onMounted
(()
=>
{
loadLogs
();
loadStats
()
})
onUnmounted
(()
=>
{
abortController
?.
abort
();
exportAbortController
?.
abort
()
})
</
script
>
\ No newline at end of file
frontend/src/views/admin/UsersView.vue
View file @
fd29fe11
...
...
@@ -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 @
fd29fe11
...
...
@@ -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 @
fd29fe11
<
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
)
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
[]
>
([])
// Recent usage
const
recentUsage
=
ref
<
UsageLog
[]
>
([]
)
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
'
)
// 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
'
)}
`
}
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
}
}
// 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
))
// Granularity options for Select component
const
granularityOptions
=
computed
(()
=>
[
{
value
:
'
day
'
,
label
:
t
(
'
dashboard.day
'
)
},
{
value
:
'
hour
'
,
label
:
t
(
'
dashboard.hour
'
)
}
])
// Dark mode detection
const
isDarkMode
=
computed
(()
=>
{
return
document
.
documentElement
.
classList
.
contains
(
'
dark
'
)
})
// 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
>
frontend/src/views/user/KeysView.vue
View file @
fd29fe11
...
...
@@ -141,7 +141,7 @@
<
template
#cell-status=
"{ value }"
>
<span
:class=
"['badge', value === 'active' ? 'badge-success' : 'badge-gray']"
>
{{
value
}}
{{
t
(
'
admin.accounts.status.
'
+
value
)
}}
</span>
</
template
>
...
...
@@ -503,7 +503,8 @@
<div
v-if=
"groupSelectorKeyId !== null && dropdownPosition"
ref=
"dropdownRef"
class=
"animate-in fade-in slide-in-from-top-2 fixed z-[9999] w-64 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 duration-200 dark:bg-dark-800 dark:ring-white/10"
class=
"animate-in fade-in slide-in-from-top-2 fixed z-[100000020] w-64 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 duration-200 dark:bg-dark-800 dark:ring-white/10"
style=
"pointer-events: auto !important;"
:style=
"{ top: dropdownPosition.top + 'px', left: dropdownPosition.left + 'px' }"
>
<div
class=
"max-h-64 overflow-y-auto p-1.5"
>
...
...
frontend/src/views/user/ProfileView.vue
View file @
fd29fe11
<
template
>
<AppLayout>
<div
class=
"mx-auto max-w-4xl space-y-6"
>
<!-- Account Stats Summary -->
<div
class=
"grid grid-cols-1 gap-6 sm:grid-cols-3"
>
<StatCard
:title=
"t('profile.accountBalance')"
:value=
"formatCurrency(user?.balance || 0)"
:icon=
"WalletIcon"
icon-variant=
"success"
/>
<StatCard
:title=
"t('profile.concurrencyLimit')"
:value=
"user?.concurrency || 0"
:icon=
"BoltIcon"
icon-variant=
"warning"
/>
<StatCard
:title=
"t('profile.memberSince')"
:value=
"formatDate(user?.created_at || '', 'YYYY-MM')"
:icon=
"CalendarIcon"
icon-variant=
"primary"
/>
<StatCard
:title=
"t('profile.accountBalance')"
:value=
"formatCurrency(user?.balance || 0)"
:icon=
"WalletIcon"
icon-variant=
"success"
/>
<StatCard
:title=
"t('profile.concurrencyLimit')"
:value=
"user?.concurrency || 0"
:icon=
"BoltIcon"
icon-variant=
"warning"
/>
<StatCard
:title=
"t('profile.memberSince')"
:value=
"formatDate(user?.created_at || '',
{ year: 'numeric', month: 'long' })" :icon="CalendarIcon" icon-variant="primary" />
</div>
<!-- User Information -->
<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>
<!-- Contact Support Section -->
<div
v-if=
"contactInfo"
class=
"card border-primary-200 bg-gradient-to-r from-primary-50 to-primary-100/50 dark:border-primary-800/40 dark:from-primary-900/20 dark:to-primary-800/10"
>
<div
class=
"px-6 py-5"
>
<ProfileInfoCard
:user=
"user"
/>
<div
v-if=
"contactInfo"
class=
"card border-primary-200 bg-primary-50 dark:bg-primary-900/20 p-6"
>
<div
class=
"flex items-center gap-4"
>
<div
class=
"flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-primary-100 dark:bg-primary-900/30"
>
<svg
class=
"h-6 w-6 text-primary-600 dark:text-primary-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"
/>
</svg>
</div>
<div
class=
"min-w-0 flex-1"
>
<h3
class=
"text-sm font-semibold text-primary-800 dark:text-primary-200"
>
{{
t
(
'
common.contactSupport
'
)
}}
</h3>
<p
class=
"mt-1 text-sm font-medium text-primary-600 dark:text-primary-300"
>
{{
contactInfo
}}
</p>
</div>
</div>
</div>
</div>
<!-- Edit Profile Section -->
<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.editProfile
'
)
}}
</h2>
</div>
<div
class=
"px-6 py-6"
>
<form
@
submit.prevent=
"handleUpdateProfile"
class=
"space-y-4"
>
<div>
<label
for=
"username"
class=
"input-label"
>
{{
t
(
'
profile.username
'
)
}}
</label>
<input
id=
"username"
v-model=
"profileForm.username"
type=
"text"
class=
"input"
:placeholder=
"t('profile.enterUsername')"
/>
</div>
<div
class=
"flex justify-end pt-4"
>
<button
type=
"submit"
:disabled=
"updatingProfile"
class=
"btn btn-primary"
>
{{
updatingProfile
?
t
(
'
profile.updating
'
)
:
t
(
'
profile.updateProfile
'
)
}}
</button>
</div>
</form>
</div>
</div>
<!-- Change Password Section -->
<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=
"passwordForm.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=
"passwordForm.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=
"passwordForm.confirm_password"
type=
"password"
required
autocomplete=
"new-password"
class=
"input"
/>
<p
v-if=
"passwordForm.new_password && passwordForm.confirm_password && passwordForm.new_password !== passwordForm.confirm_password"
class=
"input-error-text"
>
{{
t
(
'
profile.passwordsNotMatch
'
)
}}
</p>
</div>
<div
class=
"flex justify-end pt-4"
>
<button
type=
"submit"
:disabled=
"changingPassword"
class=
"btn btn-primary"
>
{{
changingPassword
?
t
(
'
profile.changingPassword
'
)
:
t
(
'
profile.changePasswordButton
'
)
}}
</button>
</div>
</form>
<div
class=
"p-3 bg-primary-100 rounded-xl text-primary-600"
>
💬
</div>
<div><h3
class=
"font-semibold text-primary-800 dark:text-primary-200"
>
{{
t
(
'
common.contactSupport
'
)
}}
</h3><p
class=
"text-sm font-medium"
>
{{
contactInfo
}}
</p></div>
</div>
</div>
<ProfileEditForm
:initial-username=
"user?.username || ''"
/>
<ProfilePasswordForm
/>
</div>
</AppLayout>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
h
,
onMounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
formatDate
}
from
'
@/utils/format
'
const
{
t
}
=
useI18n
()
import
{
userAPI
,
authAPI
}
from
'
@/api
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
{
ref
,
computed
,
h
,
onMounted
}
from
'
vue
'
;
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
;
import
{
formatDate
}
from
'
@/utils/format
'
import
{
authAPI
}
from
'
@/api
'
;
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
StatCard
from
'
@/components/common/StatCard.vue
'
import
ProfileInfoCard
from
'
@/components/user/profile/ProfileInfoCard.vue
'
import
ProfileEditForm
from
'
@/components/user/profile/ProfileEditForm.vue
'
import
ProfilePasswordForm
from
'
@/components/user/profile/ProfilePasswordForm.vue
'
// SVG Icon Components
const
WalletIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3
'
})
]
)
}
const
BoltIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
'
stroke-linecap
'
:
'
round
'
,
'
stroke-linejoin
'
:
'
round
'
,
d
:
'
m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z
'
})
]
)
}
const
CalendarIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
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
'
})
]
)
}
const
authStore
=
useAuthStore
()
const
appStore
=
useAppStore
()
const
user
=
computed
(()
=>
authStore
.
user
)
const
passwordForm
=
ref
({
old_password
:
''
,
new_password
:
''
,
confirm_password
:
''
})
const
profileForm
=
ref
({
username
:
''
})
const
changingPassword
=
ref
(
false
)
const
updatingProfile
=
ref
(
false
)
const
{
t
}
=
useI18n
();
const
authStore
=
useAuthStore
();
const
user
=
computed
(()
=>
authStore
.
user
)
const
contactInfo
=
ref
(
''
)
onMounted
(
async
()
=>
{
try
{
const
settings
=
await
authAPI
.
getPublicSettings
()
contactInfo
.
value
=
settings
.
contact_info
||
''
// Initialize profile form with current user data
if
(
user
.
value
)
{
profileForm
.
value
.
username
=
user
.
value
.
username
||
''
}
}
catch
(
error
)
{
console
.
error
(
'
Failed to load contact info:
'
,
error
)
}
})
const
formatCurrency
=
(
value
:
number
):
string
=>
{
return
`$
${
value
.
toFixed
(
2
)}
`
}
const
handleChangePassword
=
async
()
=>
{
// Validate password match
if
(
passwordForm
.
value
.
new_password
!==
passwordForm
.
value
.
confirm_password
)
{
appStore
.
showError
(
t
(
'
profile.passwordsNotMatch
'
))
return
}
// Validate password length
if
(
passwordForm
.
value
.
new_password
.
length
<
8
)
{
appStore
.
showError
(
t
(
'
profile.passwordTooShort
'
))
return
}
changingPassword
.
value
=
true
try
{
await
userAPI
.
changePassword
(
passwordForm
.
value
.
old_password
,
passwordForm
.
value
.
new_password
)
// Clear form
passwordForm
.
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
{
changingPassword
.
value
=
false
}
}
const
handleUpdateProfile
=
async
()
=>
{
// Basic validation
if
(
!
profileForm
.
value
.
username
.
trim
())
{
appStore
.
showError
(
t
(
'
profile.usernameRequired
'
))
return
}
updatingProfile
.
value
=
true
try
{
const
updatedUser
=
await
userAPI
.
updateProfile
({
username
:
profileForm
.
value
.
username
})
// Update auth store with new user data
authStore
.
user
=
updatedUser
const
WalletIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
d
:
'
M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12
'
})])
}
const
BoltIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
d
:
'
m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z
'
})])
}
const
CalendarIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
d
:
'
M6.75 3v2.25M17.25 3v2.25
'
})])
}
appStore
.
showSuccess
(
t
(
'
profile.updateSuccess
'
))
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
profile.updateFailed
'
))
}
finally
{
updatingProfile
.
value
=
false
}
}
onMounted
(
async
()
=>
{
try
{
const
s
=
await
authAPI
.
getPublicSettings
();
contactInfo
.
value
=
s
.
contact_info
||
''
}
catch
{}
})
const
formatCurrency
=
(
v
:
number
)
=>
`$
${
v
.
toFixed
(
2
)}
`
</
script
>
\ No newline at end of file
Prev
1
2
3
4
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment