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/components/admin/account/AccountStatsModal.vue
0 → 100644
View file @
fd29fe11
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.accounts.usageStatistics')"
width=
"extra-wide"
@
close=
"handleClose"
>
<div
class=
"space-y-6"
>
<!-- Account Info Header -->
<div
v-if=
"account"
class=
"flex items-center justify-between rounded-xl border border-primary-200 bg-gradient-to-r from-primary-50 to-primary-100 p-3 dark:border-primary-700/50 dark:from-primary-900/20 dark:to-primary-800/20"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
>
<svg
class=
"h-5 w-5 text-white"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
</div>
<div>
<div
class=
"font-semibold text-gray-900 dark:text-gray-100"
>
{{
account
.
name
}}
</div>
<div
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.last30DaysUsage
'
)
}}
</div>
</div>
</div>
<span
:class=
"[
'rounded-full px-2.5 py-1 text-xs font-semibold',
account.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
]"
>
{{
account
.
status
}}
</span>
</div>
<!-- Loading State -->
<div
v-if=
"loading"
class=
"flex items-center justify-center py-12"
>
<LoadingSpinner
/>
</div>
<template
v-else-if=
"stats"
>
<!-- Row 1: Main Stats Cards -->
<div
class=
"grid grid-cols-2 gap-4 lg:grid-cols-4"
>
<!-- 30-Day Total Cost -->
<div
class=
"card border-emerald-200 bg-gradient-to-br from-emerald-50 to-white p-4 dark:border-emerald-800/30 dark:from-emerald-900/10 dark:to-dark-700"
>
<div
class=
"mb-2 flex items-center justify-between"
>
<span
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.totalCost
'
)
}}
</span>
<div
class=
"rounded-lg bg-emerald-100 p-1.5 dark:bg-emerald-900/30"
>
<svg
class=
"h-4 w-4 text-emerald-600 dark:text-emerald-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<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>
<p
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
$
{{
formatCost
(
stats
.
summary
.
total_cost
)
}}
</p>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.accumulatedCost
'
)
}}
<span
class=
"text-gray-400 dark:text-gray-500"
>
(
{{
t
(
'
admin.accounts.stats.standardCost
'
)
}}
: $
{{
formatCost
(
stats
.
summary
.
total_standard_cost
)
}}
)
</span
>
</p>
</div>
<!-- 30-Day Total Requests -->
<div
class=
"card border-blue-200 bg-gradient-to-br from-blue-50 to-white p-4 dark:border-blue-800/30 dark:from-blue-900/10 dark:to-dark-700"
>
<div
class=
"mb-2 flex items-center justify-between"
>
<span
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.totalRequests
'
)
}}
</span>
<div
class=
"rounded-lg bg-blue-100 p-1.5 dark:bg-blue-900/30"
>
<svg
class=
"h-4 w-4 text-blue-600 dark:text-blue-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
</div>
<p
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
{{
formatNumber
(
stats
.
summary
.
total_requests
)
}}
</p>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.totalCalls
'
)
}}
</p>
</div>
<!-- Daily Average Cost -->
<div
class=
"card border-amber-200 bg-gradient-to-br from-amber-50 to-white p-4 dark:border-amber-800/30 dark:from-amber-900/10 dark:to-dark-700"
>
<div
class=
"mb-2 flex items-center justify-between"
>
<span
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.avgDailyCost
'
)
}}
</span>
<div
class=
"rounded-lg bg-amber-100 p-1.5 dark:bg-amber-900/30"
>
<svg
class=
"h-4 w-4 text-amber-600 dark:text-amber-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
</div>
</div>
<p
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
$
{{
formatCost
(
stats
.
summary
.
avg_daily_cost
)
}}
</p>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.basedOnActualDays
'
,
{
days
:
stats
.
summary
.
actual_days_used
}
)
}}
<
/p
>
<
/div
>
<!--
Daily
Average
Requests
-->
<
div
class
=
"
card border-purple-200 bg-gradient-to-br from-purple-50 to-white p-4 dark:border-purple-800/30 dark:from-purple-900/10 dark:to-dark-700
"
>
<
div
class
=
"
mb-2 flex items-center justify-between
"
>
<
span
class
=
"
text-xs font-medium text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.avgDailyRequests
'
)
}}
<
/span
>
<
div
class
=
"
rounded-lg bg-purple-100 p-1.5 dark:bg-purple-900/30
"
>
<
svg
class
=
"
h-4 w-4 text-purple-600 dark:text-purple-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z
"
/>
<
/svg
>
<
/div
>
<
/div
>
<
p
class
=
"
text-2xl font-bold text-gray-900 dark:text-white
"
>
{{
formatNumber
(
Math
.
round
(
stats
.
summary
.
avg_daily_requests
))
}}
<
/p
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.avgDailyUsage
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<!--
Row
2
:
Today
,
Highest
Cost
,
Highest
Requests
-->
<
div
class
=
"
grid grid-cols-1 gap-4 lg:grid-cols-3
"
>
<!--
Today
Overview
-->
<
div
class
=
"
card p-4
"
>
<
div
class
=
"
mb-3 flex items-center gap-2
"
>
<
div
class
=
"
rounded-lg bg-cyan-100 p-1.5 dark:bg-cyan-900/30
"
>
<
svg
class
=
"
h-4 w-4 text-cyan-600 dark:text-cyan-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z
"
/>
<
/svg
>
<
/div
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.todayOverview
'
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.cost
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
today
?.
cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.requests
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatNumber
(
stats
.
summary
.
today
?.
requests
||
0
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.tokens
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatTokens
(
stats
.
summary
.
today
?.
tokens
||
0
)
}}
<
/span
>
<
/div
>
<
/div
>
<
/div
>
<!--
Highest
Cost
Day
-->
<
div
class
=
"
card p-4
"
>
<
div
class
=
"
mb-3 flex items-center gap-2
"
>
<
div
class
=
"
rounded-lg bg-orange-100 p-1.5 dark:bg-orange-900/30
"
>
<
svg
class
=
"
h-4 w-4 text-orange-600 dark:text-orange-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z
"
/>
<
/svg
>
<
/div
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.highestCostDay
'
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.date
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
stats
.
summary
.
highest_cost_day
?.
label
||
'
-
'
}}
<
/span
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.cost
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-orange-600 dark:text-orange-400
"
>
$
{{
formatCost
(
stats
.
summary
.
highest_cost_day
?.
cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.requests
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatNumber
(
stats
.
summary
.
highest_cost_day
?.
requests
||
0
)
}}
<
/span
>
<
/div
>
<
/div
>
<
/div
>
<!--
Highest
Request
Day
-->
<
div
class
=
"
card p-4
"
>
<
div
class
=
"
mb-3 flex items-center gap-2
"
>
<
div
class
=
"
rounded-lg bg-indigo-100 p-1.5 dark:bg-indigo-900/30
"
>
<
svg
class
=
"
h-4 w-4 text-indigo-600 dark:text-indigo-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M13 7h8m0 0v8m0-8l-8 8-4-4-6 6
"
/>
<
/svg
>
<
/div
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.highestRequestDay
'
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.date
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
stats
.
summary
.
highest_request_day
?.
label
||
'
-
'
}}
<
/span
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.requests
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-indigo-600 dark:text-indigo-400
"
>
{{
formatNumber
(
stats
.
summary
.
highest_request_day
?.
requests
||
0
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.cost
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
highest_request_day
?.
cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
Row
3
:
Token
Stats
-->
<
div
class
=
"
grid grid-cols-1 gap-4 lg:grid-cols-3
"
>
<!--
Accumulated
Tokens
-->
<
div
class
=
"
card p-4
"
>
<
div
class
=
"
mb-3 flex items-center gap-2
"
>
<
div
class
=
"
rounded-lg bg-teal-100 p-1.5 dark:bg-teal-900/30
"
>
<
svg
class
=
"
h-4 w-4 text-teal-600 dark:text-teal-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4
"
/>
<
/svg
>
<
/div
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.accumulatedTokens
'
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.totalTokens
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatTokens
(
stats
.
summary
.
total_tokens
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.dailyAvgTokens
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatTokens
(
Math
.
round
(
stats
.
summary
.
avg_daily_tokens
))
}}
<
/span
>
<
/div
>
<
/div
>
<
/div
>
<!--
Performance
-->
<
div
class
=
"
card p-4
"
>
<
div
class
=
"
mb-3 flex items-center gap-2
"
>
<
div
class
=
"
rounded-lg bg-rose-100 p-1.5 dark:bg-rose-900/30
"
>
<
svg
class
=
"
h-4 w-4 text-rose-600 dark:text-rose-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M13 10V3L4 14h7v7l9-11h-7z
"
/>
<
/svg
>
<
/div
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.performance
'
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.avgResponseTime
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatDuration
(
stats
.
summary
.
avg_duration_ms
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.daysActive
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
stats
.
summary
.
actual_days_used
}}
/
{{
stats
.
summary
.
days
}}
<
/spa
n
>
<
/div
>
<
/div
>
<
/div
>
<!--
Recent
Activity
-->
<
div
class
=
"
card p-4
"
>
<
div
class
=
"
mb-3 flex items-center gap-2
"
>
<
div
class
=
"
rounded-lg bg-lime-100 p-1.5 dark:bg-lime-900/30
"
>
<
svg
class
=
"
h-4 w-4 text-lime-600 dark:text-lime-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2
"
/>
<
/svg
>
<
/div
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.recentActivity
'
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.todayRequests
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatNumber
(
stats
.
summary
.
today
?.
requests
||
0
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.todayTokens
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatTokens
(
stats
.
summary
.
today
?.
tokens
||
0
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.todayCost
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
today
?.
cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
Usage
Trend
Chart
-->
<
div
class
=
"
card p-4
"
>
<
h3
class
=
"
mb-4 text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.usageTrend
'
)
}}
<
/h3
>
<
div
class
=
"
h-64
"
>
<
Line
v
-
if
=
"
trendChartData
"
:
data
=
"
trendChartData
"
:
options
=
"
lineChartOptions
"
/>
<
div
v
-
else
class
=
"
flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.dashboard.noDataAvailable
'
)
}}
<
/div
>
<
/div
>
<
/div
>
<!--
Model
Distribution
-->
<
ModelDistributionChart
:
model
-
stats
=
"
stats.models
"
:
loading
=
"
false
"
/>
<
/template
>
<!--
No
Data
State
-->
<
div
v
-
else
-
if
=
"
!loading
"
class
=
"
flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400
"
>
<
svg
class
=
"
mb-4 h-12 w-12
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
1.5
"
d
=
"
M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z
"
/>
<
/svg
>
<
p
class
=
"
text-sm
"
>
{{
t
(
'
admin.accounts.stats.noData
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
template
#
footer
>
<
div
class
=
"
flex justify-end
"
>
<
button
@
click
=
"
handleClose
"
class
=
"
rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500
"
>
{{
t
(
'
common.close
'
)
}}
<
/button
>
<
/div
>
<
/template
>
<
/BaseDialog
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
watch
,
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
Chart
as
ChartJS
,
CategoryScale
,
LinearScale
,
PointElement
,
LineElement
,
Title
,
Tooltip
,
Legend
,
Filler
}
from
'
chart.js
'
import
{
Line
}
from
'
vue-chartjs
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
LoadingSpinner
from
'
@/components/common/LoadingSpinner.vue
'
import
ModelDistributionChart
from
'
@/components/charts/ModelDistributionChart.vue
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Account
,
AccountUsageStatsResponse
}
from
'
@/types
'
ChartJS
.
register
(
CategoryScale
,
LinearScale
,
PointElement
,
LineElement
,
Title
,
Tooltip
,
Legend
,
Filler
)
const
{
t
}
=
useI18n
()
const
props
=
defineProps
<
{
show
:
boolean
account
:
Account
|
null
}
>
()
const
emit
=
defineEmits
<
{
(
e
:
'
close
'
):
void
}
>
()
const
loading
=
ref
(
false
)
const
stats
=
ref
<
AccountUsageStatsResponse
|
null
>
(
null
)
// 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
'
}
))
// Line chart data
const
trendChartData
=
computed
(()
=>
{
if
(
!
stats
.
value
?.
history
?.
length
)
return
null
return
{
labels
:
stats
.
value
.
history
.
map
((
h
)
=>
h
.
label
),
datasets
:
[
{
label
:
t
(
'
admin.accounts.stats.cost
'
)
+
'
(USD)
'
,
data
:
stats
.
value
.
history
.
map
((
h
)
=>
h
.
cost
),
borderColor
:
'
#3b82f6
'
,
backgroundColor
:
'
rgba(59, 130, 246, 0.1)
'
,
fill
:
true
,
tension
:
0.3
,
yAxisID
:
'
y
'
}
,
{
label
:
t
(
'
admin.accounts.stats.requests
'
),
data
:
stats
.
value
.
history
.
map
((
h
)
=>
h
.
requests
),
borderColor
:
'
#f97316
'
,
backgroundColor
:
'
rgba(249, 115, 22, 0.1)
'
,
fill
:
false
,
tension
:
0.3
,
yAxisID
:
'
y1
'
}
]
}
}
)
// Line chart options with dual Y-axis
const
lineChartOptions
=
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
)
=>
{
const
label
=
context
.
dataset
.
label
||
''
const
value
=
context
.
raw
if
(
label
.
includes
(
'
USD
'
))
{
return
`${label
}
: $${formatCost(value)
}
`
}
return
`${label
}
: ${formatNumber(value)
}
`
}
}
}
}
,
scales
:
{
x
:
{
grid
:
{
color
:
chartColors
.
value
.
grid
}
,
ticks
:
{
color
:
chartColors
.
value
.
text
,
font
:
{
size
:
10
}
,
maxRotation
:
45
,
minRotation
:
0
}
}
,
y
:
{
type
:
'
linear
'
as
const
,
display
:
true
,
position
:
'
left
'
as
const
,
grid
:
{
color
:
chartColors
.
value
.
grid
}
,
ticks
:
{
color
:
'
#3b82f6
'
,
font
:
{
size
:
10
}
,
callback
:
(
value
:
string
|
number
)
=>
'
$
'
+
formatCost
(
Number
(
value
))
}
,
title
:
{
display
:
true
,
text
:
t
(
'
admin.accounts.stats.cost
'
)
+
'
(USD)
'
,
color
:
'
#3b82f6
'
,
font
:
{
size
:
11
}
}
}
,
y1
:
{
type
:
'
linear
'
as
const
,
display
:
true
,
position
:
'
right
'
as
const
,
grid
:
{
drawOnChartArea
:
false
}
,
ticks
:
{
color
:
'
#f97316
'
,
font
:
{
size
:
10
}
,
callback
:
(
value
:
string
|
number
)
=>
formatNumber
(
Number
(
value
))
}
,
title
:
{
display
:
true
,
text
:
t
(
'
admin.accounts.stats.requests
'
),
color
:
'
#f97316
'
,
font
:
{
size
:
11
}
}
}
}
}
))
// Load stats when modal opens
watch
(
()
=>
props
.
show
,
async
(
newVal
)
=>
{
if
(
newVal
&&
props
.
account
)
{
await
loadStats
()
}
else
{
stats
.
value
=
null
}
}
)
const
loadStats
=
async
()
=>
{
if
(
!
props
.
account
)
return
loading
.
value
=
true
try
{
stats
.
value
=
await
adminAPI
.
accounts
.
getStats
(
props
.
account
.
id
,
30
)
}
catch
(
error
)
{
console
.
error
(
'
Failed to load account stats:
'
,
error
)
stats
.
value
=
null
}
finally
{
loading
.
value
=
false
}
}
const
handleClose
=
()
=>
{
emit
(
'
close
'
)
}
// Format helpers
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
formatNumber
=
(
value
:
number
):
string
=>
{
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
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
()
}
const
formatDuration
=
(
ms
:
number
):
string
=>
{
if
(
ms
>=
1000
)
{
return
`${(ms / 1000).toFixed(2)
}
s`
}
return
`${Math.round(ms)
}
ms`
}
<
/script
>
frontend/src/components/admin/account/AccountTableActions.vue
0 → 100644
View file @
fd29fe11
<
template
>
<div
class=
"flex max-w-full flex-wrap justify-end gap-3"
>
<button
@
click=
"$emit('refresh')"
:disabled=
"loading"
class=
"btn btn-secondary flex-shrink-0"
><svg
:class=
"['h-5 w-5', loading ? 'animate-spin' : '']"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
><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=
"$emit('sync')"
class=
"btn btn-secondary flex-shrink-0"
>
{{
t
(
'
admin.accounts.syncFromCrs
'
)
}}
</button>
<button
@
click=
"$emit('create')"
class=
"btn btn-primary flex-shrink-0"
>
{{
t
(
'
admin.accounts.createAccount
'
)
}}
</button>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
useI18n
}
from
'
vue-i18n
'
;
defineProps
([
'
loading
'
]);
defineEmits
([
'
refresh
'
,
'
sync
'
,
'
create
'
]);
const
{
t
}
=
useI18n
()
</
script
>
frontend/src/components/admin/account/AccountTableFilters.vue
0 → 100644
View file @
fd29fe11
<
template
>
<div
class=
"flex flex-wrap items-start gap-3"
>
<div
class=
"min-w-0 flex-1"
>
<SearchInput
:model-value=
"searchQuery"
:placeholder=
"t('admin.accounts.searchAccounts')"
@
update:model-value=
"$emit('update:searchQuery', $event)"
@
search=
"$emit('change')"
/>
</div>
<div
class=
"flex flex-wrap items-center gap-3"
>
<Select
v-model=
"filters.platform"
class=
"w-40 flex-shrink-0"
:options=
"pOpts"
@
change=
"$emit('change')"
/>
<Select
v-model=
"filters.status"
class=
"w-40 flex-shrink-0"
:options=
"sOpts"
@
change=
"$emit('change')"
/>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
;
import
{
useI18n
}
from
'
vue-i18n
'
;
import
Select
from
'
@/components/common/Select.vue
'
;
import
SearchInput
from
'
@/components/common/SearchInput.vue
'
defineProps
([
'
searchQuery
'
,
'
filters
'
]);
defineEmits
([
'
update:searchQuery
'
,
'
change
'
]);
const
{
t
}
=
useI18n
()
const
pOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allPlatforms
'
)
},
{
value
:
'
openai
'
,
label
:
'
OpenAI
'
},
{
value
:
'
anthropic
'
,
label
:
'
Anthropic
'
},
{
value
:
'
gemini
'
,
label
:
'
Gemini
'
}])
const
sOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allStatus
'
)
},
{
value
:
'
active
'
,
label
:
t
(
'
admin.accounts.status.active
'
)
},
{
value
:
'
error
'
,
label
:
t
(
'
admin.accounts.status.error
'
)
}])
</
script
>
frontend/src/components/admin/account/AccountTestModal.vue
0 → 100644
View file @
fd29fe11
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.accounts.testAccountConnection')"
width=
"normal"
@
close=
"handleClose"
>
<div
class=
"space-y-4"
>
<!-- Account Info Card -->
<div
v-if=
"account"
class=
"flex items-center justify-between rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-gray-100 p-3 dark:border-dark-500 dark:from-dark-700 dark:to-dark-600"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
>
<svg
class=
"h-5 w-5 text-white"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<div
class=
"font-semibold text-gray-900 dark:text-gray-100"
>
{{
account
.
name
}}
</div>
<div
class=
"flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400"
>
<span
class=
"rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium uppercase dark:bg-dark-500"
>
{{
account
.
type
}}
</span>
<span>
{{
t
(
'
admin.accounts.account
'
)
}}
</span>
</div>
</div>
</div>
<span
:class=
"[
'rounded-full px-2.5 py-1 text-xs font-semibold',
account.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
]"
>
{{
account
.
status
}}
</span>
</div>
<div
class=
"space-y-1.5"
>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.accounts.selectTestModel
'
)
}}
</label>
<Select
v-model=
"selectedModelId"
:options=
"availableModels"
:disabled=
"loadingModels || status === 'connecting'"
value-key=
"id"
label-key=
"display_name"
:placeholder=
"loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')"
/>
</div>
<!-- Terminal Output -->
<div
class=
"group relative"
>
<div
ref=
"terminalRef"
class=
"max-h-[240px] min-h-[120px] overflow-y-auto rounded-xl border border-gray-700 bg-gray-900 p-4 font-mono text-sm dark:border-gray-800 dark:bg-black"
>
<!-- Status Line -->
<div
v-if=
"status === 'idle'"
class=
"flex items-center gap-2 text-gray-500"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<span>
{{
t
(
'
admin.accounts.readyToTest
'
)
}}
</span>
</div>
<div
v-else-if=
"status === 'connecting'"
class=
"flex items-center gap-2 text-yellow-400"
>
<svg
class=
"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>
<span>
{{
t
(
'
admin.accounts.connectingToApi
'
)
}}
</span>
</div>
<!-- Output Lines -->
<div
v-for=
"(line, index) in outputLines"
:key=
"index"
:class=
"line.class"
>
{{
line
.
text
}}
</div>
<!-- Streaming Content -->
<div
v-if=
"streamingContent"
class=
"text-green-400"
>
{{
streamingContent
}}
<span
class=
"animate-pulse"
>
_
</span>
</div>
<!-- Result Status -->
<div
v-if=
"status === 'success'"
class=
"mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-green-400"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>
{{
t
(
'
admin.accounts.testCompleted
'
)
}}
</span>
</div>
<div
v-else-if=
"status === 'error'"
class=
"mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-red-400"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>
{{
errorMessage
}}
</span>
</div>
</div>
<!-- Copy Button -->
<button
v-if=
"outputLines.length > 0"
@
click=
"copyOutput"
class=
"absolute right-2 top-2 rounded-lg bg-gray-800/80 p-1.5 text-gray-400 opacity-0 transition-all hover:bg-gray-700 hover:text-white group-hover:opacity-100"
:title=
"t('admin.accounts.copyOutput')"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"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>
<!-- Test Info -->
<div
class=
"flex items-center justify-between px-1 text-xs text-gray-500 dark:text-gray-400"
>
<div
class=
"flex items-center gap-3"
>
<span
class=
"flex items-center gap-1"
>
<svg
class=
"h-3.5 w-3.5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
{{
t
(
'
admin.accounts.testModel
'
)
}}
</span>
</div>
<span
class=
"flex items-center gap-1"
>
<svg
class=
"h-3.5 w-3.5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
{{
t
(
'
admin.accounts.testPrompt
'
)
}}
</span>
</div>
</div>
<template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"handleClose"
class=
"rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
:disabled=
"status === 'connecting'"
>
{{
t
(
'
common.close
'
)
}}
</button>
<button
@
click=
"startTest"
:disabled=
"status === 'connecting' || !selectedModelId"
:class=
"[
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
status === 'connecting' || !selectedModelId
? 'cursor-not-allowed bg-primary-400 text-white'
: status === 'success'
? 'bg-green-500 text-white hover:bg-green-600'
: status === 'error'
? 'bg-orange-500 text-white hover:bg-orange-600'
: 'bg-primary-500 text-white hover:bg-primary-600'
]"
>
<svg
v-if=
"status === 'connecting'"
class=
"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>
<svg
v-else-if=
"status === 'idle'"
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<svg
v-else
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span>
{{
status
===
'
connecting
'
?
t
(
'
admin.accounts.testing
'
)
:
status
===
'
idle
'
?
t
(
'
admin.accounts.startTest
'
)
:
t
(
'
admin.accounts.retry
'
)
}}
</span>
</button>
</div>
</
template
>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
watch
,
nextTick
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Account
,
ClaudeModel
}
from
'
@/types
'
const
{
t
}
=
useI18n
()
const
{
copyToClipboard
}
=
useClipboard
()
interface
OutputLine
{
text
:
string
class
:
string
}
const
props
=
defineProps
<
{
show
:
boolean
account
:
Account
|
null
}
>
()
const
emit
=
defineEmits
<
{
(
e
:
'
close
'
):
void
}
>
()
const
terminalRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
status
=
ref
<
'
idle
'
|
'
connecting
'
|
'
success
'
|
'
error
'
>
(
'
idle
'
)
const
outputLines
=
ref
<
OutputLine
[]
>
([])
const
streamingContent
=
ref
(
''
)
const
errorMessage
=
ref
(
''
)
const
availableModels
=
ref
<
ClaudeModel
[]
>
([])
const
selectedModelId
=
ref
(
''
)
const
loadingModels
=
ref
(
false
)
let
eventSource
:
EventSource
|
null
=
null
// Load available models when modal opens
watch
(
()
=>
props
.
show
,
async
(
newVal
)
=>
{
if
(
newVal
&&
props
.
account
)
{
resetState
()
await
loadAvailableModels
()
}
else
{
closeEventSource
()
}
}
)
const
loadAvailableModels
=
async
()
=>
{
if
(
!
props
.
account
)
return
loadingModels
.
value
=
true
selectedModelId
.
value
=
''
// Reset selection before loading
try
{
availableModels
.
value
=
await
adminAPI
.
accounts
.
getAvailableModels
(
props
.
account
.
id
)
// Default selection by platform
if
(
availableModels
.
value
.
length
>
0
)
{
if
(
props
.
account
.
platform
===
'
gemini
'
)
{
const
preferred
=
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-2.5-pro
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-3-pro
'
)
selectedModelId
.
value
=
preferred
?.
id
||
availableModels
.
value
[
0
].
id
}
else
{
// Try to select Sonnet as default, otherwise use first model
const
sonnetModel
=
availableModels
.
value
.
find
((
m
)
=>
m
.
id
.
includes
(
'
sonnet
'
))
selectedModelId
.
value
=
sonnetModel
?.
id
||
availableModels
.
value
[
0
].
id
}
}
}
catch
(
error
)
{
console
.
error
(
'
Failed to load available models:
'
,
error
)
// Fallback to empty list
availableModels
.
value
=
[]
selectedModelId
.
value
=
''
}
finally
{
loadingModels
.
value
=
false
}
}
const
resetState
=
()
=>
{
status
.
value
=
'
idle
'
outputLines
.
value
=
[]
streamingContent
.
value
=
''
errorMessage
.
value
=
''
}
const
handleClose
=
()
=>
{
// 防止在连接测试进行中关闭对话框
if
(
status
.
value
===
'
connecting
'
)
{
return
}
closeEventSource
()
emit
(
'
close
'
)
}
const
closeEventSource
=
()
=>
{
if
(
eventSource
)
{
eventSource
.
close
()
eventSource
=
null
}
}
const
addLine
=
(
text
:
string
,
className
:
string
=
'
text-gray-300
'
)
=>
{
outputLines
.
value
.
push
({
text
,
class
:
className
})
scrollToBottom
()
}
const
scrollToBottom
=
async
()
=>
{
await
nextTick
()
if
(
terminalRef
.
value
)
{
terminalRef
.
value
.
scrollTop
=
terminalRef
.
value
.
scrollHeight
}
}
const
startTest
=
async
()
=>
{
if
(
!
props
.
account
||
!
selectedModelId
.
value
)
return
resetState
()
status
.
value
=
'
connecting
'
addLine
(
t
(
'
admin.accounts.startingTestForAccount
'
,
{
name
:
props
.
account
.
name
}),
'
text-blue-400
'
)
addLine
(
t
(
'
admin.accounts.testAccountTypeLabel
'
,
{
type
:
props
.
account
.
type
}),
'
text-gray-400
'
)
addLine
(
''
,
'
text-gray-300
'
)
closeEventSource
()
try
{
// Create EventSource for SSE
const
url
=
`/api/v1/admin/accounts/
${
props
.
account
.
id
}
/test`
// Use fetch with streaming for SSE since EventSource doesn't support POST
const
response
=
await
fetch
(
url
,
{
method
:
'
POST
'
,
headers
:
{
Authorization
:
`Bearer
${
localStorage
.
getItem
(
'
auth_token
'
)}
`
,
'
Content-Type
'
:
'
application/json
'
},
body
:
JSON
.
stringify
({
model_id
:
selectedModelId
.
value
})
})
if
(
!
response
.
ok
)
{
throw
new
Error
(
`HTTP error! status:
${
response
.
status
}
`
)
}
const
reader
=
response
.
body
?.
getReader
()
if
(
!
reader
)
{
throw
new
Error
(
'
No response body
'
)
}
const
decoder
=
new
TextDecoder
()
let
buffer
=
''
while
(
true
)
{
const
{
done
,
value
}
=
await
reader
.
read
()
if
(
done
)
break
buffer
+=
decoder
.
decode
(
value
,
{
stream
:
true
})
const
lines
=
buffer
.
split
(
'
\n
'
)
buffer
=
lines
.
pop
()
||
''
for
(
const
line
of
lines
)
{
if
(
line
.
startsWith
(
'
data:
'
))
{
const
jsonStr
=
line
.
slice
(
6
).
trim
()
if
(
jsonStr
)
{
try
{
const
event
=
JSON
.
parse
(
jsonStr
)
handleEvent
(
event
)
}
catch
(
e
)
{
console
.
error
(
'
Failed to parse SSE event:
'
,
e
)
}
}
}
}
}
}
catch
(
error
:
any
)
{
status
.
value
=
'
error
'
errorMessage
.
value
=
error
.
message
||
'
Unknown error
'
addLine
(
`Error:
${
errorMessage
.
value
}
`
,
'
text-red-400
'
)
}
}
const
handleEvent
=
(
event
:
{
type
:
string
text
?:
string
model
?:
string
success
?:
boolean
error
?:
string
})
=>
{
switch
(
event
.
type
)
{
case
'
test_start
'
:
addLine
(
t
(
'
admin.accounts.connectedToApi
'
),
'
text-green-400
'
)
if
(
event
.
model
)
{
addLine
(
t
(
'
admin.accounts.usingModel
'
,
{
model
:
event
.
model
}),
'
text-cyan-400
'
)
}
addLine
(
t
(
'
admin.accounts.sendingTestMessage
'
),
'
text-gray-400
'
)
addLine
(
''
,
'
text-gray-300
'
)
addLine
(
t
(
'
admin.accounts.response
'
),
'
text-yellow-400
'
)
break
case
'
content
'
:
if
(
event
.
text
)
{
streamingContent
.
value
+=
event
.
text
scrollToBottom
()
}
break
case
'
test_complete
'
:
// Move streaming content to output lines
if
(
streamingContent
.
value
)
{
addLine
(
streamingContent
.
value
,
'
text-green-300
'
)
streamingContent
.
value
=
''
}
if
(
event
.
success
)
{
status
.
value
=
'
success
'
}
else
{
status
.
value
=
'
error
'
errorMessage
.
value
=
event
.
error
||
'
Test failed
'
}
break
case
'
error
'
:
status
.
value
=
'
error
'
errorMessage
.
value
=
event
.
error
||
'
Unknown error
'
if
(
streamingContent
.
value
)
{
addLine
(
streamingContent
.
value
,
'
text-green-300
'
)
streamingContent
.
value
=
''
}
break
}
}
const
copyOutput
=
()
=>
{
const
text
=
outputLines
.
value
.
map
((
l
)
=>
l
.
text
).
join
(
'
\n
'
)
copyToClipboard
(
text
,
t
(
'
admin.accounts.outputCopied
'
))
}
</
script
>
frontend/src/components/admin/account/ReAuthAccountModal.vue
0 → 100644
View file @
fd29fe11
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.accounts.reAuthorizeAccount')"
width=
"normal"
@
close=
"handleClose"
>
<div
v-if=
"account"
class=
"space-y-4"
>
<!-- Account Info -->
<div
class=
"rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-700"
>
<div
class=
"flex items-center gap-3"
>
<div
:class=
"[
'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br',
isOpenAI
? 'from-green-500 to-green-600'
: isGemini
? 'from-blue-500 to-blue-600'
: isAntigravity
? 'from-purple-500 to-purple-600'
: 'from-orange-500 to-orange-600'
]"
>
<svg
class=
"h-5 w-5 text-white"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</svg>
</div>
<div>
<span
class=
"block font-semibold text-gray-900 dark:text-white"
>
{{
account
.
name
}}
</span>
<span
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
isOpenAI
?
t
(
'
admin.accounts.openaiAccount
'
)
:
isGemini
?
t
(
'
admin.accounts.geminiAccount
'
)
:
isAntigravity
?
t
(
'
admin.accounts.antigravityAccount
'
)
:
t
(
'
admin.accounts.claudeCodeAccount
'
)
}}
</span>
</div>
</div>
</div>
<!-- Add Method Selection (Claude only) -->
<fieldset
v-if=
"isAnthropic"
class=
"border-0 p-0"
>
<legend
class=
"input-label"
>
{{
t
(
'
admin.accounts.oauth.authMethod
'
)
}}
</legend>
<div
class=
"mt-2 flex gap-4"
>
<label
class=
"flex cursor-pointer items-center"
>
<input
v-model=
"addMethod"
type=
"radio"
value=
"oauth"
class=
"mr-2 text-primary-600 focus:ring-primary-500"
/>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.accounts.types.oauth
'
)
}}
</span>
</label>
<label
class=
"flex cursor-pointer items-center"
>
<input
v-model=
"addMethod"
type=
"radio"
value=
"setup-token"
class=
"mr-2 text-primary-600 focus:ring-primary-500"
/>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.accounts.setupTokenLongLived
'
)
}}
</span>
</label>
</div>
</fieldset>
<!-- Gemini OAuth Type Selection -->
<fieldset
v-if=
"isGemini"
class=
"border-0 p-0"
>
<legend
class=
"input-label"
>
{{
t
(
'
admin.accounts.oauth.gemini.oauthTypeLabel
'
)
}}
</legend>
<div
class=
"mt-2 grid grid-cols-3 gap-3"
>
<button
type=
"button"
@
click=
"handleSelectGeminiOAuthType('google_one')"
:class=
"[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
geminiOAuthType === 'google_one'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
]"
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'google_one'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<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=
"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>
</div>
<div
class=
"min-w-0"
>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
Google One
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
个人账号
</span>
</div>
</button>
<button
type=
"button"
@
click=
"handleSelectGeminiOAuthType('code_assist')"
:class=
"[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
geminiOAuthType === 'code_assist'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 hover:border-blue-300 dark:border-dark-600 dark:hover:border-blue-700'
]"
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'code_assist'
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<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=
"M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z"
/>
</svg>
</div>
<div
class=
"min-w-0"
>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
admin.accounts.gemini.oauthType.builtInTitle
'
)
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.gemini.oauthType.builtInDesc
'
)
}}
</span>
</div>
</button>
<button
type=
"button"
:disabled=
"!geminiAIStudioOAuthEnabled"
@
click=
"handleSelectGeminiOAuthType('ai_studio')"
:class=
"[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
!geminiAIStudioOAuthEnabled ? 'cursor-not-allowed opacity-60' : '',
geminiOAuthType === 'ai_studio'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
]"
>
<div
:class=
"[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'ai_studio'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<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=
"M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</svg>
</div>
<div
class=
"min-w-0"
>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
admin.accounts.gemini.oauthType.customTitle
'
)
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.gemini.oauthType.customDesc
'
)
}}
</span>
<div
v-if=
"!geminiAIStudioOAuthEnabled"
class=
"group relative mt-1 inline-block"
>
<span
class=
"rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
>
{{
t
(
'
admin.accounts.oauth.gemini.aiStudioNotConfiguredShort
'
)
}}
</span>
<div
class=
"pointer-events-none absolute left-0 top-full z-10 mt-2 w-[28rem] rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 opacity-0 shadow-sm transition-opacity group-hover:opacity-100 dark:border-amber-700 dark:bg-amber-900/40 dark:text-amber-200"
>
{{
t
(
'
admin.accounts.oauth.gemini.aiStudioNotConfiguredTip
'
)
}}
</div>
</div>
</div>
</button>
</div>
</fieldset>
<OAuthAuthorizationFlow
ref=
"oauthFlowRef"
:add-method=
"addMethod"
:auth-url=
"currentAuthUrl"
:session-id=
"currentSessionId"
:loading=
"currentLoading"
:error=
"currentError"
:show-help=
"isAnthropic"
:show-proxy-warning=
"isAnthropic"
:show-cookie-option=
"isAnthropic"
:allow-multiple=
"false"
:method-label=
"t('admin.accounts.inputMethod')"
:platform=
"isOpenAI ? 'openai' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
:show-project-id=
"isGemini && geminiOAuthType === 'code_assist'"
@
generate-url=
"handleGenerateUrl"
@
cookie-auth=
"handleCookieAuth"
/>
</div>
<template
#footer
>
<div
v-if=
"account"
class=
"flex justify-between gap-3"
>
<button
type=
"button"
class=
"btn btn-secondary"
@
click=
"handleClose"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
v-if=
"isManualInputMethod"
type=
"button"
:disabled=
"!canExchangeCode"
class=
"btn btn-primary"
@
click=
"handleExchangeCode"
>
<svg
v-if=
"currentLoading"
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>
{{
currentLoading
?
t
(
'
admin.accounts.oauth.verifying
'
)
:
t
(
'
admin.accounts.oauth.completeAuth
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
useAccountOAuth
,
type
AddMethod
,
type
AuthInputMethod
}
from
'
@/composables/useAccountOAuth
'
import
{
useOpenAIOAuth
}
from
'
@/composables/useOpenAIOAuth
'
import
{
useGeminiOAuth
}
from
'
@/composables/useGeminiOAuth
'
import
{
useAntigravityOAuth
}
from
'
@/composables/useAntigravityOAuth
'
import
type
{
Account
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
OAuthAuthorizationFlow
from
'
@/components/account/OAuthAuthorizationFlow.vue
'
// Type for exposed OAuthAuthorizationFlow component
// Note: defineExpose automatically unwraps refs, so we use the unwrapped types
interface
OAuthFlowExposed
{
authCode
:
string
oauthState
:
string
projectId
:
string
sessionKey
:
string
inputMethod
:
AuthInputMethod
reset
:
()
=>
void
}
interface
Props
{
show
:
boolean
account
:
Account
|
null
}
const
props
=
defineProps
<
Props
>
()
const
emit
=
defineEmits
<
{
close
:
[]
reauthorized
:
[]
}
>
()
const
appStore
=
useAppStore
()
const
{
t
}
=
useI18n
()
// OAuth composables
const
claudeOAuth
=
useAccountOAuth
()
const
openaiOAuth
=
useOpenAIOAuth
()
const
geminiOAuth
=
useGeminiOAuth
()
const
antigravityOAuth
=
useAntigravityOAuth
()
// Refs
const
oauthFlowRef
=
ref
<
OAuthFlowExposed
|
null
>
(
null
)
// State
const
addMethod
=
ref
<
AddMethod
>
(
'
oauth
'
)
const
geminiOAuthType
=
ref
<
'
code_assist
'
|
'
google_one
'
|
'
ai_studio
'
>
(
'
code_assist
'
)
const
geminiAIStudioOAuthEnabled
=
ref
(
false
)
// Computed - check platform
const
isOpenAI
=
computed
(()
=>
props
.
account
?.
platform
===
'
openai
'
)
const
isGemini
=
computed
(()
=>
props
.
account
?.
platform
===
'
gemini
'
)
const
isAnthropic
=
computed
(()
=>
props
.
account
?.
platform
===
'
anthropic
'
)
const
isAntigravity
=
computed
(()
=>
props
.
account
?.
platform
===
'
antigravity
'
)
// Computed - current OAuth state based on platform
const
currentAuthUrl
=
computed
(()
=>
{
if
(
isOpenAI
.
value
)
return
openaiOAuth
.
authUrl
.
value
if
(
isGemini
.
value
)
return
geminiOAuth
.
authUrl
.
value
if
(
isAntigravity
.
value
)
return
antigravityOAuth
.
authUrl
.
value
return
claudeOAuth
.
authUrl
.
value
})
const
currentSessionId
=
computed
(()
=>
{
if
(
isOpenAI
.
value
)
return
openaiOAuth
.
sessionId
.
value
if
(
isGemini
.
value
)
return
geminiOAuth
.
sessionId
.
value
if
(
isAntigravity
.
value
)
return
antigravityOAuth
.
sessionId
.
value
return
claudeOAuth
.
sessionId
.
value
})
const
currentLoading
=
computed
(()
=>
{
if
(
isOpenAI
.
value
)
return
openaiOAuth
.
loading
.
value
if
(
isGemini
.
value
)
return
geminiOAuth
.
loading
.
value
if
(
isAntigravity
.
value
)
return
antigravityOAuth
.
loading
.
value
return
claudeOAuth
.
loading
.
value
})
const
currentError
=
computed
(()
=>
{
if
(
isOpenAI
.
value
)
return
openaiOAuth
.
error
.
value
if
(
isGemini
.
value
)
return
geminiOAuth
.
error
.
value
if
(
isAntigravity
.
value
)
return
antigravityOAuth
.
error
.
value
return
claudeOAuth
.
error
.
value
})
// Computed
const
isManualInputMethod
=
computed
(()
=>
{
// OpenAI/Gemini/Antigravity always use manual input (no cookie auth option)
return
isOpenAI
.
value
||
isGemini
.
value
||
isAntigravity
.
value
||
oauthFlowRef
.
value
?.
inputMethod
===
'
manual
'
})
const
canExchangeCode
=
computed
(()
=>
{
const
authCode
=
oauthFlowRef
.
value
?.
authCode
||
''
const
sessionId
=
currentSessionId
.
value
const
loading
=
currentLoading
.
value
return
authCode
.
trim
()
&&
sessionId
&&
!
loading
})
// Watchers
watch
(
()
=>
props
.
show
,
(
newVal
)
=>
{
if
(
newVal
&&
props
.
account
)
{
// Initialize addMethod based on current account type (Claude only)
if
(
isAnthropic
.
value
&&
(
props
.
account
.
type
===
'
oauth
'
||
props
.
account
.
type
===
'
setup-token
'
)
)
{
addMethod
.
value
=
props
.
account
.
type
as
AddMethod
}
if
(
isGemini
.
value
)
{
const
creds
=
(
props
.
account
.
credentials
||
{})
as
Record
<
string
,
unknown
>
geminiOAuthType
.
value
=
creds
.
oauth_type
===
'
google_one
'
?
'
google_one
'
:
creds
.
oauth_type
===
'
ai_studio
'
?
'
ai_studio
'
:
'
code_assist
'
}
if
(
isGemini
.
value
)
{
geminiOAuth
.
getCapabilities
().
then
((
caps
)
=>
{
geminiAIStudioOAuthEnabled
.
value
=
!!
caps
?.
ai_studio_oauth_enabled
if
(
!
geminiAIStudioOAuthEnabled
.
value
&&
geminiOAuthType
.
value
===
'
ai_studio
'
)
{
geminiOAuthType
.
value
=
'
code_assist
'
}
})
}
}
else
{
resetState
()
}
}
)
// Methods
const
resetState
=
()
=>
{
addMethod
.
value
=
'
oauth
'
geminiOAuthType
.
value
=
'
code_assist
'
geminiAIStudioOAuthEnabled
.
value
=
false
claudeOAuth
.
resetState
()
openaiOAuth
.
resetState
()
geminiOAuth
.
resetState
()
antigravityOAuth
.
resetState
()
oauthFlowRef
.
value
?.
reset
()
}
const
handleSelectGeminiOAuthType
=
(
oauthType
:
'
code_assist
'
|
'
google_one
'
|
'
ai_studio
'
)
=>
{
if
(
oauthType
===
'
ai_studio
'
&&
!
geminiAIStudioOAuthEnabled
.
value
)
{
appStore
.
showError
(
t
(
'
admin.accounts.oauth.gemini.aiStudioNotConfigured
'
))
return
}
geminiOAuthType
.
value
=
oauthType
}
const
handleClose
=
()
=>
{
emit
(
'
close
'
)
}
const
handleGenerateUrl
=
async
()
=>
{
if
(
!
props
.
account
)
return
if
(
isOpenAI
.
value
)
{
await
openaiOAuth
.
generateAuthUrl
(
props
.
account
.
proxy_id
)
}
else
if
(
isGemini
.
value
)
{
const
creds
=
(
props
.
account
.
credentials
||
{})
as
Record
<
string
,
unknown
>
const
tierId
=
typeof
creds
.
tier_id
===
'
string
'
?
creds
.
tier_id
:
undefined
const
projectId
=
geminiOAuthType
.
value
===
'
code_assist
'
?
oauthFlowRef
.
value
?.
projectId
:
undefined
await
geminiOAuth
.
generateAuthUrl
(
props
.
account
.
proxy_id
,
projectId
,
geminiOAuthType
.
value
,
tierId
)
}
else
if
(
isAntigravity
.
value
)
{
await
antigravityOAuth
.
generateAuthUrl
(
props
.
account
.
proxy_id
)
}
else
{
await
claudeOAuth
.
generateAuthUrl
(
addMethod
.
value
,
props
.
account
.
proxy_id
)
}
}
const
handleExchangeCode
=
async
()
=>
{
if
(
!
props
.
account
)
return
const
authCode
=
oauthFlowRef
.
value
?.
authCode
||
''
if
(
!
authCode
.
trim
())
return
if
(
isOpenAI
.
value
)
{
// OpenAI OAuth flow
const
sessionId
=
openaiOAuth
.
sessionId
.
value
if
(
!
sessionId
)
return
const
tokenInfo
=
await
openaiOAuth
.
exchangeAuthCode
(
authCode
.
trim
(),
sessionId
,
props
.
account
.
proxy_id
)
if
(
!
tokenInfo
)
return
// Build credentials and extra info
const
credentials
=
openaiOAuth
.
buildCredentials
(
tokenInfo
)
const
extra
=
openaiOAuth
.
buildExtraInfo
(
tokenInfo
)
try
{
// Update account with new credentials
await
adminAPI
.
accounts
.
update
(
props
.
account
.
id
,
{
type
:
'
oauth
'
,
// OpenAI OAuth is always 'oauth' type
credentials
,
extra
})
// Clear error status after successful re-authorization
await
adminAPI
.
accounts
.
clearError
(
props
.
account
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.reAuthorizedSuccess
'
))
emit
(
'
reauthorized
'
)
handleClose
()
}
catch
(
error
:
any
)
{
openaiOAuth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
appStore
.
showError
(
openaiOAuth
.
error
.
value
)
}
}
else
if
(
isGemini
.
value
)
{
const
sessionId
=
geminiOAuth
.
sessionId
.
value
if
(
!
sessionId
)
return
const
stateFromInput
=
oauthFlowRef
.
value
?.
oauthState
||
''
const
stateToUse
=
stateFromInput
||
geminiOAuth
.
state
.
value
if
(
!
stateToUse
)
return
const
tokenInfo
=
await
geminiOAuth
.
exchangeAuthCode
({
code
:
authCode
.
trim
(),
sessionId
,
state
:
stateToUse
,
proxyId
:
props
.
account
.
proxy_id
,
oauthType
:
geminiOAuthType
.
value
,
tierId
:
typeof
(
props
.
account
.
credentials
as
any
)?.
tier_id
===
'
string
'
?
((
props
.
account
.
credentials
as
any
).
tier_id
as
string
)
:
undefined
})
if
(
!
tokenInfo
)
return
const
credentials
=
geminiOAuth
.
buildCredentials
(
tokenInfo
)
try
{
await
adminAPI
.
accounts
.
update
(
props
.
account
.
id
,
{
type
:
'
oauth
'
,
credentials
})
await
adminAPI
.
accounts
.
clearError
(
props
.
account
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.reAuthorizedSuccess
'
))
emit
(
'
reauthorized
'
)
handleClose
()
}
catch
(
error
:
any
)
{
geminiOAuth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
appStore
.
showError
(
geminiOAuth
.
error
.
value
)
}
}
else
if
(
isAntigravity
.
value
)
{
// Antigravity OAuth flow
const
sessionId
=
antigravityOAuth
.
sessionId
.
value
if
(
!
sessionId
)
return
const
stateFromInput
=
oauthFlowRef
.
value
?.
oauthState
||
''
const
stateToUse
=
stateFromInput
||
antigravityOAuth
.
state
.
value
if
(
!
stateToUse
)
return
const
tokenInfo
=
await
antigravityOAuth
.
exchangeAuthCode
({
code
:
authCode
.
trim
(),
sessionId
,
state
:
stateToUse
,
proxyId
:
props
.
account
.
proxy_id
})
if
(
!
tokenInfo
)
return
const
credentials
=
antigravityOAuth
.
buildCredentials
(
tokenInfo
)
try
{
await
adminAPI
.
accounts
.
update
(
props
.
account
.
id
,
{
type
:
'
oauth
'
,
credentials
})
await
adminAPI
.
accounts
.
clearError
(
props
.
account
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.reAuthorizedSuccess
'
))
emit
(
'
reauthorized
'
)
handleClose
()
}
catch
(
error
:
any
)
{
antigravityOAuth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
appStore
.
showError
(
antigravityOAuth
.
error
.
value
)
}
}
else
{
// Claude OAuth flow
const
sessionId
=
claudeOAuth
.
sessionId
.
value
if
(
!
sessionId
)
return
claudeOAuth
.
loading
.
value
=
true
claudeOAuth
.
error
.
value
=
''
try
{
const
proxyConfig
=
props
.
account
.
proxy_id
?
{
proxy_id
:
props
.
account
.
proxy_id
}
:
{}
const
endpoint
=
addMethod
.
value
===
'
oauth
'
?
'
/admin/accounts/exchange-code
'
:
'
/admin/accounts/exchange-setup-token-code
'
const
tokenInfo
=
await
adminAPI
.
accounts
.
exchangeCode
(
endpoint
,
{
session_id
:
sessionId
,
code
:
authCode
.
trim
(),
...
proxyConfig
})
const
extra
=
claudeOAuth
.
buildExtraInfo
(
tokenInfo
)
// Update account with new credentials and type
await
adminAPI
.
accounts
.
update
(
props
.
account
.
id
,
{
type
:
addMethod
.
value
,
// Update type based on selected method
credentials
:
tokenInfo
,
extra
})
// Clear error status after successful re-authorization
await
adminAPI
.
accounts
.
clearError
(
props
.
account
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.reAuthorizedSuccess
'
))
emit
(
'
reauthorized
'
)
handleClose
()
}
catch
(
error
:
any
)
{
claudeOAuth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
appStore
.
showError
(
claudeOAuth
.
error
.
value
)
}
finally
{
claudeOAuth
.
loading
.
value
=
false
}
}
}
const
handleCookieAuth
=
async
(
sessionKey
:
string
)
=>
{
if
(
!
props
.
account
||
isOpenAI
.
value
)
return
claudeOAuth
.
loading
.
value
=
true
claudeOAuth
.
error
.
value
=
''
try
{
const
proxyConfig
=
props
.
account
.
proxy_id
?
{
proxy_id
:
props
.
account
.
proxy_id
}
:
{}
const
endpoint
=
addMethod
.
value
===
'
oauth
'
?
'
/admin/accounts/cookie-auth
'
:
'
/admin/accounts/setup-token-cookie-auth
'
const
tokenInfo
=
await
adminAPI
.
accounts
.
exchangeCode
(
endpoint
,
{
session_id
:
''
,
code
:
sessionKey
.
trim
(),
...
proxyConfig
})
const
extra
=
claudeOAuth
.
buildExtraInfo
(
tokenInfo
)
// Update account with new credentials and type
await
adminAPI
.
accounts
.
update
(
props
.
account
.
id
,
{
type
:
addMethod
.
value
,
// Update type based on selected method
credentials
:
tokenInfo
,
extra
})
// Clear error status after successful re-authorization
await
adminAPI
.
accounts
.
clearError
(
props
.
account
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.reAuthorizedSuccess
'
))
emit
(
'
reauthorized
'
)
handleClose
()
}
catch
(
error
:
any
)
{
claudeOAuth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.cookieAuthFailed
'
)
}
finally
{
claudeOAuth
.
loading
.
value
=
false
}
}
</
script
>
frontend/src/components/admin/usage/UsageExportProgress.vue
0 → 100644
View file @
fd29fe11
<
template
>
<ExportProgressDialog
:show=
"show"
:progress=
"progress"
:current=
"current"
:total=
"total"
:estimated-time=
"estimatedTime"
@
cancel=
"$emit('cancel')"
/>
</
template
>
<
script
setup
lang=
"ts"
>
import
ExportProgressDialog
from
'
@/components/common/ExportProgressDialog.vue
'
defineProps
<
{
show
:
boolean
,
progress
:
number
,
current
:
number
,
total
:
number
,
estimatedTime
:
string
}
>
()
defineEmits
([
'
cancel
'
])
</
script
>
\ No newline at end of file
frontend/src/components/admin/usage/UsageFilters.vue
0 → 100644
View file @
fd29fe11
<
template
>
<div
class=
"card p-6"
>
<!-- Toolbar: left filters (multi-line) + right actions -->
<div
class=
"flex flex-wrap items-end justify-between gap-4"
>
<!-- Left: filters (allowed to wrap to multiple rows) -->
<div
class=
"flex flex-1 flex-wrap items-end gap-4"
>
<!-- User Search -->
<div
ref=
"userSearchRef"
class=
"usage-filter-dropdown relative w-full sm:w-auto sm:min-w-[240px]"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.usage.userFilter
'
)
}}
</label>
<input
v-model=
"userKeyword"
type=
"text"
class=
"input pr-8"
:placeholder=
"t('admin.usage.searchUserPlaceholder')"
@
input=
"debounceUserSearch"
@
focus=
"showUserDropdown = true"
/>
<button
v-if=
"filters.user_id"
type=
"button"
@
click=
"clearUser"
class=
"absolute right-2 top-9 text-gray-400"
aria-label=
"Clear user filter"
>
✕
</button>
<div
v-if=
"showUserDropdown && (userResults.length > 0 || userKeyword)"
class=
"absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
>
<button
v-for=
"u in userResults"
:key=
"u.id"
type=
"button"
@
click=
"selectUser(u)"
class=
"w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span>
{{
u
.
email
}}
</span>
<span
class=
"ml-2 text-xs text-gray-400"
>
#
{{
u
.
id
}}
</span>
</button>
</div>
</div>
<!-- API Key Search -->
<div
ref=
"apiKeySearchRef"
class=
"usage-filter-dropdown relative w-full sm:w-auto sm:min-w-[240px]"
>
<label
class=
"input-label"
>
{{
t
(
'
usage.apiKeyFilter
'
)
}}
</label>
<input
v-model=
"apiKeyKeyword"
type=
"text"
class=
"input pr-8"
:placeholder=
"t('admin.usage.searchApiKeyPlaceholder')"
@
input=
"debounceApiKeySearch"
@
focus=
"showApiKeyDropdown = true"
/>
<button
v-if=
"filters.api_key_id"
type=
"button"
@
click=
"onClearApiKey"
class=
"absolute right-2 top-9 text-gray-400"
aria-label=
"Clear API key filter"
>
✕
</button>
<div
v-if=
"showApiKeyDropdown && (apiKeyResults.length > 0 || apiKeyKeyword)"
class=
"absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
>
<button
v-for=
"k in apiKeyResults"
:key=
"k.id"
type=
"button"
@
click=
"selectApiKey(k)"
class=
"w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span
class=
"truncate"
>
{{
k
.
name
||
`#${k.id
}
`
}}
<
/span
>
<
span
class
=
"
ml-2 text-xs text-gray-400
"
>
#
{{
k
.
id
}}
<
/span
>
<
/button
>
<
/div
>
<
/div
>
<!--
Model
Filter
-->
<
div
class
=
"
w-full sm:w-auto sm:min-w-[220px]
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
usage.model
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
filters.model
"
:
options
=
"
modelOptions
"
searchable
@
change
=
"
emitChange
"
/>
<
/div
>
<!--
Account
Filter
-->
<
div
class
=
"
w-full sm:w-auto sm:min-w-[220px]
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.usage.account
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
filters.account_id
"
:
options
=
"
accountOptions
"
searchable
@
change
=
"
emitChange
"
/>
<
/div
>
<!--
Stream
Type
Filter
-->
<
div
class
=
"
w-full sm:w-auto sm:min-w-[180px]
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
usage.type
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
filters.stream
"
:
options
=
"
streamTypeOptions
"
@
change
=
"
emitChange
"
/>
<
/div
>
<!--
Billing
Type
Filter
-->
<
div
class
=
"
w-full sm:w-auto sm:min-w-[180px]
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
usage.billingType
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
filters.billing_type
"
:
options
=
"
billingTypeOptions
"
@
change
=
"
emitChange
"
/>
<
/div
>
<!--
Group
Filter
-->
<
div
class
=
"
w-full sm:w-auto sm:min-w-[200px]
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.usage.group
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
filters.group_id
"
:
options
=
"
groupOptions
"
searchable
@
change
=
"
emitChange
"
/>
<
/div
>
<!--
Date
Range
Filter
-->
<
div
class
=
"
w-full sm:w-auto [&_.date-picker-trigger]:w-full
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
usage.timeRange
'
)
}}
<
/label
>
<
DateRangePicker
:
start
-
date
=
"
startDate
"
:
end
-
date
=
"
endDate
"
@
update
:
startDate
=
"
updateStartDate
"
@
update
:
endDate
=
"
updateEndDate
"
@
change
=
"
emitChange
"
/>
<
/div
>
<
/div
>
<!--
Right
:
actions
-->
<
div
class
=
"
flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto
"
>
<
button
type
=
"
button
"
@
click
=
"
$emit('reset')
"
class
=
"
btn btn-secondary
"
>
{{
t
(
'
common.reset
'
)
}}
<
/button
>
<
button
type
=
"
button
"
@
click
=
"
$emit('export')
"
:
disabled
=
"
exporting
"
class
=
"
btn btn-primary
"
>
{{
t
(
'
usage.exportExcel
'
)
}}
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
onMounted
,
onUnmounted
,
toRef
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
Select
,
{
type
SelectOption
}
from
'
@/components/common/Select.vue
'
import
DateRangePicker
from
'
@/components/common/DateRangePicker.vue
'
import
type
{
SimpleApiKey
,
SimpleUser
}
from
'
@/api/admin/usage
'
type
ModelValue
=
Record
<
string
,
any
>
interface
Props
{
modelValue
:
ModelValue
exporting
:
boolean
startDate
:
string
endDate
:
string
}
const
props
=
defineProps
<
Props
>
()
const
emit
=
defineEmits
([
'
update:modelValue
'
,
'
update:startDate
'
,
'
update:endDate
'
,
'
change
'
,
'
reset
'
,
'
export
'
])
const
{
t
}
=
useI18n
()
const
filters
=
toRef
(
props
,
'
modelValue
'
)
const
userSearchRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
apiKeySearchRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
userKeyword
=
ref
(
''
)
const
userResults
=
ref
<
SimpleUser
[]
>
([])
const
showUserDropdown
=
ref
(
false
)
let
userSearchTimeout
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
const
apiKeyKeyword
=
ref
(
''
)
const
apiKeyResults
=
ref
<
SimpleApiKey
[]
>
([])
const
showApiKeyDropdown
=
ref
(
false
)
let
apiKeySearchTimeout
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
const
modelOptions
=
ref
<
SelectOption
[]
>
([{
value
:
null
,
label
:
t
(
'
admin.usage.allModels
'
)
}
])
const
groupOptions
=
ref
<
SelectOption
[]
>
([{
value
:
null
,
label
:
t
(
'
admin.usage.allGroups
'
)
}
])
const
accountOptions
=
ref
<
SelectOption
[]
>
([{
value
:
null
,
label
:
t
(
'
admin.usage.allAccounts
'
)
}
])
const
streamTypeOptions
=
ref
<
SelectOption
[]
>
([
{
value
:
null
,
label
:
t
(
'
admin.usage.allTypes
'
)
}
,
{
value
:
true
,
label
:
t
(
'
usage.stream
'
)
}
,
{
value
:
false
,
label
:
t
(
'
usage.sync
'
)
}
])
const
billingTypeOptions
=
ref
<
SelectOption
[]
>
([
{
value
:
null
,
label
:
t
(
'
admin.usage.allBillingTypes
'
)
}
,
{
value
:
1
,
label
:
t
(
'
usage.subscription
'
)
}
,
{
value
:
0
,
label
:
t
(
'
usage.balance
'
)
}
])
const
emitChange
=
()
=>
emit
(
'
change
'
)
const
updateStartDate
=
(
value
:
string
)
=>
{
emit
(
'
update:startDate
'
,
value
)
filters
.
value
.
start_date
=
value
}
const
updateEndDate
=
(
value
:
string
)
=>
{
emit
(
'
update:endDate
'
,
value
)
filters
.
value
.
end_date
=
value
}
const
debounceUserSearch
=
()
=>
{
if
(
userSearchTimeout
)
clearTimeout
(
userSearchTimeout
)
userSearchTimeout
=
setTimeout
(
async
()
=>
{
if
(
!
userKeyword
.
value
)
{
userResults
.
value
=
[]
return
}
try
{
userResults
.
value
=
await
adminAPI
.
usage
.
searchUsers
(
userKeyword
.
value
)
}
catch
{
userResults
.
value
=
[]
}
}
,
300
)
}
const
debounceApiKeySearch
=
()
=>
{
if
(
apiKeySearchTimeout
)
clearTimeout
(
apiKeySearchTimeout
)
apiKeySearchTimeout
=
setTimeout
(
async
()
=>
{
if
(
!
apiKeyKeyword
.
value
)
{
apiKeyResults
.
value
=
[]
return
}
try
{
apiKeyResults
.
value
=
await
adminAPI
.
usage
.
searchApiKeys
(
filters
.
value
.
user_id
,
apiKeyKeyword
.
value
)
}
catch
{
apiKeyResults
.
value
=
[]
}
}
,
300
)
}
const
selectUser
=
(
u
:
SimpleUser
)
=>
{
userKeyword
.
value
=
u
.
email
showUserDropdown
.
value
=
false
filters
.
value
.
user_id
=
u
.
id
clearApiKey
()
emitChange
()
}
const
clearUser
=
()
=>
{
userKeyword
.
value
=
''
userResults
.
value
=
[]
showUserDropdown
.
value
=
false
filters
.
value
.
user_id
=
undefined
clearApiKey
()
emitChange
()
}
const
selectApiKey
=
(
k
:
SimpleApiKey
)
=>
{
apiKeyKeyword
.
value
=
k
.
name
||
String
(
k
.
id
)
showApiKeyDropdown
.
value
=
false
filters
.
value
.
api_key_id
=
k
.
id
emitChange
()
}
const
clearApiKey
=
()
=>
{
apiKeyKeyword
.
value
=
''
apiKeyResults
.
value
=
[]
showApiKeyDropdown
.
value
=
false
filters
.
value
.
api_key_id
=
undefined
}
const
onClearApiKey
=
()
=>
{
clearApiKey
()
emitChange
()
}
const
onDocumentClick
=
(
e
:
MouseEvent
)
=>
{
const
target
=
e
.
target
as
Node
|
null
if
(
!
target
)
return
const
clickedInsideUser
=
userSearchRef
.
value
?.
contains
(
target
)
??
false
const
clickedInsideApiKey
=
apiKeySearchRef
.
value
?.
contains
(
target
)
??
false
if
(
!
clickedInsideUser
)
showUserDropdown
.
value
=
false
if
(
!
clickedInsideApiKey
)
showApiKeyDropdown
.
value
=
false
}
watch
(
()
=>
props
.
startDate
,
(
value
)
=>
{
filters
.
value
.
start_date
=
value
}
,
{
immediate
:
true
}
)
watch
(
()
=>
props
.
endDate
,
(
value
)
=>
{
filters
.
value
.
end_date
=
value
}
,
{
immediate
:
true
}
)
watch
(
()
=>
filters
.
value
.
user_id
,
(
userId
)
=>
{
if
(
!
userId
)
{
userKeyword
.
value
=
''
userResults
.
value
=
[]
}
}
)
watch
(
()
=>
filters
.
value
.
api_key_id
,
(
apiKeyId
)
=>
{
if
(
!
apiKeyId
)
{
apiKeyKeyword
.
value
=
''
apiKeyResults
.
value
=
[]
}
}
)
onMounted
(
async
()
=>
{
document
.
addEventListener
(
'
click
'
,
onDocumentClick
)
try
{
const
[
gs
,
ms
,
as
]
=
await
Promise
.
all
([
adminAPI
.
groups
.
list
(
1
,
1000
),
adminAPI
.
dashboard
.
getModelStats
({
start_date
:
props
.
startDate
,
end_date
:
props
.
endDate
}
),
adminAPI
.
accounts
.
list
(
1
,
1000
)
])
groupOptions
.
value
.
push
(...
gs
.
items
.
map
((
g
:
any
)
=>
({
value
:
g
.
id
,
label
:
g
.
name
}
)))
accountOptions
.
value
.
push
(...
as
.
items
.
map
((
a
:
any
)
=>
({
value
:
a
.
id
,
label
:
a
.
name
}
)))
const
uniqueModels
=
new
Set
<
string
>
()
ms
.
models
?.
forEach
((
s
:
any
)
=>
s
.
model
&&
uniqueModels
.
add
(
s
.
model
))
modelOptions
.
value
.
push
(
...
Array
.
from
(
uniqueModels
)
.
sort
()
.
map
((
m
)
=>
({
value
:
m
,
label
:
m
}
))
)
}
catch
{
// Ignore filter option loading errors (page still usable)
}
}
)
onUnmounted
(()
=>
{
document
.
removeEventListener
(
'
click
'
,
onDocumentClick
)
}
)
<
/script
>
frontend/src/components/admin/usage/UsageStatsCards.vue
0 → 100644
View file @
fd29fe11
<
template
>
<div
class=
"grid grid-cols-2 gap-4 lg:grid-cols-4"
>
<div
class=
"card p-4 flex items-center gap-3"
>
<div
class=
"rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30 text-blue-600"
><svg
class=
"h-5 w-5"
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"
>
{{
t
(
'
usage.totalRequests
'
)
}}
</p><p
class=
"text-xl font-bold"
>
{{
stats
?.
total_requests
?.
toLocaleString
()
||
'
0
'
}}
</p></div>
</div>
<div
class=
"card p-4 flex items-center gap-3"
>
<div
class=
"rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30 text-amber-600"
><svg
class=
"h-5 w-5"
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"
>
{{
t
(
'
usage.totalTokens
'
)
}}
</p><p
class=
"text-xl font-bold"
>
{{
formatTokens
(
stats
?.
total_tokens
||
0
)
}}
</p></div>
</div>
<div
class=
"card p-4 flex items-center gap-3"
>
<div
class=
"rounded-lg bg-green-100 p-2 dark:bg-green-900/30 text-green-600"
><svg
class=
"h-5 w-5"
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><p
class=
"text-xs font-medium text-gray-500"
>
{{
t
(
'
usage.totalCost
'
)
}}
</p><p
class=
"text-xl font-bold text-green-600"
>
$
{{
(
stats
?.
total_actual_cost
||
0
).
toFixed
(
4
)
}}
</p></div>
</div>
<div
class=
"card p-4 flex items-center gap-3"
>
<div
class=
"rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30 text-purple-600"
><svg
class=
"h-5 w-5"
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"
>
{{
t
(
'
usage.avgDuration
'
)
}}
</p><p
class=
"text-xl font-bold"
>
{{
formatDuration
(
stats
?.
average_duration_ms
||
0
)
}}
</p></div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
useI18n
}
from
'
vue-i18n
'
;
import
type
{
AdminUsageStatsResponse
}
from
'
@/api/admin/usage
'
defineProps
<
{
stats
:
AdminUsageStatsResponse
|
null
}
>
();
const
{
t
}
=
useI18n
()
const
formatDuration
=
(
ms
:
number
)
=>
ms
<
1000
?
`
${
ms
.
toFixed
(
0
)}
ms`
:
`
${(
ms
/
1000
).
toFixed
(
2
)}
s`
const
formatTokens
=
(
v
:
number
)
=>
{
if
(
v
>=
1
e9
)
return
(
v
/
1
e9
).
toFixed
(
2
)
+
'
B
'
;
if
(
v
>=
1
e6
)
return
(
v
/
1
e6
).
toFixed
(
2
)
+
'
M
'
;
if
(
v
>=
1
e3
)
return
(
v
/
1
e3
).
toFixed
(
2
)
+
'
K
'
;
return
v
.
toLocaleString
()
}
</
script
>
\ No newline at end of file
frontend/src/components/admin/usage/UsageTable.vue
0 → 100644
View file @
fd29fe11
<
template
>
<div
class=
"card overflow-hidden"
><div
class=
"overflow-auto"
>
<DataTable
:columns=
"cols"
:data=
"data"
: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-xs text-gray-400"
>
#
{{
row
.
user_id
}}
</span></div></
template
>
<
template
#cell-model=
"{ value }"
><span
class=
"font-medium"
>
{{
value
}}
</span></
template
>
<
template
#cell-tokens=
"{ row }"
><div
class=
"text-sm"
>
In:
{{
row
.
input_tokens
.
toLocaleString
()
}}
/ Out:
{{
row
.
output_tokens
.
toLocaleString
()
}}
</div></
template
>
<
template
#cell-cost=
"{ row }"
><span
class=
"font-medium text-green-600"
>
$
{{
row
.
actual_cost
.
toFixed
(
6
)
}}
</span></
template
>
<
template
#cell-created_at=
"{ value }"
><span
class=
"text-sm text-gray-500"
>
{{
formatDateTime
(
value
)
}}
</span></
template
>
<
template
#empty
><EmptyState
:message=
"t('usage.noRecords')"
/></
template
>
</DataTable>
</div></div>
</template>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
;
import
{
useI18n
}
from
'
vue-i18n
'
;
import
{
formatDateTime
}
from
'
@/utils/format
'
;
import
DataTable
from
'
@/components/common/DataTable.vue
'
;
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
defineProps
([
'
data
'
,
'
loading
'
]);
const
{
t
}
=
useI18n
()
const
cols
=
computed
(()
=>
[
{
key
:
'
user
'
,
label
:
t
(
'
admin.usage.user
'
)
},
{
key
:
'
model
'
,
label
:
t
(
'
usage.model
'
),
sortable
:
true
},
{
key
:
'
tokens
'
,
label
:
t
(
'
usage.tokens
'
)
},
{
key
:
'
cost
'
,
label
:
t
(
'
usage.cost
'
)
},
{
key
:
'
created_at
'
,
label
:
t
(
'
usage.time
'
),
sortable
:
true
}
])
</
script
>
\ No newline at end of file
frontend/src/components/admin/user/UserAllowedGroupsModal.vue
0 → 100644
View file @
fd29fe11
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.users.setAllowedGroups')"
width=
"normal"
@
close=
"$emit('close')"
>
<div
v-if=
"user"
class=
"space-y-4"
>
<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"
>
<span
class=
"text-lg font-medium text-primary-700"
>
{{
user
.
email
.
charAt
(
0
).
toUpperCase
()
}}
</span>
</div>
<p
class=
"font-medium text-gray-900 dark:text-white"
>
{{
user
.
email
}}
</p>
</div>
<div
v-if=
"loading"
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
>
<p
class=
"mb-3 text-sm text-gray-600"
>
{{
t
(
'
admin.users.allowedGroupsHint
'
)
}}
</p>
<div
class=
"max-h-64 space-y-2 overflow-y-auto"
>
<label
v-for=
"group in groups"
:key=
"group.id"
class=
"flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 hover:bg-gray-50"
:class=
"
{'border-primary-300 bg-primary-50': selectedIds.includes(group.id)}">
<input
type=
"checkbox"
:value=
"group.id"
v-model=
"selectedIds"
class=
"h-4 w-4 rounded border-gray-300 text-primary-600"
/>
<div
class=
"flex-1"
><p
class=
"font-medium text-gray-900"
>
{{
group
.
name
}}
</p><p
v-if=
"group.description"
class=
"truncate text-sm text-gray-500"
>
{{
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>
<div
class=
"mt-4 border-t border-gray-200 pt-4"
>
<label
class=
"flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 hover:bg-gray-50"
:class=
"
{'border-green-300 bg-green-50': selectedIds.length === 0}">
<input
type=
"radio"
:checked=
"selectedIds.length === 0"
@
change=
"selectedIds = []"
class=
"h-4 w-4 border-gray-300 text-green-600"
/>
<div
class=
"flex-1"
><p
class=
"font-medium text-gray-900"
>
{{
t
(
'
admin.users.allowAllGroups
'
)
}}
</p><p
class=
"text-sm text-gray-500"
>
{{
t
(
'
admin.users.allowAllGroupsHint
'
)
}}
</p></div>
</label>
</div>
</div>
</div>
<template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"$emit('close')"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
@
click=
"handleSave"
:disabled=
"submitting"
class=
"btn btn-primary"
>
{{
submitting
?
t
(
'
common.saving
'
)
:
t
(
'
common.save
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
User
,
Group
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
User
|
null
}
>
()
const
emit
=
defineEmits
([
'
close
'
,
'
success
'
]);
const
{
t
}
=
useI18n
();
const
appStore
=
useAppStore
()
const
groups
=
ref
<
Group
[]
>
([]);
const
selectedIds
=
ref
<
number
[]
>
([]);
const
loading
=
ref
(
false
);
const
submitting
=
ref
(
false
)
watch
(()
=>
props
.
show
,
(
v
)
=>
{
if
(
v
&&
props
.
user
)
{
selectedIds
.
value
=
props
.
user
.
allowed_groups
||
[];
load
()
}
})
const
load
=
async
()
=>
{
loading
.
value
=
true
;
try
{
const
res
=
await
adminAPI
.
groups
.
list
(
1
,
1000
);
groups
.
value
=
res
.
items
.
filter
(
g
=>
g
.
subscription_type
===
'
standard
'
&&
g
.
status
===
'
active
'
)
}
catch
{}
finally
{
loading
.
value
=
false
}
}
const
handleSave
=
async
()
=>
{
if
(
!
props
.
user
)
return
;
submitting
.
value
=
true
try
{
await
adminAPI
.
users
.
update
(
props
.
user
.
id
,
{
allowed_groups
:
selectedIds
.
value
.
length
>
0
?
selectedIds
.
value
:
null
})
appStore
.
showSuccess
(
t
(
'
admin.users.allowedGroupsUpdated
'
));
emit
(
'
success
'
);
emit
(
'
close
'
)
}
catch
{}
finally
{
submitting
.
value
=
false
}
}
</
script
>
\ No newline at end of file
frontend/src/components/admin/user/UserApiKeysModal.vue
0 → 100644
View file @
fd29fe11
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.users.userApiKeys')"
width=
"wide"
@
close=
"$emit('close')"
>
<div
v-if=
"user"
class=
"space-y-4"
>
<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"
>
{{
user
.
email
.
charAt
(
0
).
toUpperCase
()
}}
</span>
</div>
<div><p
class=
"font-medium text-gray-900 dark:text-white"
>
{{
user
.
email
}}
</p><p
class=
"text-sm text-gray-500 dark:text-dark-400"
>
{{
user
.
username
}}
</p></div>
</div>
<div
v-if=
"loading"
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=
"apiKeys.length === 0"
class=
"py-8 text-center"
><p
class=
"text-sm text-gray-500"
>
{{
t
(
'
admin.users.noApiKeys
'
)
}}
</p></div>
<div
v-else
class=
"max-h-96 space-y-3 overflow-y-auto"
>
<div
v-for=
"key in apiKeys"
: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"
>
{{
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"
>
<div
class=
"flex items-center gap-1"
><span>
{{
t
(
'
admin.users.group
'
)
}}
:
{{
key
.
group
?.
name
||
t
(
'
admin.users.none
'
)
}}
</span></div>
<div
class=
"flex items-center gap-1"
><span>
{{
t
(
'
admin.users.columns.created
'
)
}}
:
{{
formatDateTime
(
key
.
created_at
)
}}
</span></div>
</div>
</div>
</div>
</div>
</BaseDialog>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
formatDateTime
}
from
'
@/utils/format
'
import
type
{
User
,
ApiKey
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
User
|
null
}
>
()
defineEmits
([
'
close
'
]);
const
{
t
}
=
useI18n
()
const
apiKeys
=
ref
<
ApiKey
[]
>
([]);
const
loading
=
ref
(
false
)
watch
(()
=>
props
.
show
,
(
v
)
=>
{
if
(
v
&&
props
.
user
)
load
()
})
const
load
=
async
()
=>
{
if
(
!
props
.
user
)
return
;
loading
.
value
=
true
try
{
const
res
=
await
adminAPI
.
users
.
getUserApiKeys
(
props
.
user
.
id
);
apiKeys
.
value
=
res
.
items
||
[]
}
catch
{}
finally
{
loading
.
value
=
false
}
}
</
script
>
\ No newline at end of file
frontend/src/components/admin/user/UserBalanceModal.vue
0 → 100644
View file @
fd29fe11
<
template
>
<BaseDialog
:show=
"show"
:title=
"operation === 'add' ? t('admin.users.deposit') : t('admin.users.withdraw')"
width=
"narrow"
@
close=
"$emit('close')"
>
<form
v-if=
"user"
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"
><span
class=
"text-lg font-medium text-primary-700"
>
{{
user
.
email
.
charAt
(
0
).
toUpperCase
()
}}
</span></div>
<div
class=
"flex-1"
><p
class=
"font-medium text-gray-900"
>
{{
user
.
email
}}
</p><p
class=
"text-sm text-gray-500"
>
{{
t
(
'
admin.users.currentBalance
'
)
}}
: $
{{
user
.
balance
.
toFixed
(
2
)
}}
</p></div>
</div>
<div>
<label
class=
"input-label"
>
{{
operation
===
'
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"
>
$
</div><input
v-model.number=
"form.amount"
type=
"number"
step=
"0.01"
min=
"0.01"
required
class=
"input pl-8"
/></div>
</div>
<div><label
class=
"input-label"
>
{{
t
(
'
admin.users.notes
'
)
}}
</label><textarea
v-model=
"form.notes"
rows=
"3"
class=
"input"
></textarea></div>
<div
v-if=
"form.amount > 0"
class=
"rounded-xl border border-blue-200 bg-blue-50 p-4"
><div
class=
"flex items-center justify-between text-sm"
><span>
{{
t
(
'
admin.users.newBalance
'
)
}}
:
</span><span
class=
"font-bold"
>
$
{{
calculateNewBalance
().
toFixed
(
2
)
}}
</span></div></div>
</form>
<template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"$emit('close')"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"submit"
form=
"balance-form"
:disabled=
"submitting || !form.amount"
class=
"btn"
:class=
"operation === 'add' ? 'bg-emerald-600 text-white' : 'btn-danger'"
>
{{
submitting
?
t
(
'
common.saving
'
)
:
t
(
'
common.confirm
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
reactive
,
ref
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
User
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
User
|
null
,
operation
:
'
add
'
|
'
subtract
'
}
>
()
const
emit
=
defineEmits
([
'
close
'
,
'
success
'
]);
const
{
t
}
=
useI18n
();
const
appStore
=
useAppStore
()
const
submitting
=
ref
(
false
);
const
form
=
reactive
({
amount
:
0
,
notes
:
''
})
watch
(()
=>
props
.
show
,
(
v
)
=>
{
if
(
v
)
{
form
.
amount
=
0
;
form
.
notes
=
''
}
})
const
calculateNewBalance
=
()
=>
(
props
.
user
?
(
props
.
operation
===
'
add
'
?
props
.
user
.
balance
+
form
.
amount
:
props
.
user
.
balance
-
form
.
amount
)
:
0
)
const
handleBalanceSubmit
=
async
()
=>
{
if
(
!
props
.
user
)
return
;
submitting
.
value
=
true
try
{
await
adminAPI
.
users
.
updateBalance
(
props
.
user
.
id
,
form
.
amount
,
props
.
operation
,
form
.
notes
)
appStore
.
showSuccess
(
t
(
'
common.success
'
));
emit
(
'
success
'
);
emit
(
'
close
'
)
}
catch
{}
finally
{
submitting
.
value
=
false
}
}
</
script
>
\ No newline at end of file
frontend/src/components/admin/user/UserCreateModal.vue
0 → 100644
View file @
fd29fe11
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.users.createUser')"
width=
"normal"
@
close=
"$emit('close')"
>
<form
id=
"create-user-form"
@
submit.prevent=
"submit"
class=
"space-y-5"
>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.email
'
)
}}
</label>
<input
v-model=
"form.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=
"form.password"
type=
"text"
required
class=
"input pr-10"
:placeholder=
"t('admin.users.enterPassword')"
/>
</div>
<button
type=
"button"
@
click=
"generateRandomPassword"
class=
"btn btn-secondary px-3"
>
<svg
class=
"h-5 w-5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"1.5"
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=
"form.username"
type=
"text"
class=
"input"
:placeholder=
"t('admin.users.enterUsername')"
/>
</div>
<div
class=
"grid grid-cols-2 gap-4"
>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.columns.balance
'
)
}}
</label>
<input
v-model.number=
"form.balance"
type=
"number"
step=
"any"
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.columns.concurrency
'
)
}}
</label>
<input
v-model.number=
"form.concurrency"
type=
"number"
class=
"input"
/>
</div>
</div>
</form>
<template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"$emit('close')"
type=
"button"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"submit"
form=
"create-user-form"
:disabled=
"loading"
class=
"btn btn-primary"
>
{{
loading
?
t
(
'
admin.users.creating
'
)
:
t
(
'
common.create
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
reactive
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
;
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
useForm
}
from
'
@/composables/useForm
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
const
props
=
defineProps
<
{
show
:
boolean
}
>
()
const
emit
=
defineEmits
([
'
close
'
,
'
success
'
]);
const
{
t
}
=
useI18n
()
const
form
=
reactive
({
email
:
''
,
password
:
''
,
username
:
''
,
notes
:
''
,
balance
:
0
,
concurrency
:
1
})
const
{
loading
,
submit
}
=
useForm
({
form
,
submitFn
:
async
(
data
)
=>
{
await
adminAPI
.
users
.
create
(
data
)
emit
(
'
success
'
);
emit
(
'
close
'
)
},
successMsg
:
t
(
'
admin.users.userCreated
'
)
})
watch
(()
=>
props
.
show
,
(
v
)
=>
{
if
(
v
)
Object
.
assign
(
form
,
{
email
:
''
,
password
:
''
,
username
:
''
,
notes
:
''
,
balance
:
0
,
concurrency
:
1
})
})
const
generateRandomPassword
=
()
=>
{
const
chars
=
'
ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*
'
let
p
=
''
;
for
(
let
i
=
0
;
i
<
16
;
i
++
)
p
+=
chars
.
charAt
(
Math
.
floor
(
Math
.
random
()
*
chars
.
length
))
form
.
password
=
p
}
</
script
>
frontend/src/components/admin/user/UserEditModal.vue
0 → 100644
View file @
fd29fe11
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.users.editUser')"
width=
"normal"
@
close=
"$emit('close')"
>
<form
v-if=
"user"
id=
"edit-user-form"
@
submit.prevent=
"handleUpdateUser"
class=
"space-y-5"
>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.email
'
)
}}
</label>
<input
v-model=
"form.email"
type=
"email"
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.password
'
)
}}
</label>
<div
class=
"flex gap-2"
>
<div
class=
"relative flex-1"
>
<input
v-model=
"form.password"
type=
"text"
class=
"input pr-10"
:placeholder=
"t('admin.users.enterNewPassword')"
/>
<button
v-if=
"form.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'"
>
<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>
<button
type=
"button"
@
click=
"generatePassword"
class=
"btn btn-secondary px-3"
>
<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=
"form.username"
type=
"text"
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.notes
'
)
}}
</label>
<textarea
v-model=
"form.notes"
rows=
"3"
class=
"input"
></textarea>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.columns.concurrency
'
)
}}
</label>
<input
v-model.number=
"form.concurrency"
type=
"number"
class=
"input"
/>
</div>
<UserAttributeForm
v-model=
"form.customAttributes"
:user-id=
"user?.id"
/>
</form>
<template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"$emit('close')"
type=
"button"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"submit"
form=
"edit-user-form"
:disabled=
"submitting"
class=
"btn btn-primary"
>
{{
submitting
?
t
(
'
admin.users.updating
'
)
:
t
(
'
common.update
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
reactive
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
User
,
UserAttributeValuesMap
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
UserAttributeForm
from
'
@/components/user/UserAttributeForm.vue
'
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
User
|
null
}
>
()
const
emit
=
defineEmits
([
'
close
'
,
'
success
'
])
const
{
t
}
=
useI18n
();
const
appStore
=
useAppStore
();
const
{
copyToClipboard
}
=
useClipboard
()
const
submitting
=
ref
(
false
);
const
passwordCopied
=
ref
(
false
)
const
form
=
reactive
({
email
:
''
,
password
:
''
,
username
:
''
,
notes
:
''
,
concurrency
:
1
,
customAttributes
:
{}
as
UserAttributeValuesMap
})
watch
(()
=>
props
.
user
,
(
u
)
=>
{
if
(
u
)
{
Object
.
assign
(
form
,
{
email
:
u
.
email
,
password
:
''
,
username
:
u
.
username
||
''
,
notes
:
u
.
notes
||
''
,
concurrency
:
u
.
concurrency
,
customAttributes
:
{}
})
passwordCopied
.
value
=
false
}
},
{
immediate
:
true
})
const
generatePassword
=
()
=>
{
const
chars
=
'
ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*
'
let
p
=
''
;
for
(
let
i
=
0
;
i
<
16
;
i
++
)
p
+=
chars
.
charAt
(
Math
.
floor
(
Math
.
random
()
*
chars
.
length
))
form
.
password
=
p
}
const
copyPassword
=
async
()
=>
{
if
(
form
.
password
&&
await
copyToClipboard
(
form
.
password
,
t
(
'
admin.users.passwordCopied
'
)))
{
passwordCopied
.
value
=
true
;
setTimeout
(()
=>
passwordCopied
.
value
=
false
,
2000
)
}
}
const
handleUpdateUser
=
async
()
=>
{
if
(
!
props
.
user
)
return
submitting
.
value
=
true
try
{
const
data
:
any
=
{
email
:
form
.
email
,
username
:
form
.
username
,
notes
:
form
.
notes
,
concurrency
:
form
.
concurrency
}
if
(
form
.
password
.
trim
())
data
.
password
=
form
.
password
.
trim
()
await
adminAPI
.
users
.
update
(
props
.
user
.
id
,
data
)
if
(
Object
.
keys
(
form
.
customAttributes
).
length
>
0
)
await
adminAPI
.
userAttributes
.
updateUserAttributeValues
(
props
.
user
.
id
,
form
.
customAttributes
)
appStore
.
showSuccess
(
t
(
'
admin.users.userUpdated
'
))
emit
(
'
success
'
);
emit
(
'
close
'
)
}
catch
(
e
:
any
)
{
appStore
.
showError
(
e
.
response
?.
data
?.
detail
||
t
(
'
admin.users.failedToUpdate
'
))
}
finally
{
submitting
.
value
=
false
}
}
</
script
>
\ No newline at end of file
frontend/src/components/common/GroupSelector.vue
View file @
fd29fe11
<
template
>
<div>
<label
class=
"input-label"
>
Groups
<span
class=
"font-normal text-gray-400"
>
(
{{
modelValue
.
length
}
}
selected)
</span>
{{
t
(
'
admin.users.groups
'
)
}}
<span
class=
"font-normal text-gray-400"
>
{{
t
(
'
common.selectedCount
'
,
{
count
:
modelValue
.
length
}
)
}}
<
/span
>
<
/label
>
<
div
class
=
"
grid max-h-32 grid-cols-2 gap-1 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-2 dark:border-dark-600 dark:bg-dark-800
"
...
...
@@ -32,7 +32,7 @@
v
-
if
=
"
filteredGroups.length === 0
"
class
=
"
col-span-2 py-2 text-center text-sm text-gray-500 dark:text-gray-400
"
>
No g
roups
a
vailable
{{
t
(
'
common.noG
roups
A
vailable
'
)
}}
<
/div
>
<
/div
>
<
/div
>
...
...
frontend/src/components/common/Input.vue
0 → 100644
View file @
fd29fe11
<
template
>
<div
class=
"w-full"
>
<label
v-if=
"label"
:for=
"id"
class=
"input-label mb-1.5 block"
>
{{
label
}}
<span
v-if=
"required"
class=
"text-red-500"
>
*
</span>
</label>
<div
class=
"relative"
>
<!-- Prefix Icon Slot -->
<div
v-if=
"$slots.prefix"
class=
"pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5 text-gray-400 dark:text-dark-400"
>
<slot
name=
"prefix"
></slot>
</div>
<input
:id=
"id"
ref=
"inputRef"
:type=
"type"
:value=
"modelValue"
:disabled=
"disabled"
:required=
"required"
:placeholder=
"placeholderText"
:autocomplete=
"autocomplete"
:readonly=
"readonly"
:class=
"[
'input w-full transition-all duration-200',
$slots.prefix ? 'pl-11' : '',
$slots.suffix ? 'pr-11' : '',
error ? 'input-error ring-2 ring-red-500/20' : '',
disabled ? 'cursor-not-allowed bg-gray-100 opacity-60 dark:bg-dark-900' : ''
]"
@
input=
"onInput"
@
change=
"$emit('change', ($event.target as HTMLInputElement).value)"
@
blur=
"$emit('blur', $event)"
@
focus=
"$emit('focus', $event)"
@
keyup.enter=
"$emit('enter', $event)"
/>
<!-- Suffix Slot (e.g. Password Toggle or Clear Button) -->
<div
v-if=
"$slots.suffix"
class=
"absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 dark:text-dark-400"
>
<slot
name=
"suffix"
></slot>
</div>
</div>
<!-- Hint / Error Text -->
<p
v-if=
"error"
class=
"input-error-text mt-1.5"
>
{{
error
}}
</p>
<p
v-else-if=
"hint"
class=
"input-hint mt-1.5"
>
{{
hint
}}
</p>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
,
ref
}
from
'
vue
'
interface
Props
{
modelValue
:
string
|
number
|
null
|
undefined
type
?:
string
label
?:
string
placeholder
?:
string
disabled
?:
boolean
required
?:
boolean
readonly
?:
boolean
error
?:
string
hint
?:
string
id
?:
string
autocomplete
?:
string
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
type
:
'
text
'
,
disabled
:
false
,
required
:
false
,
readonly
:
false
})
const
emit
=
defineEmits
<
{
(
e
:
'
update:modelValue
'
,
value
:
string
):
void
(
e
:
'
change
'
,
value
:
string
):
void
(
e
:
'
blur
'
,
event
:
FocusEvent
):
void
(
e
:
'
focus
'
,
event
:
FocusEvent
):
void
(
e
:
'
enter
'
,
event
:
KeyboardEvent
):
void
}
>
()
const
inputRef
=
ref
<
HTMLInputElement
|
null
>
(
null
)
const
placeholderText
=
computed
(()
=>
props
.
placeholder
||
''
)
const
onInput
=
(
event
:
Event
)
=>
{
const
value
=
(
event
.
target
as
HTMLInputElement
).
value
emit
(
'
update:modelValue
'
,
value
)
}
// Expose focus method
defineExpose
({
focus
:
()
=>
inputRef
.
value
?.
focus
(),
select
:
()
=>
inputRef
.
value
?.
select
()
})
</
script
>
frontend/src/components/common/SearchInput.vue
0 → 100644
View file @
fd29fe11
<
template
>
<div
class=
"relative w-full"
>
<div
class=
"pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"
>
<svg
class=
"h-5 w-5 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>
</div>
<input
:value=
"modelValue"
type=
"text"
class=
"input pl-10"
:placeholder=
"placeholder"
@
input=
"handleInput"
/>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
useDebounceFn
}
from
'
@vueuse/core
'
const
props
=
withDefaults
(
defineProps
<
{
modelValue
:
string
placeholder
?:
string
debounceMs
?:
number
}
>
(),
{
placeholder
:
'
Search...
'
,
debounceMs
:
300
})
const
emit
=
defineEmits
<
{
(
e
:
'
update:modelValue
'
,
value
:
string
):
void
(
e
:
'
search
'
,
value
:
string
):
void
}
>
()
const
debouncedEmitSearch
=
useDebounceFn
((
value
:
string
)
=>
{
emit
(
'
search
'
,
value
)
},
props
.
debounceMs
)
const
handleInput
=
(
event
:
Event
)
=>
{
const
value
=
(
event
.
target
as
HTMLInputElement
).
value
emit
(
'
update:modelValue
'
,
value
)
debouncedEmitSearch
(
value
)
}
</
script
>
frontend/src/components/common/Select.vue
View file @
fd29fe11
<
template
>
<div
class=
"relative"
ref=
"containerRef"
>
<button
ref=
"triggerRef"
type=
"button"
@
click=
"toggle"
:disabled=
"disabled"
:aria-expanded=
"isOpen"
:aria-haspopup=
"true"
aria-label=
"Select option"
:class=
"[
'select-trigger',
isOpen && 'select-trigger-open',
error && 'select-trigger-error',
disabled && 'select-trigger-disabled'
]"
@
keydown.down.prevent=
"onTriggerKeyDown"
@
keydown.up.prevent=
"onTriggerKeyDown"
>
<span
class=
"select-value"
>
<slot
name=
"selected"
:option=
"selectedOption"
>
...
...
@@ -29,16 +35,19 @@
</span>
</button>
<!-- Teleport dropdown to body to escape stacking context
(for driver.js overlay compatibility)
-->
<!-- Teleport dropdown to body to escape stacking context -->
<Teleport
to=
"body"
>
<Transition
name=
"select-dropdown"
>
<div
v-if=
"isOpen"
ref=
"dropdownRef"
class=
"select-dropdown-portal"
:class=
"[instanceId]"
:style=
"dropdownStyle"
role=
"listbox"
@
click.stop
@
mousedown.stop
@
keydown=
"onDropdownKeyDown"
>
<!-- Search input -->
<div
v-if=
"searchable"
class=
"select-search"
>
...
...
@@ -66,12 +75,21 @@
</div>
<!-- Options list -->
<div
class=
"select-options"
>
<div
class=
"select-options"
ref=
"optionsListRef"
>
<div
v-for=
"option in filteredOptions"
v-for=
"
(
option
, index)
in filteredOptions"
:key=
"`$
{typeof getOptionValue(option)}:${String(getOptionValue(option) ?? '')}`"
@click.stop="selectOption(option)"
:class="['select-option', isSelected(option)
&&
'select-option-selected']"
role="option"
:aria-selected="isSelected(option)"
:aria-disabled="isOptionDisabled(option)"
@click.stop="!isOptionDisabled(option)
&&
selectOption(option)"
@mouseenter="focusedIndex = index"
:class="[
'select-option',
isSelected(option)
&&
'select-option-selected',
isOptionDisabled(option)
&&
'select-option-disabled',
focusedIndex === index
&&
'select-option-focused'
]"
>
<slot
name=
"option"
:option=
"option"
:selected=
"isSelected(option)"
>
<span
class=
"select-option-label"
>
{{
getOptionLabel
(
option
)
}}
</span>
...
...
@@ -105,6 +123,9 @@ import { useI18n } from 'vue-i18n'
const
{
t
}
=
useI18n
()
// Instance ID for unique click-outside detection
const
instanceId
=
`select-
${
Math
.
random
().
toString
(
36
).
substring
(
2
,
9
)}
`
export
interface
SelectOption
{
value
:
string
|
number
|
boolean
|
null
label
:
string
...
...
@@ -138,23 +159,24 @@ const props = withDefaults(defineProps<Props>(), {
labelKey
:
'
label
'
})
// Use computed for i18n default values
const
placeholderText
=
computed
(()
=>
props
.
placeholder
??
t
(
'
common.selectOption
'
))
const
searchPlaceholderText
=
computed
(
()
=>
props
.
searchPlaceholder
??
t
(
'
common.searchPlaceholder
'
)
)
const
emptyTextDisplay
=
computed
(()
=>
props
.
emptyText
??
t
(
'
common.noOptionsFound
'
))
const
emit
=
defineEmits
<
Emits
>
()
const
isOpen
=
ref
(
false
)
const
searchQuery
=
ref
(
''
)
const
focusedIndex
=
ref
(
-
1
)
const
containerRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
triggerRef
=
ref
<
HTMLButtonElement
|
null
>
(
null
)
const
searchInputRef
=
ref
<
HTMLInputElement
|
null
>
(
null
)
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
optionsListRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
dropdownPosition
=
ref
<
'
bottom
'
|
'
top
'
>
(
'
bottom
'
)
const
triggerRect
=
ref
<
DOMRect
|
null
>
(
null
)
// i18n placeholders
const
placeholderText
=
computed
(()
=>
props
.
placeholder
??
t
(
'
common.selectOption
'
))
const
searchPlaceholderText
=
computed
(()
=>
props
.
searchPlaceholder
??
t
(
'
common.searchPlaceholder
'
))
const
emptyTextDisplay
=
computed
(()
=>
props
.
emptyText
??
t
(
'
common.noOptionsFound
'
))
// Computed style for teleported dropdown
const
dropdownStyle
=
computed
(()
=>
{
if
(
!
triggerRect
.
value
)
return
{}
...
...
@@ -164,34 +186,39 @@ const dropdownStyle = computed(() => {
position
:
'
fixed
'
,
left
:
`
${
rect
.
left
}
px`
,
minWidth
:
`
${
rect
.
width
}
px`
,
zIndex
:
'
100000020
'
// Higher than driver.js overlay (99999998)
zIndex
:
'
100000020
'
}
if
(
dropdownPosition
.
value
===
'
top
'
)
{
style
.
bottom
=
`
${
window
.
innerHeight
-
rect
.
top
+
8
}
px`
style
.
bottom
=
`
${
window
.
innerHeight
-
rect
.
top
+
4
}
px`
}
else
{
style
.
top
=
`
${
rect
.
bottom
+
8
}
px`
style
.
top
=
`
${
rect
.
bottom
+
4
}
px`
}
return
style
})
const
getOptionValue
=
(
option
:
SelectOption
|
Record
<
string
,
unknown
>
):
string
|
number
|
boolean
|
null
|
undefined
=>
{
const
getOptionValue
=
(
option
:
any
):
any
=>
{
if
(
typeof
option
===
'
object
'
&&
option
!==
null
)
{
return
option
[
props
.
valueKey
]
as
string
|
number
|
boolean
|
null
|
undefined
return
option
[
props
.
valueKey
]
}
return
option
as
string
|
number
|
boolean
|
null
return
option
}
const
getOptionLabel
=
(
option
:
SelectOption
|
Record
<
string
,
unknown
>
):
string
=>
{
const
getOptionLabel
=
(
option
:
any
):
string
=>
{
if
(
typeof
option
===
'
object
'
&&
option
!==
null
)
{
return
String
(
option
[
props
.
labelKey
]
??
''
)
}
return
String
(
option
??
''
)
}
const
isOptionDisabled
=
(
option
:
any
):
boolean
=>
{
if
(
typeof
option
===
'
object
'
&&
option
!==
null
)
{
return
!!
option
.
disabled
}
return
false
}
const
selectedOption
=
computed
(()
=>
{
return
props
.
options
.
find
((
opt
)
=>
getOptionValue
(
opt
)
===
props
.
modelValue
)
||
null
})
...
...
@@ -204,36 +231,35 @@ const selectedLabel = computed(() => {
})
const
filteredOptions
=
computed
(()
=>
{
if
(
!
props
.
searchable
||
!
searchQuery
.
value
)
{
return
props
.
options
}
let
opts
=
props
.
options
as
any
[]
if
(
props
.
searchable
&&
searchQuery
.
value
)
{
const
query
=
searchQuery
.
value
.
toLowerCase
()
return
props
.
options
.
filter
((
opt
)
=>
{
const
label
=
getOptionLabel
(
opt
).
toLowerCase
()
return
label
.
includes
(
query
)
})
opts
=
opts
.
filter
((
opt
)
=>
getOptionLabel
(
opt
).
toLowerCase
().
includes
(
query
))
}
return
opts
})
const
isSelected
=
(
option
:
SelectOption
|
Record
<
string
,
unknown
>
):
boolean
=>
{
const
isSelected
=
(
option
:
any
):
boolean
=>
{
return
getOptionValue
(
option
)
===
props
.
modelValue
}
// Update trigger rect periodically while open to follow scroll/resize
const
updateTriggerRect
=
()
=>
{
if
(
containerRef
.
value
)
{
triggerRect
.
value
=
containerRef
.
value
.
getBoundingClientRect
()
}
}
const
calculateDropdownPosition
=
()
=>
{
if
(
!
containerRef
.
value
)
return
// Update trigger rect for positioning
triggerRect
.
value
=
containerRef
.
value
.
getBoundingClientRect
()
updateTriggerRect
()
nextTick
(()
=>
{
if
(
!
containerRef
.
value
||
!
dropdownRef
.
value
)
return
if
(
!
dropdownRef
.
value
||
!
triggerRect
.
value
)
return
const
dropdownHeight
=
dropdownRef
.
value
.
offsetHeight
||
240
const
spaceBelow
=
window
.
innerHeight
-
triggerRect
.
value
.
bottom
const
spaceAbove
=
triggerRect
.
value
.
top
const
rect
=
triggerRect
.
value
!
const
dropdownHeight
=
dropdownRef
.
value
.
offsetHeight
||
240
// Max height fallback
const
viewportHeight
=
window
.
innerHeight
const
spaceBelow
=
viewportHeight
-
rect
.
bottom
const
spaceAbove
=
rect
.
top
// If not enough space below but enough space above, show dropdown on top
if
(
spaceBelow
<
dropdownHeight
&&
spaceAbove
>
dropdownHeight
)
{
dropdownPosition
.
value
=
'
top
'
}
else
{
...
...
@@ -245,63 +271,108 @@ const calculateDropdownPosition = () => {
const
toggle
=
()
=>
{
if
(
props
.
disabled
)
return
isOpen
.
value
=
!
isOpen
.
value
if
(
isOpen
.
value
)
{
}
watch
(
isOpen
,
(
open
)
=>
{
if
(
open
)
{
calculateDropdownPosition
()
// Reset focused index to current selection or first item
const
selectedIdx
=
filteredOptions
.
value
.
findIndex
(
isSelected
)
focusedIndex
.
value
=
selectedIdx
>=
0
?
selectedIdx
:
0
if
(
props
.
searchable
)
{
nextTick
(()
=>
{
searchInputRef
.
value
?.
focus
()
})
nextTick
(()
=>
searchInputRef
.
value
?.
focus
())
}
// Add scroll listener to update position
window
.
addEventListener
(
'
scroll
'
,
updateTriggerRect
,
{
capture
:
true
,
passive
:
true
})
window
.
addEventListener
(
'
resize
'
,
calculateDropdownPosition
)
}
else
{
searchQuery
.
value
=
''
focusedIndex
.
value
=
-
1
window
.
removeEventListener
(
'
scroll
'
,
updateTriggerRect
,
{
capture
:
true
})
window
.
removeEventListener
(
'
resize
'
,
calculateDropdownPosition
)
}
}
}
)
const
selectOption
=
(
option
:
SelectOption
|
Record
<
string
,
unknown
>
)
=>
{
const
selectOption
=
(
option
:
any
)
=>
{
const
value
=
getOptionValue
(
option
)
??
null
emit
(
'
update:modelValue
'
,
value
)
emit
(
'
change
'
,
value
,
option
as
SelectOption
)
emit
(
'
change
'
,
value
,
option
)
isOpen
.
value
=
false
searchQuery
.
value
=
''
triggerRef
.
value
?.
focus
()
}
const
handleClickOutside
=
(
event
:
MouseEvent
)
=>
{
const
target
=
event
.
target
as
HTMLElement
// 使用 closest 检查点击是否在下拉菜单内部(更可靠,不依赖 ref)
if
(
target
.
closest
(
'
.select-dropdown-portal
'
))
{
return
// 点击在下拉菜单内,不关闭
// Keyboards
const
onTriggerKeyDown
=
()
=>
{
if
(
!
isOpen
.
value
)
{
isOpen
.
value
=
true
}
}
// 检查是否点击在触发器内
if
(
containerRef
.
value
&&
containerRef
.
value
.
contains
(
target
))
{
return
// 点击在触发器内,让 toggle 处理
const
onDropdownKeyDown
=
(
e
:
KeyboardEvent
)
=>
{
switch
(
e
.
key
)
{
case
'
ArrowDown
'
:
e
.
preventDefault
()
focusedIndex
.
value
=
(
focusedIndex
.
value
+
1
)
%
filteredOptions
.
value
.
length
scrollToFocused
()
break
case
'
ArrowUp
'
:
e
.
preventDefault
()
focusedIndex
.
value
=
(
focusedIndex
.
value
-
1
+
filteredOptions
.
value
.
length
)
%
filteredOptions
.
value
.
length
scrollToFocused
()
break
case
'
Enter
'
:
e
.
preventDefault
()
if
(
focusedIndex
.
value
>=
0
&&
focusedIndex
.
value
<
filteredOptions
.
value
.
length
)
{
const
opt
=
filteredOptions
.
value
[
focusedIndex
.
value
]
if
(
!
isOptionDisabled
(
opt
))
selectOption
(
opt
)
}
// 点击在外部,关闭下拉菜单
break
case
'
Escape
'
:
e
.
preventDefault
()
isOpen
.
value
=
false
searchQuery
.
value
=
''
triggerRef
.
value
?.
focus
()
break
case
'
Tab
'
:
isOpen
.
value
=
false
break
}
}
const
handleEscape
=
(
event
:
KeyboardEvent
)
=>
{
if
(
event
.
key
===
'
Escape
'
&&
isOpen
.
value
)
{
isOpen
.
value
=
false
searchQuery
.
value
=
''
const
scrollToFocused
=
()
=>
{
nextTick
(()
=>
{
const
list
=
optionsListRef
.
value
if
(
!
list
)
return
const
focusedEl
=
list
.
children
[
focusedIndex
.
value
]
as
HTMLElement
if
(
!
focusedEl
)
return
if
(
focusedEl
.
offsetTop
<
list
.
scrollTop
)
{
list
.
scrollTop
=
focusedEl
.
offsetTop
}
else
if
(
focusedEl
.
offsetTop
+
focusedEl
.
offsetHeight
>
list
.
scrollTop
+
list
.
offsetHeight
)
{
list
.
scrollTop
=
focusedEl
.
offsetTop
+
focusedEl
.
offsetHeight
-
list
.
offsetHeight
}
})
}
watch
(
isOpen
,
(
open
)
=>
{
if
(
!
open
)
{
searchQuery
.
value
=
''
const
handleClickOutside
=
(
event
:
MouseEvent
)
=>
{
const
target
=
event
.
target
as
HTMLElement
// Check if click is inside THIS specific instance's dropdown or trigger
const
isInDropdown
=
!!
target
.
closest
(
`.
${
instanceId
}
`
)
const
isInTrigger
=
containerRef
.
value
?.
contains
(
target
)
if
(
!
isInDropdown
&&
!
isInTrigger
&&
isOpen
.
value
)
{
isOpen
.
value
=
false
}
}
)
}
onMounted
(()
=>
{
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
document
.
addEventListener
(
'
keydown
'
,
handleEscape
)
})
onUnmounted
(()
=>
{
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
document
.
removeEventListener
(
'
keydown
'
,
handleEscape
)
window
.
removeEventListener
(
'
scroll
'
,
updateTriggerRect
,
{
capture
:
true
})
window
.
removeEventListener
(
'
resize
'
,
calculateDropdownPosition
)
})
</
script
>
...
...
@@ -339,16 +410,14 @@ onUnmounted(() => {
}
</
style
>
<!-- Global styles for teleported dropdown -->
<
style
>
.select-dropdown-portal
{
@apply
w-max
max-w-[3
0
0px];
@apply
w-max
min-w-[160px]
max-w-[3
2
0px];
@apply
bg-white
dark
:
bg-dark-800
;
@apply
rounded-xl;
@apply
border
border-gray-200
dark
:
border-dark-700
;
@apply
shadow-lg
shadow-black/10
dark
:
shadow-black
/
30
;
@apply
overflow-hidden;
/* 确保下拉菜单在引导期间可点击(覆盖 driver.js 的 pointer-events 影响) */
pointer-events
:
auto
!important
;
}
...
...
@@ -365,7 +434,7 @@ onUnmounted(() => {
}
.select-dropdown-portal
.select-options
{
@apply
max-h-60
overflow-y-auto
py-1;
@apply
max-h-60
overflow-y-auto
py-1
outline-none
;
}
.select-dropdown-portal
.select-option
{
...
...
@@ -374,7 +443,6 @@ onUnmounted(() => {
@apply
text-gray-700
dark
:
text-gray-300
;
@apply
cursor-pointer
transition-colors
duration-150;
@apply
hover
:
bg-gray-50
dark
:
hover
:
bg-dark-700
;
/* 确保选项在引导期间可点击 */
pointer-events
:
auto
!important
;
}
...
...
@@ -383,6 +451,14 @@ onUnmounted(() => {
@apply
text-primary-700
dark
:
text-primary-300
;
}
.select-dropdown-portal
.select-option-focused
{
@apply
bg-gray-100
dark
:
bg-dark-700
;
}
.select-dropdown-portal
.select-option-disabled
{
@apply
cursor-not-allowed
opacity-40;
}
.select-dropdown-portal
.select-option-label
{
@apply
flex-1
min-w-0
truncate
text-left;
}
...
...
@@ -392,7 +468,6 @@ onUnmounted(() => {
@apply
text-gray-500
dark
:
text-dark-400
;
}
/* Dropdown animation */
.select-dropdown-enter-active
,
.select-dropdown-leave-active
{
transition
:
all
0.2s
ease
;
...
...
frontend/src/components/common/Skeleton.vue
0 → 100644
View file @
fd29fe11
<
template
>
<div
:class=
"[
'animate-pulse bg-gray-200 dark:bg-dark-700',
variant === 'circle' ? 'rounded-full' : 'rounded-lg',
customClass
]"
:style=
"style"
></div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
interface
Props
{
variant
?:
'
rect
'
|
'
circle
'
|
'
text
'
width
?:
string
|
number
height
?:
string
|
number
class
?:
string
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
variant
:
'
rect
'
,
width
:
'
100%
'
})
const
customClass
=
computed
(()
=>
props
.
class
||
''
)
const
style
=
computed
(()
=>
{
const
s
:
Record
<
string
,
string
>
=
{}
if
(
props
.
width
)
{
s
.
width
=
typeof
props
.
width
===
'
number
'
?
`
${
props
.
width
}
px`
:
props
.
width
}
if
(
props
.
height
)
{
s
.
height
=
typeof
props
.
height
===
'
number
'
?
`
${
props
.
height
}
px`
:
props
.
height
}
else
if
(
props
.
variant
===
'
text
'
)
{
s
.
height
=
'
1em
'
s
.
marginTop
=
'
0.25em
'
s
.
marginBottom
=
'
0.25em
'
}
return
s
})
</
script
>
frontend/src/components/common/StatusBadge.vue
0 → 100644
View file @
fd29fe11
<
template
>
<div
class=
"flex items-center gap-1.5"
>
<span
:class=
"[
'inline-block h-2 w-2 rounded-full',
variantClass
]"
></span>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
label
}}
</span>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
const
props
=
defineProps
<
{
status
:
string
label
:
string
}
>
()
const
variantClass
=
computed
(()
=>
{
switch
(
props
.
status
)
{
case
'
active
'
:
case
'
success
'
:
return
'
bg-green-500
'
case
'
disabled
'
:
case
'
inactive
'
:
case
'
warning
'
:
return
'
bg-yellow-500
'
case
'
error
'
:
case
'
danger
'
:
return
'
bg-red-500
'
default
:
return
'
bg-gray-400
'
}
})
</
script
>
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