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
5deef27e
Commit
5deef27e
authored
Dec 25, 2025
by
ianshaw
Browse files
style(frontend): 优化 Components 代码风格和结构
- 统一移除语句末尾分号,规范代码格式 - 优化组件类型定义和 props 声明 - 改进组件文档和示例代码 - 提升代码可读性和一致性
parent
1ac8b1f0
Changes
38
Hide whitespace changes
Inline
Side-by-side
frontend/src/components/TurnstileWidget.vue
View file @
5deef27e
...
@@ -5,158 +5,164 @@
...
@@ -5,158 +5,164 @@
</
template
>
</
template
>
<
script
setup
lang=
"ts"
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
onMounted
,
onUnmounted
,
watch
}
from
'
vue
'
;
import
{
ref
,
onMounted
,
onUnmounted
,
watch
}
from
'
vue
'
interface
TurnstileRenderOptions
{
interface
TurnstileRenderOptions
{
sitekey
:
string
;
sitekey
:
string
callback
:
(
token
:
string
)
=>
void
;
callback
:
(
token
:
string
)
=>
void
'
expired-callback
'
?:
()
=>
void
;
'
expired-callback
'
?:
()
=>
void
'
error-callback
'
?:
()
=>
void
;
'
error-callback
'
?:
()
=>
void
theme
?:
'
light
'
|
'
dark
'
|
'
auto
'
;
theme
?:
'
light
'
|
'
dark
'
|
'
auto
'
size
?:
'
normal
'
|
'
compact
'
|
'
flexible
'
;
size
?:
'
normal
'
|
'
compact
'
|
'
flexible
'
}
}
interface
TurnstileAPI
{
interface
TurnstileAPI
{
render
:
(
container
:
HTMLElement
,
options
:
TurnstileRenderOptions
)
=>
string
;
render
:
(
container
:
HTMLElement
,
options
:
TurnstileRenderOptions
)
=>
string
reset
:
(
widgetId
?:
string
)
=>
void
;
reset
:
(
widgetId
?:
string
)
=>
void
remove
:
(
widgetId
?:
string
)
=>
void
;
remove
:
(
widgetId
?:
string
)
=>
void
}
}
declare
global
{
declare
global
{
interface
Window
{
interface
Window
{
turnstile
?:
TurnstileAPI
;
turnstile
?:
TurnstileAPI
onTurnstileLoad
?:
()
=>
void
;
onTurnstileLoad
?:
()
=>
void
}
}
}
}
const
props
=
withDefaults
(
defineProps
<
{
const
props
=
withDefaults
(
siteKey
:
string
;
defineProps
<
{
theme
?:
'
light
'
|
'
dark
'
|
'
auto
'
;
siteKey
:
string
size
?:
'
normal
'
|
'
compact
'
|
'
flexible
'
;
theme
?:
'
light
'
|
'
dark
'
|
'
auto
'
}
>
(),
{
size
?:
'
normal
'
|
'
compact
'
|
'
flexible
'
theme
:
'
auto
'
,
}
>
(),
size
:
'
flexible
'
,
{
});
theme
:
'
auto
'
,
size
:
'
flexible
'
}
)
const
emit
=
defineEmits
<
{
const
emit
=
defineEmits
<
{
(
e
:
'
verify
'
,
token
:
string
):
void
;
(
e
:
'
verify
'
,
token
:
string
):
void
(
e
:
'
expire
'
):
void
;
(
e
:
'
expire
'
):
void
(
e
:
'
error
'
):
void
;
(
e
:
'
error
'
):
void
}
>
()
;
}
>
()
const
containerRef
=
ref
<
HTMLElement
|
null
>
(
null
)
;
const
containerRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
widgetId
=
ref
<
string
|
null
>
(
null
)
;
const
widgetId
=
ref
<
string
|
null
>
(
null
)
const
scriptLoaded
=
ref
(
false
)
;
const
scriptLoaded
=
ref
(
false
)
const
loadScript
=
():
Promise
<
void
>
=>
{
const
loadScript
=
():
Promise
<
void
>
=>
{
return
new
Promise
((
resolve
,
reject
)
=>
{
return
new
Promise
((
resolve
,
reject
)
=>
{
if
(
window
.
turnstile
)
{
if
(
window
.
turnstile
)
{
scriptLoaded
.
value
=
true
;
scriptLoaded
.
value
=
true
resolve
()
;
resolve
()
return
;
return
}
}
// Check if script is already loading
// Check if script is already loading
const
existingScript
=
document
.
querySelector
(
'
script[src*="turnstile"]
'
)
;
const
existingScript
=
document
.
querySelector
(
'
script[src*="turnstile"]
'
)
if
(
existingScript
)
{
if
(
existingScript
)
{
window
.
onTurnstileLoad
=
()
=>
{
window
.
onTurnstileLoad
=
()
=>
{
scriptLoaded
.
value
=
true
;
scriptLoaded
.
value
=
true
resolve
()
;
resolve
()
}
;
}
return
;
return
}
}
const
script
=
document
.
createElement
(
'
script
'
)
;
const
script
=
document
.
createElement
(
'
script
'
)
script
.
src
=
'
https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad
'
;
script
.
src
=
'
https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad
'
script
.
async
=
true
;
script
.
async
=
true
script
.
defer
=
true
;
script
.
defer
=
true
window
.
onTurnstileLoad
=
()
=>
{
window
.
onTurnstileLoad
=
()
=>
{
scriptLoaded
.
value
=
true
;
scriptLoaded
.
value
=
true
resolve
()
;
resolve
()
}
;
}
script
.
onerror
=
()
=>
{
script
.
onerror
=
()
=>
{
reject
(
new
Error
(
'
Failed to load Turnstile script
'
))
;
reject
(
new
Error
(
'
Failed to load Turnstile script
'
))
}
;
}
document
.
head
.
appendChild
(
script
)
;
document
.
head
.
appendChild
(
script
)
})
;
})
}
;
}
const
renderWidget
=
()
=>
{
const
renderWidget
=
()
=>
{
if
(
!
window
.
turnstile
||
!
containerRef
.
value
||
!
props
.
siteKey
)
{
if
(
!
window
.
turnstile
||
!
containerRef
.
value
||
!
props
.
siteKey
)
{
return
;
return
}
}
// Remove existing widget if any
// Remove existing widget if any
if
(
widgetId
.
value
)
{
if
(
widgetId
.
value
)
{
try
{
try
{
window
.
turnstile
.
remove
(
widgetId
.
value
)
;
window
.
turnstile
.
remove
(
widgetId
.
value
)
}
catch
{
}
catch
{
// Ignore errors when removing
// Ignore errors when removing
}
}
widgetId
.
value
=
null
;
widgetId
.
value
=
null
}
}
// Clear container
// Clear container
containerRef
.
value
.
innerHTML
=
''
;
containerRef
.
value
.
innerHTML
=
''
widgetId
.
value
=
window
.
turnstile
.
render
(
containerRef
.
value
,
{
widgetId
.
value
=
window
.
turnstile
.
render
(
containerRef
.
value
,
{
sitekey
:
props
.
siteKey
,
sitekey
:
props
.
siteKey
,
callback
:
(
token
:
string
)
=>
{
callback
:
(
token
:
string
)
=>
{
emit
(
'
verify
'
,
token
)
;
emit
(
'
verify
'
,
token
)
},
},
'
expired-callback
'
:
()
=>
{
'
expired-callback
'
:
()
=>
{
emit
(
'
expire
'
)
;
emit
(
'
expire
'
)
},
},
'
error-callback
'
:
()
=>
{
'
error-callback
'
:
()
=>
{
emit
(
'
error
'
)
;
emit
(
'
error
'
)
},
},
theme
:
props
.
theme
,
theme
:
props
.
theme
,
size
:
props
.
size
,
size
:
props
.
size
})
;
})
}
;
}
const
reset
=
()
=>
{
const
reset
=
()
=>
{
if
(
window
.
turnstile
&&
widgetId
.
value
)
{
if
(
window
.
turnstile
&&
widgetId
.
value
)
{
window
.
turnstile
.
reset
(
widgetId
.
value
)
;
window
.
turnstile
.
reset
(
widgetId
.
value
)
}
}
}
;
}
// Expose reset method to parent
// Expose reset method to parent
defineExpose
({
reset
})
;
defineExpose
({
reset
})
onMounted
(
async
()
=>
{
onMounted
(
async
()
=>
{
if
(
!
props
.
siteKey
)
{
if
(
!
props
.
siteKey
)
{
return
;
return
}
}
try
{
try
{
await
loadScript
()
;
await
loadScript
()
renderWidget
()
;
renderWidget
()
}
catch
(
error
)
{
}
catch
(
error
)
{
console
.
error
(
'
Failed to initialize Turnstile:
'
,
error
)
;
console
.
error
(
'
Failed to initialize Turnstile:
'
,
error
)
emit
(
'
error
'
)
;
emit
(
'
error
'
)
}
}
})
;
})
onUnmounted
(()
=>
{
onUnmounted
(()
=>
{
if
(
window
.
turnstile
&&
widgetId
.
value
)
{
if
(
window
.
turnstile
&&
widgetId
.
value
)
{
try
{
try
{
window
.
turnstile
.
remove
(
widgetId
.
value
)
;
window
.
turnstile
.
remove
(
widgetId
.
value
)
}
catch
{
}
catch
{
// Ignore errors when removing
// Ignore errors when removing
}
}
}
}
})
;
})
// Re-render when siteKey changes
// Re-render when siteKey changes
watch
(()
=>
props
.
siteKey
,
(
newKey
)
=>
{
watch
(
if
(
newKey
&&
scriptLoaded
.
value
)
{
()
=>
props
.
siteKey
,
renderWidget
();
(
newKey
)
=>
{
if
(
newKey
&&
scriptLoaded
.
value
)
{
renderWidget
()
}
}
}
});
)
</
script
>
</
script
>
<
style
scoped
>
<
style
scoped
>
...
...
frontend/src/components/account/AccountStatsModal.vue
View file @
5deef27e
<
template
>
<
template
>
<Modal
<Modal
:show=
"show"
:title=
"t('admin.accounts.usageStatistics')"
size=
"2xl"
@
close=
"handleClose"
>
:show=
"show"
:title=
"t('admin.accounts.usageStatistics')"
size=
"2xl"
@
close=
"handleClose"
>
<div
class=
"space-y-6"
>
<div
class=
"space-y-6"
>
<!-- Account Info Header -->
<!-- Account Info Header -->
<div
v-if=
"account"
class=
"flex items-center justify-between p-3 bg-gradient-to-r from-primary-50 to-primary-100 dark:from-primary-900/20 dark:to-primary-800/20 rounded-xl border border-primary-200 dark:border-primary-700/50"
>
<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 items-center gap-3"
>
<div
class=
"w-10 h-10 rounded-lg bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center"
>
<div
<svg
class=
"w-5 h-5 text-white"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
class=
"flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
<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
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>
</svg>
</div>
</div>
<div>
<div>
...
@@ -23,7 +28,7 @@
...
@@ -23,7 +28,7 @@
</div>
</div>
<span
<span
:class=
"[
:class=
"[
'px-2.5 py-1 text-xs font-semibold
rounded-full
',
'
rounded-full
px-2.5 py-1 text-xs font-semibold',
account.status === 'active'
account.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
? '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'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
...
@@ -42,62 +47,140 @@
...
@@ -42,62 +47,140 @@
<!-- Row 1: Main Stats Cards -->
<!-- Row 1: Main Stats Cards -->
<div
class=
"grid grid-cols-2 gap-4 lg:grid-cols-4"
>
<div
class=
"grid grid-cols-2 gap-4 lg:grid-cols-4"
>
<!-- 30-Day Total Cost -->
<!-- 30-Day Total Cost -->
<div
class=
"card p-4 bg-gradient-to-br from-emerald-50 to-white dark:from-emerald-900/10 dark:to-dark-700 border-emerald-200 dark:border-emerald-800/30"
>
<div
<div
class=
"flex items-center justify-between mb-2"
>
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"
<span
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.totalCost
'
)
}}
</span>
>
<div
class=
"p-1.5 rounded-lg bg-emerald-100 dark:bg-emerald-900/30"
>
<div
class=
"mb-2 flex items-center justify-between"
>
<svg
class=
"w-4 h-4 text-emerald-600 dark:text-emerald-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<span
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
<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"
/>
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>
</svg>
</div>
</div>
</div>
</div>
<p
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
$
{{
formatCost
(
stats
.
summary
.
total_cost
)
}}
</p>
<p
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
<p
class=
"text-xs text-gray-500 dark:text-gray-400 mt-1"
>
$
{{
formatCost
(
stats
.
summary
.
total_cost
)
}}
</p>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.accumulatedCost
'
)
}}
{{
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>
<span
class=
"text-gray-400 dark:text-gray-500"
>
(
{{
t
(
'
admin.accounts.stats.standardCost
'
)
}}
: $
{{
formatCost
(
stats
.
summary
.
total_standard_cost
)
}}
)
</span
>
</p>
</p>
</div>
</div>
<!-- 30-Day Total Requests -->
<!-- 30-Day Total Requests -->
<div
class=
"card p-4 bg-gradient-to-br from-blue-50 to-white dark:from-blue-900/10 dark:to-dark-700 border-blue-200 dark:border-blue-800/30"
>
<div
<div
class=
"flex items-center justify-between mb-2"
>
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"
<span
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.totalRequests
'
)
}}
</span>
>
<div
class=
"p-1.5 rounded-lg bg-blue-100 dark:bg-blue-900/30"
>
<div
class=
"mb-2 flex items-center justify-between"
>
<svg
class=
"w-4 h-4 text-blue-600 dark:text-blue-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<span
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M13 10V3L4 14h7v7l9-11h-7z"
/>
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>
</svg>
</div>
</div>
</div>
</div>
<p
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
{{
formatNumber
(
stats
.
summary
.
total_requests
)
}}
</p>
<p
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
<p
class=
"text-xs text-gray-500 dark:text-gray-400 mt-1"
>
{{
t
(
'
admin.accounts.stats.totalCalls
'
)
}}
</p>
{{
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>
</div>
<!-- Daily Average Cost -->
<!-- Daily Average Cost -->
<div
class=
"card p-4 bg-gradient-to-br from-amber-50 to-white dark:from-amber-900/10 dark:to-dark-700 border-amber-200 dark:border-amber-800/30"
>
<div
<div
class=
"flex items-center justify-between mb-2"
>
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"
<span
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.avgDailyCost
'
)
}}
</span>
>
<div
class=
"p-1.5 rounded-lg bg-amber-100 dark:bg-amber-900/30"
>
<div
class=
"mb-2 flex items-center justify-between"
>
<svg
class=
"w-4 h-4 text-amber-600 dark:text-amber-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<span
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
<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"
/>
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>
</svg>
</div>
</div>
</div>
</div>
<p
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
$
{{
formatCost
(
stats
.
summary
.
avg_daily_cost
)
}}
</p>
<p
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
<p
class=
"text-xs text-gray-500 dark:text-gray-400 mt-1"
>
{{
t
(
'
admin.accounts.stats.basedOnActualDays
'
,
{
days
:
stats
.
summary
.
actual_days_used
}
)
}}
<
/p
>
$
{{
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
>
<
/div
>
<!--
Daily
Average
Requests
-->
<!--
Daily
Average
Requests
-->
<
div
class
=
"
card p-4 bg-gradient-to-br from-purple-50 to-white dark:from-purple-900/10 dark:to-dark-700 border-purple-200 dark:border-purple-800/30
"
>
<
div
<
div
class
=
"
flex items-center justify-between mb-2
"
>
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
"
<
span
class
=
"
text-xs font-medium text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.avgDailyRequests
'
)
}}
<
/span
>
>
<
div
class
=
"
p-1.5 rounded-lg bg-purple-100 dark:bg-purple-900/30
"
>
<
div
class
=
"
mb-2 flex items-center justify-between
"
>
<
svg
class
=
"
w-4 h-4 text-purple-600 dark:text-purple-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
span
class
=
"
text-xs font-medium text-gray-500 dark:text-gray-400
"
>
{{
<
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
"
/>
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
>
<
/svg
>
<
/div
>
<
/div
>
<
/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
=
"
text-2xl font-bold text-gray-900 dark:text-white
"
>
<
p
class
=
"
text-xs text-gray-500 dark:text-gray-400 mt-1
"
>
{{
t
(
'
admin.accounts.stats.avgDailyUsage
'
)
}}
<
/p
>
{{
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
>
<
/div
>
<
/div
>
...
@@ -105,78 +188,148 @@
...
@@ -105,78 +188,148 @@
<
div
class
=
"
grid grid-cols-1 gap-4 lg:grid-cols-3
"
>
<
div
class
=
"
grid grid-cols-1 gap-4 lg:grid-cols-3
"
>
<!--
Today
Overview
-->
<!--
Today
Overview
-->
<
div
class
=
"
card p-4
"
>
<
div
class
=
"
card p-4
"
>
<
div
class
=
"
flex items-center gap-2 mb-3
"
>
<
div
class
=
"
mb-3 flex items-center gap-2
"
>
<
div
class
=
"
p-1.5 rounded-lg bg-cyan-100 dark:bg-cyan-900/30
"
>
<
div
class
=
"
rounded-lg bg-cyan-100 p-1.5 dark:bg-cyan-900/30
"
>
<
svg
class
=
"
w-4 h-4 text-cyan-600 dark:text-cyan-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
svg
<
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
"
/>
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
>
<
/svg
>
<
/div
>
<
/div
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.todayOverview
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.todayOverview
'
)
}}
<
/span
>
<
/div
>
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
flex justify-between items-center
"
>
<
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-xs text-gray-500 dark:text-gray-400
"
>
{{
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
today
?.
cost
||
0
)
}}
<
/span
>
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
>
<
div
class
=
"
flex justify-between items-center
"
>
<
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-xs text-gray-500 dark:text-gray-400
"
>
{{
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatNumber
(
stats
.
summary
.
today
?.
requests
||
0
)
}}
<
/span
>
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
>
<
div
class
=
"
flex justify-between
items-center
"
>
<
div
class
=
"
flex
items-center
justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
Tokens
<
/span
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
Tokens
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatTokens
(
stats
.
summary
.
today
?.
tokens
||
0
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatTokens
(
stats
.
summary
.
today
?.
tokens
||
0
)
}}
<
/span
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
Highest
Cost
Day
-->
<!--
Highest
Cost
Day
-->
<
div
class
=
"
card p-4
"
>
<
div
class
=
"
card p-4
"
>
<
div
class
=
"
flex items-center gap-2 mb-3
"
>
<
div
class
=
"
mb-3 flex items-center gap-2
"
>
<
div
class
=
"
p-1.5 rounded-lg bg-orange-100 dark:bg-orange-900/30
"
>
<
div
class
=
"
rounded-lg bg-orange-100 p-1.5 dark:bg-orange-900/30
"
>
<
svg
class
=
"
w-4 h-4 text-orange-600 dark:text-orange-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
svg
<
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
"
/>
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
>
<
/svg
>
<
/div
>
<
/div
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.highestCostDay
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.highestCostDay
'
)
}}
<
/span
>
<
/div
>
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
flex justify-between items-center
"
>
<
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-xs text-gray-500 dark:text-gray-400
"
>
{{
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
stats
.
summary
.
highest_cost_day
?.
label
||
'
-
'
}}
<
/span
>
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
>
<
div
class
=
"
flex justify-between items-center
"
>
<
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-xs text-gray-500 dark:text-gray-400
"
>
{{
<
span
class
=
"
text-sm font-semibold text-orange-600 dark:text-orange-400
"
>
$
{{
formatCost
(
stats
.
summary
.
highest_cost_day
?.
cost
||
0
)
}}
<
/span
>
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
>
<
div
class
=
"
flex justify-between items-center
"
>
<
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-xs text-gray-500 dark:text-gray-400
"
>
{{
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatNumber
(
stats
.
summary
.
highest_cost_day
?.
requests
||
0
)
}}
<
/span
>
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
>
<
/div
>
<
/div
>
<
/div
>
<!--
Highest
Request
Day
-->
<!--
Highest
Request
Day
-->
<
div
class
=
"
card p-4
"
>
<
div
class
=
"
card p-4
"
>
<
div
class
=
"
flex items-center gap-2 mb-3
"
>
<
div
class
=
"
mb-3 flex items-center gap-2
"
>
<
div
class
=
"
p-1.5 rounded-lg bg-indigo-100 dark:bg-indigo-900/30
"
>
<
div
class
=
"
rounded-lg bg-indigo-100 p-1.5 dark:bg-indigo-900/30
"
>
<
svg
class
=
"
w-4 h-4 text-indigo-600 dark:text-indigo-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
svg
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M13 7h8m0 0v8m0-8l-8 8-4-4-6 6
"
/>
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
>
<
/svg
>
<
/div
>
<
/div
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.highestRequestDay
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.highestRequestDay
'
)
}}
<
/span
>
<
/div
>
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
flex justify-between items-center
"
>
<
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-xs text-gray-500 dark:text-gray-400
"
>
{{
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
stats
.
summary
.
highest_request_day
?.
label
||
'
-
'
}}
<
/span
>
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
>
<
div
class
=
"
flex justify-between items-center
"
>
<
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-xs text-gray-500 dark:text-gray-400
"
>
{{
<
span
class
=
"
text-sm font-semibold text-indigo-600 dark:text-indigo-400
"
>
{{
formatNumber
(
stats
.
summary
.
highest_request_day
?.
requests
||
0
)
}}
<
/span
>
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
>
<
div
class
=
"
flex justify-between items-center
"
>
<
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-xs text-gray-500 dark:text-gray-400
"
>
{{
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
highest_request_day
?.
cost
||
0
)
}}
<
/span
>
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
>
<
/div
>
<
/div
>
...
@@ -186,70 +339,134 @@
...
@@ -186,70 +339,134 @@
<
div
class
=
"
grid grid-cols-1 gap-4 lg:grid-cols-3
"
>
<
div
class
=
"
grid grid-cols-1 gap-4 lg:grid-cols-3
"
>
<!--
Accumulated
Tokens
-->
<!--
Accumulated
Tokens
-->
<
div
class
=
"
card p-4
"
>
<
div
class
=
"
card p-4
"
>
<
div
class
=
"
flex items-center gap-2 mb-3
"
>
<
div
class
=
"
mb-3 flex items-center gap-2
"
>
<
div
class
=
"
p-1.5 rounded-lg bg-teal-100 dark:bg-teal-900/30
"
>
<
div
class
=
"
rounded-lg bg-teal-100 p-1.5 dark:bg-teal-900/30
"
>
<
svg
class
=
"
w-4 h-4 text-teal-600 dark:text-teal-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
svg
<
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
"
/>
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
>
<
/svg
>
<
/div
>
<
/div
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.accumulatedTokens
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.accumulatedTokens
'
)
}}
<
/span
>
<
/div
>
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
flex justify-between items-center
"
>
<
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-xs text-gray-500 dark:text-gray-400
"
>
{{
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatTokens
(
stats
.
summary
.
total_tokens
)
}}
<
/span
>
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
>
<
div
class
=
"
flex justify-between items-center
"
>
<
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-xs text-gray-500 dark:text-gray-400
"
>
{{
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatTokens
(
Math
.
round
(
stats
.
summary
.
avg_daily_tokens
))
}}
<
/span
>
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
>
<
/div
>
<
/div
>
<
/div
>
<!--
Performance
-->
<!--
Performance
-->
<
div
class
=
"
card p-4
"
>
<
div
class
=
"
card p-4
"
>
<
div
class
=
"
flex items-center gap-2 mb-3
"
>
<
div
class
=
"
mb-3 flex items-center gap-2
"
>
<
div
class
=
"
p-1.5 rounded-lg bg-rose-100 dark:bg-rose-900/30
"
>
<
div
class
=
"
rounded-lg bg-rose-100 p-1.5 dark:bg-rose-900/30
"
>
<
svg
class
=
"
w-4 h-4 text-rose-600 dark:text-rose-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
svg
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M13 10V3L4 14h7v7l9-11h-7z
"
/>
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
>
<
/svg
>
<
/div
>
<
/div
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.performance
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.performance
'
)
}}
<
/span
>
<
/div
>
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
flex justify-between items-center
"
>
<
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-xs text-gray-500 dark:text-gray-400
"
>
{{
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatDuration
(
stats
.
summary
.
avg_duration_ms
)
}}
<
/span
>
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
>
<
div
class
=
"
flex justify-between items-center
"
>
<
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-xs text-gray-500 dark:text-gray-400
"
>
{{
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
stats
.
summary
.
actual_days_used
}}
/
{{
stats
.
summary
.
days
}}
<
/span
>
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
>
<
/div
>
<
/div
>
<
/div
>
<!--
Recent
Activity
-->
<!--
Recent
Activity
-->
<
div
class
=
"
card p-4
"
>
<
div
class
=
"
card p-4
"
>
<
div
class
=
"
flex items-center gap-2 mb-3
"
>
<
div
class
=
"
mb-3 flex items-center gap-2
"
>
<
div
class
=
"
p-1.5 rounded-lg bg-lime-100 dark:bg-lime-900/30
"
>
<
div
class
=
"
rounded-lg bg-lime-100 p-1.5 dark:bg-lime-900/30
"
>
<
svg
class
=
"
w-4 h-4 text-lime-600 dark:text-lime-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
svg
<
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
"
/>
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
>
<
/svg
>
<
/div
>
<
/div
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.recentActivity
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.recentActivity
'
)
}}
<
/span
>
<
/div
>
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
flex justify-between items-center
"
>
<
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-xs text-gray-500 dark:text-gray-400
"
>
{{
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatNumber
(
stats
.
summary
.
today
?.
requests
||
0
)
}}
<
/span
>
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
>
<
div
class
=
"
flex justify-between items-center
"
>
<
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-xs text-gray-500 dark:text-gray-400
"
>
{{
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatTokens
(
stats
.
summary
.
today
?.
tokens
||
0
)
}}
<
/span
>
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
>
<
div
class
=
"
flex justify-between items-center
"
>
<
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-xs text-gray-500 dark:text-gray-400
"
>
{{
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
today
?.
cost
||
0
)
}}
<
/span
>
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
>
<
/div
>
<
/div
>
...
@@ -257,26 +474,36 @@
...
@@ -257,26 +474,36 @@
<!--
Usage
Trend
Chart
-->
<!--
Usage
Trend
Chart
-->
<
div
class
=
"
card p-4
"
>
<
div
class
=
"
card p-4
"
>
<
h3
class
=
"
text-sm font-semibold text-gray-900 dark:text-white mb-4
"
>
{{
t
(
'
admin.accounts.stats.usageTrend
'
)
}}
<
/h3
>
<
h3
class
=
"
mb-4 text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.usageTrend
'
)
}}
<
/h3
>
<
div
class
=
"
h-64
"
>
<
div
class
=
"
h-64
"
>
<
Line
v
-
if
=
"
trendChartData
"
:
data
=
"
trendChartData
"
:
options
=
"
lineChartOptions
"
/>
<
Line
v
-
if
=
"
trendChartData
"
:
data
=
"
trendChartData
"
:
options
=
"
lineChartOptions
"
/>
<
div
v
-
else
class
=
"
flex items-center justify-center h-full text-gray-500 dark:text-gray-400 text-sm
"
>
<
div
v
-
else
class
=
"
flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.dashboard.noDataAvailable
'
)
}}
{{
t
(
'
admin.dashboard.noDataAvailable
'
)
}}
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
Model
Distribution
-->
<!--
Model
Distribution
-->
<
ModelDistributionChart
<
ModelDistributionChart
:
model
-
stats
=
"
stats.models
"
:
loading
=
"
false
"
/>
:
model
-
stats
=
"
stats.models
"
:
loading
=
"
false
"
/>
<
/template
>
<
/template
>
<!--
No
Data
State
-->
<!--
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
"
>
<
div
<
svg
class
=
"
w-12 h-12 mb-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
v
-
else
-
if
=
"
!loading
"
<
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
"
/>
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
>
<
/svg
>
<
p
class
=
"
text-sm
"
>
{{
t
(
'
admin.accounts.stats.noData
'
)
}}
<
/p
>
<
p
class
=
"
text-sm
"
>
{{
t
(
'
admin.accounts.stats.noData
'
)
}}
<
/p
>
<
/div
>
<
/div
>
...
@@ -286,7 +513,7 @@
...
@@ -286,7 +513,7 @@
<
div
class
=
"
flex justify-end
"
>
<
div
class
=
"
flex justify-end
"
>
<
button
<
button
@
click
=
"
handleClose
"
@
click
=
"
handleClose
"
class
=
"
px-4 py-2 text-sm font-medium text-gray-700
dark:text-gray-300
bg-gray-
1
00 dark:bg-dark-600
hover:bg
-gray-
2
00 dark:hover:bg-dark-500
rounded-lg transition-colors
"
class
=
"
rounded-lg bg-gray-100
px-4 py-2 text-sm font-medium text-gray-700
transition-colors hover:
bg-gray-
2
00 dark:bg-dark-600
dark:text
-gray-
3
00 dark:hover:bg-dark-500
"
>
>
{{
t
(
'
common.close
'
)
}}
{{
t
(
'
common.close
'
)
}}
<
/button
>
<
/button
>
...
@@ -349,7 +576,7 @@ const isDarkMode = computed(() => {
...
@@ -349,7 +576,7 @@ const isDarkMode = computed(() => {
// Chart colors
// Chart colors
const
chartColors
=
computed
(()
=>
({
const
chartColors
=
computed
(()
=>
({
text
:
isDarkMode
.
value
?
'
#e5e7eb
'
:
'
#374151
'
,
text
:
isDarkMode
.
value
?
'
#e5e7eb
'
:
'
#374151
'
,
grid
:
isDarkMode
.
value
?
'
#374151
'
:
'
#e5e7eb
'
,
grid
:
isDarkMode
.
value
?
'
#374151
'
:
'
#e5e7eb
'
}
))
}
))
// Line chart data
// Line chart data
...
@@ -357,27 +584,27 @@ const trendChartData = computed(() => {
...
@@ -357,27 +584,27 @@ const trendChartData = computed(() => {
if
(
!
stats
.
value
?.
history
?.
length
)
return
null
if
(
!
stats
.
value
?.
history
?.
length
)
return
null
return
{
return
{
labels
:
stats
.
value
.
history
.
map
(
h
=>
h
.
label
),
labels
:
stats
.
value
.
history
.
map
(
(
h
)
=>
h
.
label
),
datasets
:
[
datasets
:
[
{
{
label
:
t
(
'
admin.accounts.stats.cost
'
)
+
'
(USD)
'
,
label
:
t
(
'
admin.accounts.stats.cost
'
)
+
'
(USD)
'
,
data
:
stats
.
value
.
history
.
map
(
h
=>
h
.
cost
),
data
:
stats
.
value
.
history
.
map
(
(
h
)
=>
h
.
cost
),
borderColor
:
'
#3b82f6
'
,
borderColor
:
'
#3b82f6
'
,
backgroundColor
:
'
rgba(59, 130, 246, 0.1)
'
,
backgroundColor
:
'
rgba(59, 130, 246, 0.1)
'
,
fill
:
true
,
fill
:
true
,
tension
:
0.3
,
tension
:
0.3
,
yAxisID
:
'
y
'
,
yAxisID
:
'
y
'
}
,
}
,
{
{
label
:
t
(
'
admin.accounts.stats.requests
'
),
label
:
t
(
'
admin.accounts.stats.requests
'
),
data
:
stats
.
value
.
history
.
map
(
h
=>
h
.
requests
),
data
:
stats
.
value
.
history
.
map
(
(
h
)
=>
h
.
requests
),
borderColor
:
'
#f97316
'
,
borderColor
:
'
#f97316
'
,
backgroundColor
:
'
rgba(249, 115, 22, 0.1)
'
,
backgroundColor
:
'
rgba(249, 115, 22, 0.1)
'
,
fill
:
false
,
fill
:
false
,
tension
:
0.3
,
tension
:
0.3
,
yAxisID
:
'
y1
'
,
yAxisID
:
'
y1
'
}
,
}
]
,
]
}
}
}
)
}
)
...
@@ -387,7 +614,7 @@ const lineChartOptions = computed(() => ({
...
@@ -387,7 +614,7 @@ const lineChartOptions = computed(() => ({
maintainAspectRatio
:
false
,
maintainAspectRatio
:
false
,
interaction
:
{
interaction
:
{
intersect
:
false
,
intersect
:
false
,
mode
:
'
index
'
as
const
,
mode
:
'
index
'
as
const
}
,
}
,
plugins
:
{
plugins
:
{
legend
:
{
legend
:
{
...
@@ -398,9 +625,9 @@ const lineChartOptions = computed(() => ({
...
@@ -398,9 +625,9 @@ const lineChartOptions = computed(() => ({
pointStyle
:
'
circle
'
,
pointStyle
:
'
circle
'
,
padding
:
15
,
padding
:
15
,
font
:
{
font
:
{
size
:
11
,
size
:
11
}
,
}
}
,
}
}
,
}
,
tooltip
:
{
tooltip
:
{
callbacks
:
{
callbacks
:
{
...
@@ -411,81 +638,84 @@ const lineChartOptions = computed(() => ({
...
@@ -411,81 +638,84 @@ const lineChartOptions = computed(() => ({
return
`${label
}
: $${formatCost(value)
}
`
return
`${label
}
: $${formatCost(value)
}
`
}
}
return
`${label
}
: ${formatNumber(value)
}
`
return
`${label
}
: ${formatNumber(value)
}
`
}
,
}
}
,
}
}
,
}
}
,
}
,
scales
:
{
scales
:
{
x
:
{
x
:
{
grid
:
{
grid
:
{
color
:
chartColors
.
value
.
grid
,
color
:
chartColors
.
value
.
grid
}
,
}
,
ticks
:
{
ticks
:
{
color
:
chartColors
.
value
.
text
,
color
:
chartColors
.
value
.
text
,
font
:
{
font
:
{
size
:
10
,
size
:
10
}
,
}
,
maxRotation
:
45
,
maxRotation
:
45
,
minRotation
:
0
,
minRotation
:
0
}
,
}
}
,
}
,
y
:
{
y
:
{
type
:
'
linear
'
as
const
,
type
:
'
linear
'
as
const
,
display
:
true
,
display
:
true
,
position
:
'
left
'
as
const
,
position
:
'
left
'
as
const
,
grid
:
{
grid
:
{
color
:
chartColors
.
value
.
grid
,
color
:
chartColors
.
value
.
grid
}
,
}
,
ticks
:
{
ticks
:
{
color
:
'
#3b82f6
'
,
color
:
'
#3b82f6
'
,
font
:
{
font
:
{
size
:
10
,
size
:
10
}
,
}
,
callback
:
(
value
:
string
|
number
)
=>
'
$
'
+
formatCost
(
Number
(
value
))
,
callback
:
(
value
:
string
|
number
)
=>
'
$
'
+
formatCost
(
Number
(
value
))
}
,
}
,
title
:
{
title
:
{
display
:
true
,
display
:
true
,
text
:
t
(
'
admin.accounts.stats.cost
'
)
+
'
(USD)
'
,
text
:
t
(
'
admin.accounts.stats.cost
'
)
+
'
(USD)
'
,
color
:
'
#3b82f6
'
,
color
:
'
#3b82f6
'
,
font
:
{
font
:
{
size
:
11
,
size
:
11
}
,
}
}
,
}
}
,
}
,
y1
:
{
y1
:
{
type
:
'
linear
'
as
const
,
type
:
'
linear
'
as
const
,
display
:
true
,
display
:
true
,
position
:
'
right
'
as
const
,
position
:
'
right
'
as
const
,
grid
:
{
grid
:
{
drawOnChartArea
:
false
,
drawOnChartArea
:
false
}
,
}
,
ticks
:
{
ticks
:
{
color
:
'
#f97316
'
,
color
:
'
#f97316
'
,
font
:
{
font
:
{
size
:
10
,
size
:
10
}
,
}
,
callback
:
(
value
:
string
|
number
)
=>
formatNumber
(
Number
(
value
))
,
callback
:
(
value
:
string
|
number
)
=>
formatNumber
(
Number
(
value
))
}
,
}
,
title
:
{
title
:
{
display
:
true
,
display
:
true
,
text
:
t
(
'
admin.accounts.stats.requests
'
),
text
:
t
(
'
admin.accounts.stats.requests
'
),
color
:
'
#f97316
'
,
color
:
'
#f97316
'
,
font
:
{
font
:
{
size
:
11
,
size
:
11
}
,
}
}
,
}
}
,
}
}
,
}
}
))
}
))
// Load stats when modal opens
// Load stats when modal opens
watch
(()
=>
props
.
show
,
async
(
newVal
)
=>
{
watch
(
if
(
newVal
&&
props
.
account
)
{
()
=>
props
.
show
,
await
loadStats
()
async
(
newVal
)
=>
{
}
else
{
if
(
newVal
&&
props
.
account
)
{
stats
.
value
=
null
await
loadStats
()
}
else
{
stats
.
value
=
null
}
}
}
}
)
)
const
loadStats
=
async
()
=>
{
const
loadStats
=
async
()
=>
{
if
(
!
props
.
account
)
return
if
(
!
props
.
account
)
return
...
...
frontend/src/components/account/AccountStatusIndicator.vue
View file @
5deef27e
<
template
>
<
template
>
<div
class=
"flex items-center gap-2"
>
<div
class=
"flex items-center gap-2"
>
<!-- Main Status Badge -->
<!-- Main Status Badge -->
<span
<span
:class=
"['badge text-xs', statusClass]"
>
:class=
"[
'badge text-xs',
statusClass
]"
>
{{
statusText
}}
{{
statusText
}}
</span>
</span>
<!-- Error Info Indicator -->
<!-- Error Info Indicator -->
<div
v-if=
"hasError && account.error_message"
class=
"relative group/error"
>
<div
v-if=
"hasError && account.error_message"
class=
"group/error relative"
>
<svg
class=
"w-4 h-4 text-red-500 dark:text-red-400 cursor-help hover:text-red-600 dark:hover:text-red-300 transition-colors"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<svg
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
/>
class=
"h-4 w-4 cursor-help text-red-500 transition-colors hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
/>
</svg>
</svg>
<!-- Tooltip - 向下显示 -->
<!-- Tooltip - 向下显示 -->
<div
class=
"absolute top-full left-0 mt-1.5 px-3 py-2 bg-gray-800 dark:bg-gray-900 text-white text-xs rounded-lg shadow-xl opacity-0 invisible group-hover/error:opacity-100 group-hover/error:visible transition-all duration-200 z-[100] min-w-[200px] max-w-[300px]"
>
<div
<div
class=
"text-gray-300 break-words whitespace-pre-wrap leading-relaxed"
>
{{
account
.
error_message
}}
</div>
class=
"invisible absolute left-0 top-full z-[100] mt-1.5 min-w-[200px] max-w-[300px] rounded-lg bg-gray-800 px-3 py-2 text-xs text-white opacity-0 shadow-xl transition-all duration-200 group-hover/error:visible group-hover/error:opacity-100 dark:bg-gray-900"
>
<div
class=
"whitespace-pre-wrap break-words leading-relaxed text-gray-300"
>
{{
account
.
error_message
}}
</div>
<!-- 上方小三角 -->
<!-- 上方小三角 -->
<div
class=
"absolute bottom-full left-3 border-[6px] border-transparent border-b-gray-800 dark:border-b-gray-900"
></div>
<div
class=
"absolute bottom-full left-3 border-[6px] border-transparent border-b-gray-800 dark:border-b-gray-900"
></div>
</div>
</div>
</div>
</div>
<!-- Rate Limit Indicator (429) -->
<!-- Rate Limit Indicator (429) -->
<div
v-if=
"isRateLimited"
class=
"relative group"
>
<div
v-if=
"isRateLimited"
class=
"group relative"
>
<span
class=
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
>
<span
<svg
class=
"w-3 h-3"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
class=
"inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
>
<svg
class=
"h-3 w-3"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</svg>
429
429
</span>
</span>
<!-- Tooltip -->
<!-- Tooltip -->
<div
class=
"absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50"
>
<div
class=
"pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
Rate limited until
{{
formatTime
(
account
.
rate_limit_reset_at
)
}}
Rate limited until
{{
formatTime
(
account
.
rate_limit_reset_at
)
}}
<div
class=
"absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
></div>
<div
class=
"absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
></div>
</div>
</div>
</div>
</div>
<!-- Overload Indicator (529) -->
<!-- Overload Indicator (529) -->
<div
v-if=
"isOverloaded"
class=
"relative group"
>
<div
v-if=
"isOverloaded"
class=
"group relative"
>
<span
class=
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
>
<span
<svg
class=
"w-3 h-3"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
class=
"inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
>
<svg
class=
"h-3 w-3"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</svg>
529
529
</span>
</span>
<!-- Tooltip -->
<!-- Tooltip -->
<div
class=
"absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50"
>
<div
class=
"pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
Overloaded until
{{
formatTime
(
account
.
overload_until
)
}}
Overloaded until
{{
formatTime
(
account
.
overload_until
)
}}
<div
class=
"absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
></div>
<div
class=
"absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
></div>
</div>
</div>
</div>
</div>
</div>
</div>
...
...
frontend/src/components/account/AccountTestModal.vue
View file @
5deef27e
...
@@ -7,17 +7,29 @@
...
@@ -7,17 +7,29 @@
>
>
<div
class=
"space-y-4"
>
<div
class=
"space-y-4"
>
<!-- Account Info Card -->
<!-- Account Info Card -->
<div
v-if=
"account"
class=
"flex items-center justify-between p-3 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-dark-700 dark:to-dark-600 rounded-xl border border-gray-200 dark:border-dark-500"
>
<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 items-center gap-3"
>
<div
class=
"w-10 h-10 rounded-lg bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center"
>
<div
<svg
class=
"w-5 h-5 text-white"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
class=
"flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
<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
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>
</svg>
</div>
</div>
<div>
<div>
<div
class=
"font-semibold text-gray-900 dark:text-gray-100"
>
{{
account
.
name
}}
</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 flex items-center gap-1.5"
>
<div
class=
"flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400"
>
<span
class=
"px-1.5 py-0.5 bg-gray-200 dark:bg-dark-500 rounded text-[10px] font-medium uppercase"
>
<span
class=
"rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium uppercase dark:bg-dark-500"
>
{{
account
.
type
}}
{{
account
.
type
}}
</span>
</span>
<span>
{{
t
(
'
admin.accounts.account
'
)
}}
</span>
<span>
{{
t
(
'
admin.accounts.account
'
)
}}
</span>
...
@@ -26,7 +38,7 @@
...
@@ -26,7 +38,7 @@
</div>
</div>
<span
<span
:class=
"[
:class=
"[
'px-2.5 py-1 text-xs font-semibold
rounded-full
',
'
rounded-full
px-2.5 py-1 text-xs font-semibold',
account.status === 'active'
account.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
? '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'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
...
@@ -44,7 +56,7 @@
...
@@ -44,7 +56,7 @@
<select
<select
v-model=
"selectedModelId"
v-model=
"selectedModelId"
:disabled=
"loadingModels || status === 'connecting'"
:disabled=
"loadingModels || status === 'connecting'"
class=
"w-full
px-3 py-2 text-sm
rounded-lg border border-gray-300
dark:border-dark-500 bg-white dark:bg-dark-700 text-gray-900 dark:text-gra
y-
1
00 focus:ring-2 focus:ring-primary-500
focus:border-primary-500 disabled:opacity-50 disabled:cursor-not-allowed
"
class=
"w-full rounded-lg border border-gray-300
bg-white px-3 py-2 text-sm text-gray-900 focus:border-primar
y-
5
00 focus:ring-2 focus:ring-primary-500
disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-500 dark:bg-dark-700 dark:text-gray-100
"
>
>
<option
v-if=
"loadingModels"
value=
""
>
{{
t
(
'
common.loading
'
)
}}
...
</option>
<option
v-if=
"loadingModels"
value=
""
>
{{
t
(
'
common.loading
'
)
}}
...
</option>
<option
v-for=
"model in availableModels"
:key=
"model.id"
:value=
"model.id"
>
<option
v-for=
"model in availableModels"
:key=
"model.id"
:value=
"model.id"
>
...
@@ -54,22 +66,38 @@
...
@@ -54,22 +66,38 @@
</div>
</div>
<!-- Terminal Output -->
<!-- Terminal Output -->
<div
class=
"relative
group
"
>
<div
class=
"
group
relative"
>
<div
<div
ref=
"terminalRef"
ref=
"terminalRef"
class=
"
bg-gray-900 dark:bg-black rounded-xl p-4 min
-h-[
1
20px] m
ax
-h-[2
4
0px] overflow-y-auto
font-mono text-sm
border border-gray-700 dark:border-gray-800"
class=
"
max
-h-[2
4
0px] m
in
-h-[
1
20px] 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 -->
<!-- Status Line -->
<div
v-if=
"status === 'idle'"
class=
"text-gray-500 flex items-center gap-2"
>
<div
v-if=
"status === 'idle'"
class=
"flex items-center gap-2 text-gray-500"
>
<svg
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<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"
/>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</svg>
<span>
{{
t
(
'
admin.accounts.readyToTest
'
)
}}
</span>
<span>
{{
t
(
'
admin.accounts.readyToTest
'
)
}}
</span>
</div>
</div>
<div
v-else-if=
"status === 'connecting'"
class=
"text-yellow-400 flex items-center gap-2"
>
<div
v-else-if=
"status === 'connecting'"
class=
"flex items-center gap-2 text-yellow-400"
>
<svg
class=
"animate-spin w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
>
<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>
<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>
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>
<span>
{{
t
(
'
admin.accounts.connectingToApi
'
)
}}
</span>
<span>
{{
t
(
'
admin.accounts.connectingToApi
'
)
}}
</span>
</div>
</div>
...
@@ -85,15 +113,31 @@
...
@@ -85,15 +113,31 @@
</div>
</div>
<!-- Result Status -->
<!-- Result Status -->
<div
v-if=
"status === 'success'"
class=
"text-green-400 mt-3 pt-3 border-t border-gray-700 flex items-center gap-2"
>
<div
<svg
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
v-if=
"status === 'success'"
<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"
/>
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>
</svg>
<span>
{{
t
(
'
admin.accounts.testCompleted
'
)
}}
</span>
<span>
{{
t
(
'
admin.accounts.testCompleted
'
)
}}
</span>
</div>
</div>
<div
v-else-if=
"status === 'error'"
class=
"text-red-400 mt-3 pt-3 border-t border-gray-700 flex items-center gap-2"
>
<div
<svg
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
v-else-if=
"status === 'error'"
<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"
/>
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>
</svg>
<span>
{{
errorMessage
}}
</span>
<span>
{{
errorMessage
}}
</span>
</div>
</div>
...
@@ -103,28 +147,43 @@
...
@@ -103,28 +147,43 @@
<button
<button
v-if=
"outputLines.length > 0"
v-if=
"outputLines.length > 0"
@
click=
"copyOutput"
@
click=
"copyOutput"
class=
"absolute
top-2
right-2
p-1.5 text-gray-400 hover:text-white
bg-gray-800/80
hover:bg
-gray-
7
00
rounded-lg
transition-all
opacity-0
group-hover:opacity-100"
class=
"absolute right-2
top-2 rounded-lg
bg-gray-800/80
p-1.5 text
-gray-
4
00
opacity-0
transition-all
hover:bg-gray-700 hover:text-white
group-hover:opacity-100"
:title=
"t('admin.accounts.copyOutput')"
:title=
"t('admin.accounts.copyOutput')"
>
>
<svg
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<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"
/>
<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>
</svg>
</button>
</button>
</div>
</div>
<!-- Test Info -->
<!-- Test Info -->
<div
class=
"flex items-center justify-between text-xs text-gray-500 dark:text-gray-400
px-1
"
>
<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"
>
<div
class=
"flex items-center gap-3"
>
<span
class=
"flex items-center gap-1"
>
<span
class=
"flex items-center gap-1"
>
<svg
class=
"w-3.5 h-3.5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<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"
/>
<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>
</svg>
{{
t
(
'
admin.accounts.testModel
'
)
}}
{{
t
(
'
admin.accounts.testModel
'
)
}}
</span>
</span>
</div>
</div>
<span
class=
"flex items-center gap-1"
>
<span
class=
"flex items-center gap-1"
>
<svg
class=
"w-3.5 h-3.5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<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"
/>
<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>
</svg>
{{
t
(
'
admin.accounts.testPrompt
'
)
}}
{{
t
(
'
admin.accounts.testPrompt
'
)
}}
</span>
</span>
...
@@ -135,7 +194,7 @@
...
@@ -135,7 +194,7 @@
<div
class=
"flex justify-end gap-3"
>
<div
class=
"flex justify-end gap-3"
>
<button
<button
@
click=
"handleClose"
@
click=
"handleClose"
class=
"px-4 py-2 text-sm font-medium text-gray-700
dark:text-gray-300
bg-gray-
1
00 dark:bg-dark-600
hover:bg
-gray-
2
00 dark:hover:bg-dark-500
rounded-lg transition-colors
"
class=
"
rounded-lg bg-gray-100
px-4 py-2 text-sm font-medium text-gray-700
transition-colors hover:
bg-gray-
2
00 dark:bg-dark-600
dark:text
-gray-
3
00 dark:hover:bg-dark-500"
:disabled=
"status === 'connecting'"
:disabled=
"status === 'connecting'"
>
>
{{
t
(
'
common.close
'
)
}}
{{
t
(
'
common.close
'
)
}}
...
@@ -144,29 +203,72 @@
...
@@ -144,29 +203,72 @@
@
click=
"startTest"
@
click=
"startTest"
:disabled=
"status === 'connecting' || !selectedModelId"
:disabled=
"status === 'connecting' || !selectedModelId"
:class=
"[
:class=
"[
'px-4 py-2 text-sm font-medium
rounded-lg
transition-all
flex items-center gap-2
',
'
flex items-center gap-2 rounded-lg
px-4 py-2 text-sm font-medium transition-all',
status === 'connecting' || !selectedModelId
status === 'connecting' || !selectedModelId
? 'bg-primary-400 text-white
cursor-not-allowed
'
? '
cursor-not-allowed
bg-primary-400 text-white'
: status === 'success'
: status === 'success'
? 'bg-green-500 hover:bg-green-600
text-white
'
? 'bg-green-500
text-white
hover:bg-green-600'
: status === 'error'
: status === 'error'
? 'bg-orange-500 hover:bg-orange-600
text-white
'
? 'bg-orange-500
text-white
hover:bg-orange-600'
: 'bg-primary-500 hover:bg-primary-600
text-white
'
: 'bg-primary-500
text-white
hover:bg-primary-600'
]"
]"
>
>
<svg
v-if=
"status === 'connecting'"
class=
"animate-spin h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
>
<svg
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle>
v-if=
"status === 'connecting'"
<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>
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>
<svg
v-else-if=
"status === 'idle'"
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<svg
<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"
/>
v-else-if=
"status === 'idle'"
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
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>
<svg
v-else
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<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"
/>
<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>
</svg>
<span>
<span>
{{
status
===
'
connecting
'
?
t
(
'
admin.accounts.testing
'
)
:
status
===
'
idle
'
?
t
(
'
admin.accounts.startTest
'
)
:
t
(
'
admin.accounts.retry
'
)
}}
{{
status
===
'
connecting
'
?
t
(
'
admin.accounts.testing
'
)
:
status
===
'
idle
'
?
t
(
'
admin.accounts.startTest
'
)
:
t
(
'
admin.accounts.retry
'
)
}}
</span>
</span>
</button>
</button>
</div>
</div>
...
@@ -208,14 +310,17 @@ const loadingModels = ref(false)
...
@@ -208,14 +310,17 @@ const loadingModels = ref(false)
let
eventSource
:
EventSource
|
null
=
null
let
eventSource
:
EventSource
|
null
=
null
// Load available models when modal opens
// Load available models when modal opens
watch
(()
=>
props
.
show
,
async
(
newVal
)
=>
{
watch
(
if
(
newVal
&&
props
.
account
)
{
()
=>
props
.
show
,
resetState
()
async
(
newVal
)
=>
{
await
loadAvailableModels
()
if
(
newVal
&&
props
.
account
)
{
}
else
{
resetState
()
closeEventSource
()
await
loadAvailableModels
()
}
else
{
closeEventSource
()
}
}
}
}
)
)
const
loadAvailableModels
=
async
()
=>
{
const
loadAvailableModels
=
async
()
=>
{
if
(
!
props
.
account
)
return
if
(
!
props
.
account
)
return
...
@@ -227,7 +332,7 @@ const loadAvailableModels = async () => {
...
@@ -227,7 +332,7 @@ const loadAvailableModels = async () => {
// Default to first model (usually Sonnet)
// Default to first model (usually Sonnet)
if
(
availableModels
.
value
.
length
>
0
)
{
if
(
availableModels
.
value
.
length
>
0
)
{
// Try to select Sonnet as default, otherwise use first model
// Try to select Sonnet as default, otherwise use first model
const
sonnetModel
=
availableModels
.
value
.
find
(
m
=>
m
.
id
.
includes
(
'
sonnet
'
))
const
sonnetModel
=
availableModels
.
value
.
find
(
(
m
)
=>
m
.
id
.
includes
(
'
sonnet
'
))
selectedModelId
.
value
=
sonnetModel
?.
id
||
availableModels
.
value
[
0
].
id
selectedModelId
.
value
=
sonnetModel
?.
id
||
availableModels
.
value
[
0
].
id
}
}
}
catch
(
error
)
{
}
catch
(
error
)
{
...
@@ -290,7 +395,7 @@ const startTest = async () => {
...
@@ -290,7 +395,7 @@ const startTest = async () => {
const
response
=
await
fetch
(
url
,
{
const
response
=
await
fetch
(
url
,
{
method
:
'
POST
'
,
method
:
'
POST
'
,
headers
:
{
headers
:
{
'
Authorization
'
:
`Bearer
${
localStorage
.
getItem
(
'
auth_token
'
)}
`
,
Authorization
:
`Bearer
${
localStorage
.
getItem
(
'
auth_token
'
)}
`
,
'
Content-Type
'
:
'
application/json
'
'
Content-Type
'
:
'
application/json
'
},
},
body
:
JSON
.
stringify
({
model_id
:
selectedModelId
.
value
})
body
:
JSON
.
stringify
({
model_id
:
selectedModelId
.
value
})
...
@@ -337,7 +442,13 @@ const startTest = async () => {
...
@@ -337,7 +442,13 @@ const startTest = async () => {
}
}
}
}
const
handleEvent
=
(
event
:
{
type
:
string
;
text
?:
string
;
model
?:
string
;
success
?:
boolean
;
error
?:
string
})
=>
{
const
handleEvent
=
(
event
:
{
type
:
string
text
?:
string
model
?:
string
success
?:
boolean
error
?:
string
})
=>
{
switch
(
event
.
type
)
{
switch
(
event
.
type
)
{
case
'
test_start
'
:
case
'
test_start
'
:
addLine
(
t
(
'
admin.accounts.connectedToApi
'
),
'
text-green-400
'
)
addLine
(
t
(
'
admin.accounts.connectedToApi
'
),
'
text-green-400
'
)
...
@@ -382,7 +493,7 @@ const handleEvent = (event: { type: string; text?: string; model?: string; succe
...
@@ -382,7 +493,7 @@ const handleEvent = (event: { type: string; text?: string; model?: string; succe
}
}
const
copyOutput
=
()
=>
{
const
copyOutput
=
()
=>
{
const
text
=
outputLines
.
value
.
map
(
l
=>
l
.
text
).
join
(
'
\n
'
)
const
text
=
outputLines
.
value
.
map
(
(
l
)
=>
l
.
text
).
join
(
'
\n
'
)
navigator
.
clipboard
.
writeText
(
text
)
navigator
.
clipboard
.
writeText
(
text
)
}
}
</
script
>
</
script
>
frontend/src/components/account/AccountTodayStatsCell.vue
View file @
5deef27e
...
@@ -2,9 +2,9 @@
...
@@ -2,9 +2,9 @@
<div>
<div>
<!-- Loading state -->
<!-- Loading state -->
<div
v-if=
"loading"
class=
"space-y-0.5"
>
<div
v-if=
"loading"
class=
"space-y-0.5"
>
<div
class=
"h-3 w-12 bg-gray-200 dark:bg-gray-700
rounded animate-pulse
"
></div>
<div
class=
"h-3 w-12
animate-pulse rounded
bg-gray-200 dark:bg-gray-700"
></div>
<div
class=
"h-3 w-16 bg-gray-200 dark:bg-gray-700
rounded animate-pulse
"
></div>
<div
class=
"h-3 w-16
animate-pulse rounded
bg-gray-200 dark:bg-gray-700"
></div>
<div
class=
"h-3 w-10 bg-gray-200 dark:bg-gray-700
rounded animate-pulse
"
></div>
<div
class=
"h-3 w-10
animate-pulse rounded
bg-gray-200 dark:bg-gray-700"
></div>
</div>
</div>
<!-- Error state -->
<!-- Error state -->
...
@@ -17,24 +17,28 @@
...
@@ -17,24 +17,28 @@
<!-- Requests -->
<!-- Requests -->
<div
class=
"flex items-center gap-1"
>
<div
class=
"flex items-center gap-1"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
Req:
</span>
<span
class=
"text-gray-500 dark:text-gray-400"
>
Req:
</span>
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
{{
formatNumber
(
stats
.
requests
)
}}
</span>
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
{{
formatNumber
(
stats
.
requests
)
}}
</span>
</div>
</div>
<!-- Tokens -->
<!-- Tokens -->
<div
class=
"flex items-center gap-1"
>
<div
class=
"flex items-center gap-1"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
Tok:
</span>
<span
class=
"text-gray-500 dark:text-gray-400"
>
Tok:
</span>
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
{{
formatTokens
(
stats
.
tokens
)
}}
</span>
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
{{
formatTokens
(
stats
.
tokens
)
}}
</span>
</div>
</div>
<!-- Cost -->
<!-- Cost -->
<div
class=
"flex items-center gap-1"
>
<div
class=
"flex items-center gap-1"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
Cost:
</span>
<span
class=
"text-gray-500 dark:text-gray-400"
>
Cost:
</span>
<span
class=
"font-medium text-emerald-600 dark:text-emerald-400"
>
{{
formatCurrency
(
stats
.
cost
)
}}
</span>
<span
class=
"font-medium text-emerald-600 dark:text-emerald-400"
>
{{
formatCurrency
(
stats
.
cost
)
}}
</span>
</div>
</div>
</div>
</div>
<!-- No data -->
<!-- No data -->
<div
v-else
class=
"text-xs text-gray-400"
>
<div
v-else
class=
"text-xs text-gray-400"
>
-
</div>
-
</div>
</div>
</div>
</
template
>
</
template
>
...
...
frontend/src/components/account/AccountUsageCell.vue
View file @
5deef27e
<
template
>
<
template
>
<div
v-if=
"showUsageWindows"
>
<div
v-if=
"showUsageWindows"
>
<!-- Anthropic OAuth and Setup Token accounts: fetch real usage data -->
<!-- Anthropic OAuth and Setup Token accounts: fetch real usage data -->
<template
v-if=
"account.platform === 'anthropic' && (account.type === 'oauth' || account.type === 'setup-token')"
>
<template
v-if=
"
account.platform === 'anthropic' &&
(account.type === 'oauth' || account.type === 'setup-token')
"
>
<!-- Loading state -->
<!-- Loading state -->
<div
v-if=
"loading"
class=
"space-y-1.5"
>
<div
v-if=
"loading"
class=
"space-y-1.5"
>
<!-- OAuth: 3 rows, Setup Token: 1 row -->
<!-- OAuth: 3 rows, Setup Token: 1 row -->
<div
class=
"flex items-center gap-1"
>
<div
class=
"flex items-center gap-1"
>
<div
class=
"w-[32px]
h-3
bg-gray-200 dark:bg-gray-700
rounded animate-pulse
"
></div>
<div
class=
"
h-3
w-[32px]
animate-pulse rounded
bg-gray-200 dark:bg-gray-700"
></div>
<div
class=
"
w-8
h-1.5 bg-gray-200 dark:bg-gray-700
rounded-full animate-pulse
"
></div>
<div
class=
"h-1.5
w-8 animate-pulse rounded-full
bg-gray-200 dark:bg-gray-700"
></div>
<div
class=
"w-[32px]
h-3
bg-gray-200 dark:bg-gray-700
rounded animate-pulse
"
></div>
<div
class=
"
h-3
w-[32px]
animate-pulse rounded
bg-gray-200 dark:bg-gray-700"
></div>
</div>
</div>
<template
v-if=
"account.type === 'oauth'"
>
<template
v-if=
"account.type === 'oauth'"
>
<div
class=
"flex items-center gap-1"
>
<div
class=
"flex items-center gap-1"
>
<div
class=
"w-[32px]
h-3
bg-gray-200 dark:bg-gray-700
rounded animate-pulse
"
></div>
<div
class=
"
h-3
w-[32px]
animate-pulse rounded
bg-gray-200 dark:bg-gray-700"
></div>
<div
class=
"
w-8
h-1.5 bg-gray-200 dark:bg-gray-700
rounded-full animate-pulse
"
></div>
<div
class=
"h-1.5
w-8 animate-pulse rounded-full
bg-gray-200 dark:bg-gray-700"
></div>
<div
class=
"w-[32px]
h-3
bg-gray-200 dark:bg-gray-700
rounded animate-pulse
"
></div>
<div
class=
"
h-3
w-[32px]
animate-pulse rounded
bg-gray-200 dark:bg-gray-700"
></div>
</div>
</div>
<div
class=
"flex items-center gap-1"
>
<div
class=
"flex items-center gap-1"
>
<div
class=
"w-[32px]
h-3
bg-gray-200 dark:bg-gray-700
rounded animate-pulse
"
></div>
<div
class=
"
h-3
w-[32px]
animate-pulse rounded
bg-gray-200 dark:bg-gray-700"
></div>
<div
class=
"
w-8
h-1.5 bg-gray-200 dark:bg-gray-700
rounded-full animate-pulse
"
></div>
<div
class=
"h-1.5
w-8 animate-pulse rounded-full
bg-gray-200 dark:bg-gray-700"
></div>
<div
class=
"w-[32px]
h-3
bg-gray-200 dark:bg-gray-700
rounded animate-pulse
"
></div>
<div
class=
"
h-3
w-[32px]
animate-pulse rounded
bg-gray-200 dark:bg-gray-700"
></div>
</div>
</div>
</
template
>
</
template
>
</div>
</div>
...
@@ -61,9 +66,7 @@
...
@@ -61,9 +66,7 @@
</div>
</div>
<!-- No data yet -->
<!-- No data yet -->
<div
v-else
class=
"text-xs text-gray-400"
>
<div
v-else
class=
"text-xs text-gray-400"
>
-
</div>
-
</div>
</template>
</template>
<!-- OpenAI OAuth accounts: show Codex usage from extra field -->
<!-- OpenAI OAuth accounts: show Codex usage from extra field -->
...
@@ -97,9 +100,7 @@
...
@@ -97,9 +100,7 @@
</div>
</div>
<!-- Non-OAuth/Setup-Token accounts -->
<!-- Non-OAuth/Setup-Token accounts -->
<div
v-else
class=
"text-xs text-gray-400"
>
<div
v-else
class=
"text-xs text-gray-400"
>
-
</div>
-
</div>
</template>
</template>
<
script
setup
lang=
"ts"
>
<
script
setup
lang=
"ts"
>
...
@@ -117,20 +118,21 @@ const error = ref<string | null>(null)
...
@@ -117,20 +118,21 @@ const error = ref<string | null>(null)
const
usageInfo
=
ref
<
AccountUsageInfo
|
null
>
(
null
)
const
usageInfo
=
ref
<
AccountUsageInfo
|
null
>
(
null
)
// Show usage windows for OAuth and Setup Token accounts
// Show usage windows for OAuth and Setup Token accounts
const
showUsageWindows
=
computed
(
()
=>
const
showUsageWindows
=
computed
(
props
.
account
.
type
===
'
oauth
'
||
props
.
account
.
type
===
'
setup-token
'
()
=>
props
.
account
.
type
===
'
oauth
'
||
props
.
account
.
type
===
'
setup-token
'
)
)
// OpenAI Codex usage computed properties
// OpenAI Codex usage computed properties
const
hasCodexUsage
=
computed
(()
=>
{
const
hasCodexUsage
=
computed
(()
=>
{
const
extra
=
props
.
account
.
extra
const
extra
=
props
.
account
.
extra
return
extra
&&
(
return
(
extra
&&
// Check for new canonical fields first
// Check for new canonical fields first
extra
.
codex_5h_used_percent
!==
undefined
||
(
extra
.
codex_5h_used_percent
!==
undefined
||
extra
.
codex_7d_used_percent
!==
undefined
||
extra
.
codex_7d_used_percent
!==
undefined
||
// Fallback to legacy fields
// Fallback to legacy fields
extra
.
codex_primary_used_percent
!==
undefined
||
extra
.
codex_primary_used_percent
!==
undefined
||
extra
.
codex_secondary_used_percent
!==
undefined
extra
.
codex_secondary_used_percent
!==
undefined
)
)
)
})
})
...
@@ -145,10 +147,16 @@ const codex5hUsedPercent = computed(() => {
...
@@ -145,10 +147,16 @@ const codex5hUsedPercent = computed(() => {
}
}
// Fallback: detect from legacy fields using window_minutes
// Fallback: detect from legacy fields using window_minutes
if
(
extra
.
codex_primary_window_minutes
!==
undefined
&&
extra
.
codex_primary_window_minutes
<=
360
)
{
if
(
extra
.
codex_primary_window_minutes
!==
undefined
&&
extra
.
codex_primary_window_minutes
<=
360
)
{
return
extra
.
codex_primary_used_percent
??
null
return
extra
.
codex_primary_used_percent
??
null
}
}
if
(
extra
.
codex_secondary_window_minutes
!==
undefined
&&
extra
.
codex_secondary_window_minutes
<=
360
)
{
if
(
extra
.
codex_secondary_window_minutes
!==
undefined
&&
extra
.
codex_secondary_window_minutes
<=
360
)
{
return
extra
.
codex_secondary_used_percent
??
null
return
extra
.
codex_secondary_used_percent
??
null
}
}
...
@@ -167,13 +175,19 @@ const codex5hResetAt = computed(() => {
...
@@ -167,13 +175,19 @@ const codex5hResetAt = computed(() => {
}
}
// Fallback: detect from legacy fields using window_minutes
// Fallback: detect from legacy fields using window_minutes
if
(
extra
.
codex_primary_window_minutes
!==
undefined
&&
extra
.
codex_primary_window_minutes
<=
360
)
{
if
(
extra
.
codex_primary_window_minutes
!==
undefined
&&
extra
.
codex_primary_window_minutes
<=
360
)
{
if
(
extra
.
codex_primary_reset_after_seconds
!==
undefined
)
{
if
(
extra
.
codex_primary_reset_after_seconds
!==
undefined
)
{
const
resetTime
=
new
Date
(
Date
.
now
()
+
extra
.
codex_primary_reset_after_seconds
*
1000
)
const
resetTime
=
new
Date
(
Date
.
now
()
+
extra
.
codex_primary_reset_after_seconds
*
1000
)
return
resetTime
.
toISOString
()
return
resetTime
.
toISOString
()
}
}
}
}
if
(
extra
.
codex_secondary_window_minutes
!==
undefined
&&
extra
.
codex_secondary_window_minutes
<=
360
)
{
if
(
extra
.
codex_secondary_window_minutes
!==
undefined
&&
extra
.
codex_secondary_window_minutes
<=
360
)
{
if
(
extra
.
codex_secondary_reset_after_seconds
!==
undefined
)
{
if
(
extra
.
codex_secondary_reset_after_seconds
!==
undefined
)
{
const
resetTime
=
new
Date
(
Date
.
now
()
+
extra
.
codex_secondary_reset_after_seconds
*
1000
)
const
resetTime
=
new
Date
(
Date
.
now
()
+
extra
.
codex_secondary_reset_after_seconds
*
1000
)
return
resetTime
.
toISOString
()
return
resetTime
.
toISOString
()
...
@@ -200,10 +214,16 @@ const codex7dUsedPercent = computed(() => {
...
@@ -200,10 +214,16 @@ const codex7dUsedPercent = computed(() => {
}
}
// Fallback: detect from legacy fields using window_minutes
// Fallback: detect from legacy fields using window_minutes
if
(
extra
.
codex_primary_window_minutes
!==
undefined
&&
extra
.
codex_primary_window_minutes
>=
10000
)
{
if
(
extra
.
codex_primary_window_minutes
!==
undefined
&&
extra
.
codex_primary_window_minutes
>=
10000
)
{
return
extra
.
codex_primary_used_percent
??
null
return
extra
.
codex_primary_used_percent
??
null
}
}
if
(
extra
.
codex_secondary_window_minutes
!==
undefined
&&
extra
.
codex_secondary_window_minutes
>=
10000
)
{
if
(
extra
.
codex_secondary_window_minutes
!==
undefined
&&
extra
.
codex_secondary_window_minutes
>=
10000
)
{
return
extra
.
codex_secondary_used_percent
??
null
return
extra
.
codex_secondary_used_percent
??
null
}
}
...
@@ -222,13 +242,19 @@ const codex7dResetAt = computed(() => {
...
@@ -222,13 +242,19 @@ const codex7dResetAt = computed(() => {
}
}
// Fallback: detect from legacy fields using window_minutes
// Fallback: detect from legacy fields using window_minutes
if
(
extra
.
codex_primary_window_minutes
!==
undefined
&&
extra
.
codex_primary_window_minutes
>=
10000
)
{
if
(
extra
.
codex_primary_window_minutes
!==
undefined
&&
extra
.
codex_primary_window_minutes
>=
10000
)
{
if
(
extra
.
codex_primary_reset_after_seconds
!==
undefined
)
{
if
(
extra
.
codex_primary_reset_after_seconds
!==
undefined
)
{
const
resetTime
=
new
Date
(
Date
.
now
()
+
extra
.
codex_primary_reset_after_seconds
*
1000
)
const
resetTime
=
new
Date
(
Date
.
now
()
+
extra
.
codex_primary_reset_after_seconds
*
1000
)
return
resetTime
.
toISOString
()
return
resetTime
.
toISOString
()
}
}
}
}
if
(
extra
.
codex_secondary_window_minutes
!==
undefined
&&
extra
.
codex_secondary_window_minutes
>=
10000
)
{
if
(
extra
.
codex_secondary_window_minutes
!==
undefined
&&
extra
.
codex_secondary_window_minutes
>=
10000
)
{
if
(
extra
.
codex_secondary_reset_after_seconds
!==
undefined
)
{
if
(
extra
.
codex_secondary_reset_after_seconds
!==
undefined
)
{
const
resetTime
=
new
Date
(
Date
.
now
()
+
extra
.
codex_secondary_reset_after_seconds
*
1000
)
const
resetTime
=
new
Date
(
Date
.
now
()
+
extra
.
codex_secondary_reset_after_seconds
*
1000
)
return
resetTime
.
toISOString
()
return
resetTime
.
toISOString
()
...
...
frontend/src/components/account/BulkEditAccountModal.vue
View file @
5deef27e
...
@@ -2,9 +2,9 @@
...
@@ -2,9 +2,9 @@
<Modal
:show=
"show"
:title=
"t('admin.accounts.bulkEdit.title')"
size=
"lg"
@
close=
"handleClose"
>
<Modal
:show=
"show"
:title=
"t('admin.accounts.bulkEdit.title')"
size=
"lg"
@
close=
"handleClose"
>
<form
class=
"space-y-5"
@
submit.prevent=
"handleSubmit"
>
<form
class=
"space-y-5"
@
submit.prevent=
"handleSubmit"
>
<!-- Info -->
<!-- Info -->
<div
class=
"rounded-lg bg-blue-50 dark:bg-blue-900/20
p-4
"
>
<div
class=
"rounded-lg bg-blue-50
p-4
dark:bg-blue-900/20"
>
<p
class=
"text-sm text-blue-700 dark:text-blue-400"
>
<p
class=
"text-sm text-blue-700 dark:text-blue-400"
>
<svg
class=
"
w-5 h-
5 inline
mr-1.
5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<svg
class=
"
mr-1.
5 inline
h-5 w-
5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
<path
stroke-linecap=
"round"
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-linejoin=
"round"
...
@@ -17,8 +17,8 @@
...
@@ -17,8 +17,8 @@
<
/div
>
<
/div
>
<!--
Base
URL
(
API
Key
only
)
-->
<!--
Base
URL
(
API
Key
only
)
-->
<
div
class
=
"
border-t border-gray-200 dark:border-dark-600
pt-4
"
>
<
div
class
=
"
border-t border-gray-200
pt-4
dark:border-dark-600
"
>
<
div
class
=
"
flex items-center justify-between
mb-3
"
>
<
div
class
=
"
mb-3
flex items-center justify-between
"
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.baseUrl
'
)
}}
<
/label
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.baseUrl
'
)
}}
<
/label
>
<
input
<
input
v
-
model
=
"
enableBaseUrl
"
v
-
model
=
"
enableBaseUrl
"
...
@@ -31,7 +31,7 @@
...
@@ -31,7 +31,7 @@
type
=
"
text
"
type
=
"
text
"
:
disabled
=
"
!enableBaseUrl
"
:
disabled
=
"
!enableBaseUrl
"
class
=
"
input
"
class
=
"
input
"
:
class
=
"
!enableBaseUrl && '
opacity-50
cursor-not-allowed'
"
:
class
=
"
!enableBaseUrl && 'cursor-not-allowed
opacity-50
'
"
:
placeholder
=
"
t('admin.accounts.bulkEdit.baseUrlPlaceholder')
"
:
placeholder
=
"
t('admin.accounts.bulkEdit.baseUrlPlaceholder')
"
/>
/>
<
p
class
=
"
input-hint
"
>
<
p
class
=
"
input-hint
"
>
...
@@ -40,8 +40,8 @@
...
@@ -40,8 +40,8 @@
<
/div
>
<
/div
>
<!--
Model
restriction
-->
<!--
Model
restriction
-->
<
div
class
=
"
border-t border-gray-200 dark:border-dark-600
pt-4
"
>
<
div
class
=
"
border-t border-gray-200
pt-4
dark:border-dark-600
"
>
<
div
class
=
"
flex items-center justify-between
mb-3
"
>
<
div
class
=
"
mb-3
flex items-center justify-between
"
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.modelRestriction
'
)
}}
<
/label
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.modelRestriction
'
)
}}
<
/label
>
<
input
<
input
v
-
model
=
"
enableModelRestriction
"
v
-
model
=
"
enableModelRestriction
"
...
@@ -50,21 +50,21 @@
...
@@ -50,21 +50,21 @@
/>
/>
<
/div
>
<
/div
>
<
div
:
class
=
"
!enableModelRestriction && '
opacity-50
pointer-events-none'
"
>
<
div
:
class
=
"
!enableModelRestriction && 'pointer-events-none
opacity-50
'
"
>
<!--
Mode
Toggle
-->
<!--
Mode
Toggle
-->
<
div
class
=
"
flex gap-2
mb-4
"
>
<
div
class
=
"
mb-4
flex gap-2
"
>
<
button
<
button
type
=
"
button
"
type
=
"
button
"
:
class
=
"
[
:
class
=
"
[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'whitelist'
modelRestrictionMode === 'whitelist'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
,
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]
"
]
"
@
click
=
"
modelRestrictionMode = 'whitelist'
"
@
click
=
"
modelRestrictionMode = 'whitelist'
"
>
>
<
svg
<
svg
class
=
"
w-4 h-4
inline
mr-1.5
"
class
=
"
mr-1.5
inline
h-4 w-4
"
fill
=
"
none
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
=
"
currentColor
"
...
@@ -84,12 +84,12 @@
...
@@ -84,12 +84,12 @@
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'mapping'
modelRestrictionMode === 'mapping'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
,
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]
"
]
"
@
click
=
"
modelRestrictionMode = 'mapping'
"
@
click
=
"
modelRestrictionMode = 'mapping'
"
>
>
<
svg
<
svg
class
=
"
w-4 h-4
inline
mr-1.5
"
class
=
"
mr-1.5
inline
h-4 w-4
"
fill
=
"
none
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
=
"
currentColor
"
...
@@ -107,10 +107,10 @@
...
@@ -107,10 +107,10 @@
<!--
Whitelist
Mode
-->
<!--
Whitelist
Mode
-->
<
div
v
-
if
=
"
modelRestrictionMode === 'whitelist'
"
>
<
div
v
-
if
=
"
modelRestrictionMode === 'whitelist'
"
>
<
div
class
=
"
mb-3 rounded-lg bg-blue-50 dark:bg-blue-900/20
p-3
"
>
<
div
class
=
"
mb-3 rounded-lg bg-blue-50
p-3
dark:bg-blue-900/20
"
>
<
p
class
=
"
text-xs text-blue-700 dark:text-blue-400
"
>
<
p
class
=
"
text-xs text-blue-700 dark:text-blue-400
"
>
<
svg
<
svg
class
=
"
w-4 h-4
inline
mr-1
"
class
=
"
mr-1
inline
h-4 w-4
"
fill
=
"
none
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
=
"
currentColor
"
...
@@ -127,7 +127,7 @@
...
@@ -127,7 +127,7 @@
<
/div
>
<
/div
>
<!--
Model
Checkbox
List
-->
<!--
Model
Checkbox
List
-->
<
div
class
=
"
grid grid-cols-2 gap-2
mb-3
"
>
<
div
class
=
"
mb-3
grid grid-cols-2 gap-2
"
>
<
label
<
label
v
-
for
=
"
model in allModels
"
v
-
for
=
"
model in allModels
"
:
key
=
"
model.value
"
:
key
=
"
model.value
"
...
@@ -158,10 +158,10 @@
...
@@ -158,10 +158,10 @@
<!--
Mapping
Mode
-->
<!--
Mapping
Mode
-->
<
div
v
-
else
>
<
div
v
-
else
>
<
div
class
=
"
mb-3 rounded-lg bg-purple-50 dark:bg-purple-900/20
p-3
"
>
<
div
class
=
"
mb-3 rounded-lg bg-purple-50
p-3
dark:bg-purple-900/20
"
>
<
p
class
=
"
text-xs text-purple-700 dark:text-purple-400
"
>
<
p
class
=
"
text-xs text-purple-700 dark:text-purple-400
"
>
<
svg
<
svg
class
=
"
w-4 h-4
inline
mr-1
"
class
=
"
mr-1
inline
h-4 w-4
"
fill
=
"
none
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
=
"
currentColor
"
...
@@ -178,7 +178,7 @@
...
@@ -178,7 +178,7 @@
<
/div
>
<
/div
>
<!--
Model
Mapping
List
-->
<!--
Model
Mapping
List
-->
<
div
v
-
if
=
"
modelMappings.length > 0
"
class
=
"
space-y-2
mb-3
"
>
<
div
v
-
if
=
"
modelMappings.length > 0
"
class
=
"
mb-3
space-y-2
"
>
<
div
<
div
v
-
for
=
"
(mapping, index) in modelMappings
"
v
-
for
=
"
(mapping, index) in modelMappings
"
:
key
=
"
index
"
:
key
=
"
index
"
...
@@ -191,7 +191,7 @@
...
@@ -191,7 +191,7 @@
:
placeholder
=
"
t('admin.accounts.requestModel')
"
:
placeholder
=
"
t('admin.accounts.requestModel')
"
/>
/>
<
svg
<
svg
class
=
"
w-4 h-4 text-gray-400 flex-shrink-
0
"
class
=
"
h-4 w-4 flex-shrink-0 text-gray-40
0
"
fill
=
"
none
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
=
"
currentColor
"
...
@@ -211,10 +211,10 @@
...
@@ -211,10 +211,10 @@
/>
/>
<
button
<
button
type
=
"
button
"
type
=
"
button
"
class
=
"
p-2 text-red-500 hover:
text
-red-
60
0 hover:
bg
-red-
5
0 dark:hover:bg-red-900/20
rounded-lg transition-colors
"
class
=
"
rounded-lg
p-2 text-red-500
transition-colors
hover:
bg
-red-
5
0 hover:
text
-red-
60
0 dark:hover:bg-red-900/20
"
@
click
=
"
removeModelMapping(index)
"
@
click
=
"
removeModelMapping(index)
"
>
>
<
svg
class
=
"
w
-4
h
-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
svg
class
=
"
h
-4
w
-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
linejoin
=
"
round
"
...
@@ -228,11 +228,11 @@
...
@@ -228,11 +228,11 @@
<
button
<
button
type
=
"
button
"
type
=
"
button
"
class
=
"
w-full rounded-lg border-2 border-dashed border-gray-300
dark:border-dark-500
px-4 py-2 text-gray-600
dark:text-gray-400
transition-colors hover:border-gray-400 hover:text-gray-700 dark:hover:border-dark-400 dark:hover:text-gray-300
mb-3
"
class
=
"
mb-3
w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700
dark:border-dark-500 dark:text-gray-400
dark:hover:border-dark-400 dark:hover:text-gray-300
"
@
click
=
"
addModelMapping
"
@
click
=
"
addModelMapping
"
>
>
<
svg
<
svg
class
=
"
w-4 h-4
inline
mr-1
"
class
=
"
mr-1
inline
h-4 w-4
"
fill
=
"
none
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
=
"
currentColor
"
...
@@ -264,11 +264,11 @@
...
@@ -264,11 +264,11 @@
<
/div
>
<
/div
>
<!--
Custom
error
codes
-->
<!--
Custom
error
codes
-->
<
div
class
=
"
border-t border-gray-200 dark:border-dark-600
pt-4
"
>
<
div
class
=
"
border-t border-gray-200
pt-4
dark:border-dark-600
"
>
<
div
class
=
"
flex items-center justify-between
mb-3
"
>
<
div
class
=
"
mb-3
flex items-center justify-between
"
>
<
div
>
<
div
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.customErrorCodes
'
)
}}
<
/label
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.customErrorCodes
'
)
}}
<
/label
>
<
p
class
=
"
text-xs text-gray-500 dark:text-gray-400
mt-1
"
>
<
p
class
=
"
mt-1
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.customErrorCodesHint
'
)
}}
{{
t
(
'
admin.accounts.customErrorCodesHint
'
)
}}
<
/p
>
<
/p
>
<
/div
>
<
/div
>
...
@@ -280,10 +280,10 @@
...
@@ -280,10 +280,10 @@
<
/div
>
<
/div
>
<
div
v
-
if
=
"
enableCustomErrorCodes
"
class
=
"
space-y-3
"
>
<
div
v
-
if
=
"
enableCustomErrorCodes
"
class
=
"
space-y-3
"
>
<
div
class
=
"
rounded-lg bg-amber-50 dark:bg-amber-900/20
p-3
"
>
<
div
class
=
"
rounded-lg bg-amber-50
p-3
dark:bg-amber-900/20
"
>
<
p
class
=
"
text-xs text-amber-700 dark:text-amber-400
"
>
<
p
class
=
"
text-xs text-amber-700 dark:text-amber-400
"
>
<
svg
<
svg
class
=
"
w-4 h-4
inline
mr-1
"
class
=
"
mr-1
inline
h-4 w-4
"
fill
=
"
none
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
stroke
=
"
currentColor
"
...
@@ -308,8 +308,8 @@
...
@@ -308,8 +308,8 @@
:
class
=
"
[
:
class
=
"
[
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
selectedErrorCodes.includes(code.value)
selectedErrorCodes.includes(code.value)
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400
ring-1 ring-red-500
'
? 'bg-red-100 text-red-700
ring-1 ring-red-500
dark:bg-red-900/30 dark:text-red-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
,
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]
"
]
"
@
click
=
"
toggleErrorCode(code.value)
"
@
click
=
"
toggleErrorCode(code.value)
"
>
>
...
@@ -329,7 +329,7 @@
...
@@ -329,7 +329,7 @@
@
keyup
.
enter
=
"
addCustomErrorCode
"
@
keyup
.
enter
=
"
addCustomErrorCode
"
/>
/>
<
button
type
=
"
button
"
class
=
"
btn btn-secondary px-3
"
@
click
=
"
addCustomErrorCode
"
>
<
button
type
=
"
button
"
class
=
"
btn btn-secondary px-3
"
@
click
=
"
addCustomErrorCode
"
>
<
svg
class
=
"
w
-4
h
-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
svg
class
=
"
h
-4
w
-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
linejoin
=
"
round
"
...
@@ -345,7 +345,7 @@
...
@@ -345,7 +345,7 @@
<
span
<
span
v
-
for
=
"
code in selectedErrorCodes.sort((a, b) => a - b)
"
v
-
for
=
"
code in selectedErrorCodes.sort((a, b) => a - b)
"
:
key
=
"
code
"
:
key
=
"
code
"
class
=
"
inline-flex items-center gap-1 rounded-full bg-red-100
dark:bg-red-900/30
px-2.5 py-0.5 text-sm font-medium text-red-700 dark:text-red-400
"
class
=
"
inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-0.5 text-sm font-medium text-red-700
dark:bg-red-900/30
dark:text-red-400
"
>
>
{{
code
}}
{{
code
}}
<
button
<
button
...
@@ -353,7 +353,7 @@
...
@@ -353,7 +353,7 @@
class
=
"
hover:text-red-900 dark:hover:text-red-300
"
class
=
"
hover:text-red-900 dark:hover:text-red-300
"
@
click
=
"
removeErrorCode(code)
"
@
click
=
"
removeErrorCode(code)
"
>
>
<
svg
class
=
"
w
-3.5
h
-3.5
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
svg
class
=
"
h
-3.5
w
-3.5
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
linejoin
=
"
round
"
...
@@ -371,13 +371,13 @@
...
@@ -371,13 +371,13 @@
<
/div
>
<
/div
>
<!--
Intercept
warmup
requests
(
Anthropic
only
)
-->
<!--
Intercept
warmup
requests
(
Anthropic
only
)
-->
<
div
class
=
"
border-t border-gray-200 dark:border-dark-600
pt-4
"
>
<
div
class
=
"
border-t border-gray-200
pt-4
dark:border-dark-600
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
div
class
=
"
flex-1 pr-4
"
>
<
div
class
=
"
flex-1 pr-4
"
>
<
label
class
=
"
input-label mb-0
"
>
{{
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.interceptWarmupRequests
'
)
t
(
'
admin.accounts.interceptWarmupRequests
'
)
}}
<
/label
>
}}
<
/label
>
<
p
class
=
"
text-xs text-gray-500 dark:text-gray-400
mt-1
"
>
<
p
class
=
"
mt-1
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.interceptWarmupRequestsDesc
'
)
}}
{{
t
(
'
admin.accounts.interceptWarmupRequestsDesc
'
)
}}
<
/p
>
<
/p
>
<
/div
>
<
/div
>
...
@@ -392,14 +392,14 @@
...
@@ -392,14 +392,14 @@
type
=
"
button
"
type
=
"
button
"
:
class
=
"
[
:
class
=
"
[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
interceptWarmupRequests ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
,
interceptWarmupRequests ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]
"
]
"
@
click
=
"
interceptWarmupRequests = !interceptWarmupRequests
"
@
click
=
"
interceptWarmupRequests = !interceptWarmupRequests
"
>
>
<
span
<
span
:
class
=
"
[
:
class
=
"
[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
interceptWarmupRequests ? 'translate-x-5' : 'translate-x-0'
,
interceptWarmupRequests ? 'translate-x-5' : 'translate-x-0'
]
"
]
"
/>
/>
<
/button
>
<
/button
>
...
@@ -407,8 +407,8 @@
...
@@ -407,8 +407,8 @@
<
/div
>
<
/div
>
<!--
Proxy
-->
<!--
Proxy
-->
<
div
class
=
"
border-t border-gray-200 dark:border-dark-600
pt-4
"
>
<
div
class
=
"
border-t border-gray-200
pt-4
dark:border-dark-600
"
>
<
div
class
=
"
flex items-center justify-between
mb-3
"
>
<
div
class
=
"
mb-3
flex items-center justify-between
"
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.proxy
'
)
}}
<
/label
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.proxy
'
)
}}
<
/label
>
<
input
<
input
v
-
model
=
"
enableProxy
"
v
-
model
=
"
enableProxy
"
...
@@ -416,15 +416,15 @@
...
@@ -416,15 +416,15 @@
class
=
"
rounded border-gray-300 text-primary-600 focus:ring-primary-500
"
class
=
"
rounded border-gray-300 text-primary-600 focus:ring-primary-500
"
/>
/>
<
/div
>
<
/div
>
<
div
:
class
=
"
!enableProxy && '
opacity-50
pointer-events-none'
"
>
<
div
:
class
=
"
!enableProxy && 'pointer-events-none
opacity-50
'
"
>
<
ProxySelector
v
-
model
=
"
proxyId
"
:
proxies
=
"
proxies
"
/>
<
ProxySelector
v
-
model
=
"
proxyId
"
:
proxies
=
"
proxies
"
/>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
Concurrency
&
Priority
-->
<!--
Concurrency
&
Priority
-->
<
div
class
=
"
grid grid-cols-2 gap-4 border-t border-gray-200 dark:border-dark-600
pt-4
"
>
<
div
class
=
"
grid grid-cols-2 gap-4 border-t border-gray-200
pt-4
dark:border-dark-600
"
>
<
div
>
<
div
>
<
div
class
=
"
flex items-center justify-between
mb-3
"
>
<
div
class
=
"
mb-3
flex items-center justify-between
"
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.concurrency
'
)
}}
<
/label
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.concurrency
'
)
}}
<
/label
>
<
input
<
input
v
-
model
=
"
enableConcurrency
"
v
-
model
=
"
enableConcurrency
"
...
@@ -438,11 +438,11 @@
...
@@ -438,11 +438,11 @@
min
=
"
1
"
min
=
"
1
"
:
disabled
=
"
!enableConcurrency
"
:
disabled
=
"
!enableConcurrency
"
class
=
"
input
"
class
=
"
input
"
:
class
=
"
!enableConcurrency && '
opacity-50
cursor-not-allowed'
"
:
class
=
"
!enableConcurrency && 'cursor-not-allowed
opacity-50
'
"
/>
/>
<
/div
>
<
/div
>
<
div
>
<
div
>
<
div
class
=
"
flex items-center justify-between
mb-3
"
>
<
div
class
=
"
mb-3
flex items-center justify-between
"
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.priority
'
)
}}
<
/label
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.priority
'
)
}}
<
/label
>
<
input
<
input
v
-
model
=
"
enablePriority
"
v
-
model
=
"
enablePriority
"
...
@@ -456,14 +456,14 @@
...
@@ -456,14 +456,14 @@
min
=
"
1
"
min
=
"
1
"
:
disabled
=
"
!enablePriority
"
:
disabled
=
"
!enablePriority
"
class
=
"
input
"
class
=
"
input
"
:
class
=
"
!enablePriority && '
opacity-50
cursor-not-allowed'
"
:
class
=
"
!enablePriority && 'cursor-not-allowed
opacity-50
'
"
/>
/>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
Status
-->
<!--
Status
-->
<
div
class
=
"
border-t border-gray-200 dark:border-dark-600
pt-4
"
>
<
div
class
=
"
border-t border-gray-200
pt-4
dark:border-dark-600
"
>
<
div
class
=
"
flex items-center justify-between
mb-3
"
>
<
div
class
=
"
mb-3
flex items-center justify-between
"
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
common.status
'
)
}}
<
/label
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
common.status
'
)
}}
<
/label
>
<
input
<
input
v
-
model
=
"
enableStatus
"
v
-
model
=
"
enableStatus
"
...
@@ -471,14 +471,14 @@
...
@@ -471,14 +471,14 @@
class
=
"
rounded border-gray-300 text-primary-600 focus:ring-primary-500
"
class
=
"
rounded border-gray-300 text-primary-600 focus:ring-primary-500
"
/>
/>
<
/div
>
<
/div
>
<
div
:
class
=
"
!enableStatus && '
opacity-50
pointer-events-none'
"
>
<
div
:
class
=
"
!enableStatus && 'pointer-events-none
opacity-50
'
"
>
<
Select
v
-
model
=
"
status
"
:
options
=
"
statusOptions
"
/>
<
Select
v
-
model
=
"
status
"
:
options
=
"
statusOptions
"
/>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
Groups
-->
<!--
Groups
-->
<
div
class
=
"
border-t border-gray-200 dark:border-dark-600
pt-4
"
>
<
div
class
=
"
border-t border-gray-200
pt-4
dark:border-dark-600
"
>
<
div
class
=
"
flex items-center justify-between
mb-3
"
>
<
div
class
=
"
mb-3
flex items-center justify-between
"
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
nav.groups
'
)
}}
<
/label
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
nav.groups
'
)
}}
<
/label
>
<
input
<
input
v
-
model
=
"
enableGroups
"
v
-
model
=
"
enableGroups
"
...
@@ -486,7 +486,7 @@
...
@@ -486,7 +486,7 @@
class
=
"
rounded border-gray-300 text-primary-600 focus:ring-primary-500
"
class
=
"
rounded border-gray-300 text-primary-600 focus:ring-primary-500
"
/>
/>
<
/div
>
<
/div
>
<
div
:
class
=
"
!enableGroups && '
opacity-50
pointer-events-none'
"
>
<
div
:
class
=
"
!enableGroups && 'pointer-events-none
opacity-50
'
"
>
<
GroupSelector
v
-
model
=
"
groupIds
"
:
groups
=
"
groups
"
/>
<
GroupSelector
v
-
model
=
"
groupIds
"
:
groups
=
"
groups
"
/>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
...
@@ -499,7 +499,7 @@
...
@@ -499,7 +499,7 @@
<
button
type
=
"
submit
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
>
<
button
type
=
"
submit
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
>
<
svg
<
svg
v
-
if
=
"
submitting
"
v
-
if
=
"
submitting
"
class
=
"
animate-spin
-ml-1 mr-2 h-4 w-4
"
class
=
"
-ml-1 mr-2 h-4 w-4
animate-spin
"
fill
=
"
none
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
viewBox
=
"
0 0 24 24
"
>
>
...
@@ -601,7 +601,7 @@ const allModels = [
...
@@ -601,7 +601,7 @@ const allModels = [
{
value
:
'
gpt-5.1-codex
'
,
label
:
'
GPT-5.1 Codex
'
}
,
{
value
:
'
gpt-5.1-codex
'
,
label
:
'
GPT-5.1 Codex
'
}
,
{
value
:
'
gpt-5.1-2025-11-13
'
,
label
:
'
GPT-5.1
'
}
,
{
value
:
'
gpt-5.1-2025-11-13
'
,
label
:
'
GPT-5.1
'
}
,
{
value
:
'
gpt-5.1-codex-mini
'
,
label
:
'
GPT-5.1 Codex Mini
'
}
,
{
value
:
'
gpt-5.1-codex-mini
'
,
label
:
'
GPT-5.1 Codex Mini
'
}
,
{
value
:
'
gpt-5-2025-08-07
'
,
label
:
'
GPT-5
'
}
,
{
value
:
'
gpt-5-2025-08-07
'
,
label
:
'
GPT-5
'
}
]
]
// Preset mappings (combined Anthropic + OpenAI)
// Preset mappings (combined Anthropic + OpenAI)
...
@@ -610,48 +610,46 @@ const presetMappings = [
...
@@ -610,48 +610,46 @@ const presetMappings = [
label
:
'
Sonnet 4
'
,
label
:
'
Sonnet 4
'
,
from
:
'
claude-sonnet-4-20250514
'
,
from
:
'
claude-sonnet-4-20250514
'
,
to
:
'
claude-sonnet-4-20250514
'
,
to
:
'
claude-sonnet-4-20250514
'
,
color
:
'
bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400
'
,
color
:
'
bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400
'
}
,
}
,
{
{
label
:
'
Sonnet 4.5
'
,
label
:
'
Sonnet 4.5
'
,
from
:
'
claude-sonnet-4-5-20250929
'
,
from
:
'
claude-sonnet-4-5-20250929
'
,
to
:
'
claude-sonnet-4-5-20250929
'
,
to
:
'
claude-sonnet-4-5-20250929
'
,
color
:
color
:
'
bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400
'
,
'
bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400
'
}
,
}
,
{
{
label
:
'
Opus 4.5
'
,
label
:
'
Opus 4.5
'
,
from
:
'
claude-opus-4-5-20251101
'
,
from
:
'
claude-opus-4-5-20251101
'
,
to
:
'
claude-opus-4-5-20251101
'
,
to
:
'
claude-opus-4-5-20251101
'
,
color
:
color
:
'
bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400
'
,
'
bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400
'
}
,
}
,
{
{
label
:
'
Opus->Sonnet
'
,
label
:
'
Opus->Sonnet
'
,
from
:
'
claude-opus-4-5-20251101
'
,
from
:
'
claude-opus-4-5-20251101
'
,
to
:
'
claude-sonnet-4-5-20250929
'
,
to
:
'
claude-sonnet-4-5-20250929
'
,
color
:
color
:
'
bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400
'
'
bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400
'
,
}
,
}
,
{
{
label
:
'
GPT-5.2
'
,
label
:
'
GPT-5.2
'
,
from
:
'
gpt-5.2-2025-12-11
'
,
from
:
'
gpt-5.2-2025-12-11
'
,
to
:
'
gpt-5.2-2025-12-11
'
,
to
:
'
gpt-5.2-2025-12-11
'
,
color
:
color
:
'
bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400
'
'
bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400
'
,
}
,
}
,
{
{
label
:
'
GPT-5.2 Codex
'
,
label
:
'
GPT-5.2 Codex
'
,
from
:
'
gpt-5.2-codex
'
,
from
:
'
gpt-5.2-codex
'
,
to
:
'
gpt-5.2-codex
'
,
to
:
'
gpt-5.2-codex
'
,
color
:
'
bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400
'
,
color
:
'
bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400
'
}
,
}
,
{
{
label
:
'
Max->Codex
'
,
label
:
'
Max->Codex
'
,
from
:
'
gpt-5.1-codex-max
'
,
from
:
'
gpt-5.1-codex-max
'
,
to
:
'
gpt-5.1-codex
'
,
to
:
'
gpt-5.1-codex
'
,
color
:
'
bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400
'
,
color
:
'
bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400
'
}
,
}
]
]
// Common HTTP error codes
// Common HTTP error codes
...
@@ -662,12 +660,12 @@ const commonErrorCodes = [
...
@@ -662,12 +660,12 @@ const commonErrorCodes = [
{
value
:
500
,
label
:
'
Server Error
'
}
,
{
value
:
500
,
label
:
'
Server Error
'
}
,
{
value
:
502
,
label
:
'
Bad Gateway
'
}
,
{
value
:
502
,
label
:
'
Bad Gateway
'
}
,
{
value
:
503
,
label
:
'
Unavailable
'
}
,
{
value
:
503
,
label
:
'
Unavailable
'
}
,
{
value
:
529
,
label
:
'
Overloaded
'
}
,
{
value
:
529
,
label
:
'
Overloaded
'
}
]
]
const
statusOptions
=
computed
(()
=>
[
const
statusOptions
=
computed
(()
=>
[
{
value
:
'
active
'
,
label
:
t
(
'
common.active
'
)
}
,
{
value
:
'
active
'
,
label
:
t
(
'
common.active
'
)
}
,
{
value
:
'
inactive
'
,
label
:
t
(
'
common.inactive
'
)
}
,
{
value
:
'
inactive
'
,
label
:
t
(
'
common.inactive
'
)
}
])
])
// Model mapping helpers
// Model mapping helpers
...
...
frontend/src/components/account/SyncFromCrsModal.vue
View file @
5deef27e
...
@@ -10,10 +10,15 @@
...
@@ -10,10 +10,15 @@
<div
class=
"text-sm text-gray-600 dark:text-dark-300"
>
<div
class=
"text-sm text-gray-600 dark:text-dark-300"
>
{{
t
(
'
admin.accounts.syncFromCrsDesc
'
)
}}
{{
t
(
'
admin.accounts.syncFromCrsDesc
'
)
}}
</div>
</div>
<div
class=
"text-xs text-gray-500 dark:text-dark-400 bg-gray-50 dark:bg-dark-700/60 rounded-lg p-3"
>
<div
已有账号仅同步 CRS 返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。
class=
"rounded-lg bg-gray-50 p-3 text-xs text-gray-500 dark:bg-dark-700/60 dark:text-dark-400"
>
已有账号仅同步 CRS
返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。
</div>
</div>
<div
class=
"text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3"
>
<div
class=
"rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
>
{{
t
(
'
admin.accounts.crsVersionRequirement
'
)
}}
{{
t
(
'
admin.accounts.crsVersionRequirement
'
)
}}
</div>
</div>
...
@@ -31,12 +36,7 @@
...
@@ -31,12 +36,7 @@
<div
class=
"grid grid-cols-1 gap-4 sm:grid-cols-2"
>
<div
class=
"grid grid-cols-1 gap-4 sm:grid-cols-2"
>
<div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.crsUsername
'
)
}}
</label>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.crsUsername
'
)
}}
</label>
<input
<input
v-model=
"form.username"
type=
"text"
class=
"input"
autocomplete=
"username"
/>
v-model=
"form.username"
type=
"text"
class=
"input"
autocomplete=
"username"
/>
</div>
</div>
<div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.crsPassword
'
)
}}
</label>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.crsPassword
'
)
}}
</label>
...
@@ -50,12 +50,19 @@
...
@@ -50,12 +50,19 @@
</div>
</div>
<label
class=
"flex items-center gap-2 text-sm text-gray-700 dark:text-dark-300"
>
<label
class=
"flex items-center gap-2 text-sm text-gray-700 dark:text-dark-300"
>
<input
v-model=
"form.sync_proxies"
type=
"checkbox"
class=
"rounded border-gray-300 dark:border-dark-600"
/>
<input
v-model=
"form.sync_proxies"
type=
"checkbox"
class=
"rounded border-gray-300 dark:border-dark-600"
/>
{{
t
(
'
admin.accounts.syncProxies
'
)
}}
{{
t
(
'
admin.accounts.syncProxies
'
)
}}
</label>
</label>
</div>
</div>
<div
v-if=
"result"
class=
"rounded-xl border border-gray-200 dark:border-dark-700 p-4 space-y-2"
>
<div
v-if=
"result"
class=
"space-y-2 rounded-xl border border-gray-200 p-4 dark:border-dark-700"
>
<div
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
<div
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
admin.accounts.syncResult
'
)
}}
{{
t
(
'
admin.accounts.syncResult
'
)
}}
</div>
</div>
...
@@ -67,9 +74,12 @@
...
@@ -67,9 +74,12 @@
<div
class=
"text-sm font-medium text-red-600 dark:text-red-400"
>
<div
class=
"text-sm font-medium text-red-600 dark:text-red-400"
>
{{
t
(
'
admin.accounts.syncErrors
'
)
}}
{{
t
(
'
admin.accounts.syncErrors
'
)
}}
</div>
</div>
<div
class=
"mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 dark:bg-dark-800 p-3 text-xs font-mono"
>
<div
class=
"mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 p-3 font-mono text-xs dark:bg-dark-800"
>
<div
v-for=
"(item, idx) in errorItems"
:key=
"idx"
class=
"whitespace-pre-wrap"
>
<div
v-for=
"(item, idx) in errorItems"
:key=
"idx"
class=
"whitespace-pre-wrap"
>
{{
item
.
kind
}}
{{
item
.
crs_account_id
}}
—
{{
item
.
action
}}{{
item
.
error
?
`: ${item.error
}
`
:
''
}}
{{
item
.
kind
}}
{{
item
.
crs_account_id
}}
—
{{
item
.
action
}}{{
item
.
error
?
`: ${item.error
}
`
:
''
}}
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
...
...
frontend/src/components/account/UsageProgressBar.vue
View file @
5deef27e
<
template
>
<
template
>
<div>
<div>
<!-- Window stats row (above progress bar, left-right aligned with progress bar) -->
<!-- Window stats row (above progress bar, left-right aligned with progress bar) -->
<div
v-if=
"windowStats"
class=
"flex items-center justify-between mb-0.5"
:title=
"`5h 窗口用量统计`"
>
<div
<div
class=
"flex items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400 cursor-help"
>
v-if=
"windowStats"
<span
class=
"px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800"
>
class=
"mb-0.5 flex items-center justify-between"
:title=
"`5h 窗口用量统计`"
>
<div
class=
"flex cursor-help items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400"
>
<span
class=
"rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
>
{{
formatRequests
}}
req
{{
formatRequests
}}
req
</span>
</span>
<span
class=
"
px-1.5 py-0.5
rounded bg-gray-100 dark:bg-gray-800"
>
<span
class=
"rounded bg-gray-100
px-1.5 py-0.5
dark:bg-gray-800"
>
{{
formatTokens
}}
{{
formatTokens
}}
</span>
</span>
<span
class=
"px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800"
>
<span
class=
"rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
>
$
{{
formatCost
}}
</span>
$
{{
formatCost
}}
</span>
</div>
</div>
</div>
</div>
...
@@ -19,16 +23,13 @@
...
@@ -19,16 +23,13 @@
<div
class=
"flex items-center gap-1"
>
<div
class=
"flex items-center gap-1"
>
<!-- Label badge (fixed width for alignment) -->
<!-- Label badge (fixed width for alignment) -->
<span
<span
:class=
"[
:class=
"['w-[32px] shrink-0 rounded px-1 text-center text-[10px] font-medium', labelClass]"
'text-[10px] font-medium px-1 rounded w-[32px] text-center shrink-0',
labelClass
]"
>
>
{{
label
}}
{{
label
}}
</span>
</span>
<!-- Progress bar container -->
<!-- Progress bar container -->
<div
class=
"
w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden shrink-
0"
>
<div
class=
"
h-1.5 w-8 shrink-0 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-70
0"
>
<div
<div
:class=
"['h-full transition-all duration-300', barClass]"
:class=
"['h-full transition-all duration-300', barClass]"
:style=
"
{ width: barWidth }"
:style=
"
{ width: barWidth }"
...
@@ -36,12 +37,12 @@
...
@@ -36,12 +37,12 @@
</div>
</div>
<!-- Percentage -->
<!-- Percentage -->
<span
:class=
"['
text-[10px] font-medium w-[32px] text-right shrink-0
', textClass]"
>
<span
:class=
"['
w-[32px] shrink-0 text-right text-[10px] font-medium
', textClass]"
>
{{
displayPercent
}}
{{
displayPercent
}}
</span>
</span>
<!-- Reset time -->
<!-- Reset time -->
<span
v-if=
"resetsAt"
class=
"text-[10px] text-gray-400
shrink-0
"
>
<span
v-if=
"resetsAt"
class=
"
shrink-0
text-[10px] text-gray-400"
>
{{
formatResetTime
}}
{{
formatResetTime
}}
</span>
</span>
</div>
</div>
...
@@ -54,7 +55,7 @@ import type { WindowStats } from '@/types'
...
@@ -54,7 +55,7 @@ import type { WindowStats } from '@/types'
const
props
=
defineProps
<
{
const
props
=
defineProps
<
{
label
:
string
label
:
string
utilization
:
number
// Percentage (0-100+)
utilization
:
number
// Percentage (0-100+)
resetsAt
?:
string
|
null
resetsAt
?:
string
|
null
color
:
'
indigo
'
|
'
emerald
'
|
'
purple
'
color
:
'
indigo
'
|
'
emerald
'
|
'
purple
'
windowStats
?:
WindowStats
|
null
windowStats
?:
WindowStats
|
null
...
...
frontend/src/components/charts/ModelDistributionChart.vue
View file @
5deef27e
<
template
>
<
template
>
<div
class=
"card p-4"
>
<div
class=
"card p-4"
>
<h3
class=
"text-sm font-semibold text-gray-900 dark:text-white mb-4"
>
{{
t
(
'
admin.dashboard.modelDistribution
'
)
}}
</h3>
<h3
class=
"mb-4 text-sm font-semibold text-gray-900 dark:text-white"
>
<div
v-if=
"loading"
class=
"flex items-center justify-center h-48"
>
{{
t
(
'
admin.dashboard.modelDistribution
'
)
}}
</h3>
<div
v-if=
"loading"
class=
"flex h-48 items-center justify-center"
>
<LoadingSpinner
/>
<LoadingSpinner
/>
</div>
</div>
<div
v-else-if=
"modelStats.length > 0 && chartData"
class=
"flex items-center gap-6"
>
<div
v-else-if=
"modelStats.length > 0 && chartData"
class=
"flex items-center gap-6"
>
<div
class=
"
w
-48
h
-48"
>
<div
class=
"
h
-48
w
-48"
>
<Doughnut
:data=
"chartData"
:options=
"doughnutOptions"
/>
<Doughnut
:data=
"chartData"
:options=
"doughnutOptions"
/>
</div>
</div>
<div
class=
"
flex-1
max-h-48 overflow-y-auto"
>
<div
class=
"max-h-48
flex-1
overflow-y-auto"
>
<table
class=
"w-full text-xs"
>
<table
class=
"w-full text-xs"
>
<thead>
<thead>
<tr
class=
"text-gray-500 dark:text-gray-400"
>
<tr
class=
"text-gray-500 dark:text-gray-400"
>
<th
class=
"text-left
pb-2
"
>
{{
t
(
'
admin.dashboard.model
'
)
}}
</th>
<th
class=
"
pb-2
text-left"
>
{{
t
(
'
admin.dashboard.model
'
)
}}
</th>
<th
class=
"text-right
pb-2
"
>
{{
t
(
'
admin.dashboard.requests
'
)
}}
</th>
<th
class=
"
pb-2
text-right"
>
{{
t
(
'
admin.dashboard.requests
'
)
}}
</th>
<th
class=
"text-right
pb-2
"
>
{{
t
(
'
admin.dashboard.tokens
'
)
}}
</th>
<th
class=
"
pb-2
text-right"
>
{{
t
(
'
admin.dashboard.tokens
'
)
}}
</th>
<th
class=
"text-right
pb-2
"
>
{{
t
(
'
admin.dashboard.actual
'
)
}}
</th>
<th
class=
"
pb-2
text-right"
>
{{
t
(
'
admin.dashboard.actual
'
)
}}
</th>
<th
class=
"text-right
pb-2
"
>
{{
t
(
'
admin.dashboard.standard
'
)
}}
</th>
<th
class=
"
pb-2
text-right"
>
{{
t
(
'
admin.dashboard.standard
'
)
}}
</th>
</tr>
</tr>
</thead>
</thead>
<tbody>
<tbody>
<tr
v-for=
"model in modelStats"
:key=
"model.model"
class=
"border-t border-gray-100 dark:border-gray-700"
>
<tr
<td
class=
"py-1.5 text-gray-900 dark:text-white font-medium truncate max-w-[100px]"
:title=
"model.model"
>
{{
model
.
model
}}
</td>
v-for=
"model in modelStats"
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
{{
formatNumber
(
model
.
requests
)
}}
</td>
:key=
"model.model"
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
{{
formatTokens
(
model
.
total_tokens
)
}}
</td>
class=
"border-t border-gray-100 dark:border-gray-700"
<td
class=
"py-1.5 text-right text-green-600 dark:text-green-400"
>
$
{{
formatCost
(
model
.
actual_cost
)
}}
</td>
>
<td
class=
"py-1.5 text-right text-gray-400 dark:text-gray-500"
>
$
{{
formatCost
(
model
.
cost
)
}}
</td>
<td
class=
"max-w-[100px] truncate py-1.5 font-medium text-gray-900 dark:text-white"
:title=
"model.model"
>
{{
model
.
model
}}
</td>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
{{
formatNumber
(
model
.
requests
)
}}
</td>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
{{
formatTokens
(
model
.
total_tokens
)
}}
</td>
<td
class=
"py-1.5 text-right text-green-600 dark:text-green-400"
>
$
{{
formatCost
(
model
.
actual_cost
)
}}
</td>
<td
class=
"py-1.5 text-right text-gray-400 dark:text-gray-500"
>
$
{{
formatCost
(
model
.
cost
)
}}
</td>
</tr>
</tr>
</tbody>
</tbody>
</table>
</table>
</div>
</div>
</div>
</div>
<div
v-else
class=
"flex items-center justify-center h-48 text-gray-500 dark:text-gray-400 text-sm"
>
<div
v-else
class=
"flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.dashboard.noDataAvailable
'
)
}}
{{
t
(
'
admin.dashboard.noDataAvailable
'
)
}}
</div>
</div>
</div>
</div>
...
@@ -40,12 +62,7 @@
...
@@ -40,12 +62,7 @@
<
script
setup
lang=
"ts"
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
import
{
Chart
as
ChartJS
,
ArcElement
,
Tooltip
,
Legend
}
from
'
chart.js
'
Chart
as
ChartJS
,
ArcElement
,
Tooltip
,
Legend
}
from
'
chart.js
'
import
{
Doughnut
}
from
'
vue-chartjs
'
import
{
Doughnut
}
from
'
vue-chartjs
'
import
LoadingSpinner
from
'
@/components/common/LoadingSpinner.vue
'
import
LoadingSpinner
from
'
@/components/common/LoadingSpinner.vue
'
import
type
{
ModelStat
}
from
'
@/types
'
import
type
{
ModelStat
}
from
'
@/types
'
...
@@ -60,20 +77,30 @@ const props = defineProps<{
...
@@ -60,20 +77,30 @@ const props = defineProps<{
}
>
()
}
>
()
const
chartColors
=
[
const
chartColors
=
[
'
#3b82f6
'
,
'
#10b981
'
,
'
#f59e0b
'
,
'
#ef4444
'
,
'
#8b5cf6
'
,
'
#3b82f6
'
,
'
#ec4899
'
,
'
#14b8a6
'
,
'
#f97316
'
,
'
#6366f1
'
,
'
#84cc16
'
'
#10b981
'
,
'
#f59e0b
'
,
'
#ef4444
'
,
'
#8b5cf6
'
,
'
#ec4899
'
,
'
#14b8a6
'
,
'
#f97316
'
,
'
#6366f1
'
,
'
#84cc16
'
]
]
const
chartData
=
computed
(()
=>
{
const
chartData
=
computed
(()
=>
{
if
(
!
props
.
modelStats
?.
length
)
return
null
if
(
!
props
.
modelStats
?.
length
)
return
null
return
{
return
{
labels
:
props
.
modelStats
.
map
(
m
=>
m
.
model
),
labels
:
props
.
modelStats
.
map
((
m
)
=>
m
.
model
),
datasets
:
[{
datasets
:
[
data
:
props
.
modelStats
.
map
(
m
=>
m
.
total_tokens
),
{
backgroundColor
:
chartColors
.
slice
(
0
,
props
.
modelStats
.
length
),
data
:
props
.
modelStats
.
map
((
m
)
=>
m
.
total_tokens
),
borderWidth
:
0
,
backgroundColor
:
chartColors
.
slice
(
0
,
props
.
modelStats
.
length
),
}],
borderWidth
:
0
}
]
}
}
})
})
...
@@ -82,7 +109,7 @@ const doughnutOptions = computed(() => ({
...
@@ -82,7 +109,7 @@ const doughnutOptions = computed(() => ({
maintainAspectRatio
:
false
,
maintainAspectRatio
:
false
,
plugins
:
{
plugins
:
{
legend
:
{
legend
:
{
display
:
false
,
display
:
false
},
},
tooltip
:
{
tooltip
:
{
callbacks
:
{
callbacks
:
{
...
@@ -91,10 +118,10 @@ const doughnutOptions = computed(() => ({
...
@@ -91,10 +118,10 @@ const doughnutOptions = computed(() => ({
const
total
=
context
.
dataset
.
data
.
reduce
((
a
:
number
,
b
:
number
)
=>
a
+
b
,
0
)
const
total
=
context
.
dataset
.
data
.
reduce
((
a
:
number
,
b
:
number
)
=>
a
+
b
,
0
)
const
percentage
=
((
value
/
total
)
*
100
).
toFixed
(
1
)
const
percentage
=
((
value
/
total
)
*
100
).
toFixed
(
1
)
return
`
${
context
.
label
}
:
${
formatTokens
(
value
)}
(
${
percentage
}
%)`
return
`
${
context
.
label
}
:
${
formatTokens
(
value
)}
(
${
percentage
}
%)`
}
,
}
}
,
}
}
,
}
}
,
}
}))
}))
const
formatTokens
=
(
value
:
number
):
string
=>
{
const
formatTokens
=
(
value
:
number
):
string
=>
{
...
...
frontend/src/components/charts/TokenUsageTrend.vue
View file @
5deef27e
<
template
>
<
template
>
<div
class=
"card p-4"
>
<div
class=
"card p-4"
>
<h3
class=
"text-sm font-semibold text-gray-900 dark:text-white mb-4"
>
{{
t
(
'
admin.dashboard.tokenUsageTrend
'
)
}}
</h3>
<h3
class=
"mb-4 text-sm font-semibold text-gray-900 dark:text-white"
>
<div
v-if=
"loading"
class=
"flex items-center justify-center h-48"
>
{{
t
(
'
admin.dashboard.tokenUsageTrend
'
)
}}
</h3>
<div
v-if=
"loading"
class=
"flex h-48 items-center justify-center"
>
<LoadingSpinner
/>
<LoadingSpinner
/>
</div>
</div>
<div
v-else-if=
"trendData.length > 0 && chartData"
class=
"h-48"
>
<div
v-else-if=
"trendData.length > 0 && chartData"
class=
"h-48"
>
<Line
:data=
"chartData"
:options=
"lineOptions"
/>
<Line
:data=
"chartData"
:options=
"lineOptions"
/>
</div>
</div>
<div
v-else
class=
"flex items-center justify-center h-48 text-gray-500 dark:text-gray-400 text-sm"
>
<div
v-else
class=
"flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.dashboard.noDataAvailable
'
)
}}
{{
t
(
'
admin.dashboard.noDataAvailable
'
)
}}
</div>
</div>
</div>
</div>
...
@@ -58,40 +63,40 @@ const chartColors = computed(() => ({
...
@@ -58,40 +63,40 @@ const chartColors = computed(() => ({
grid
:
isDarkMode
.
value
?
'
#374151
'
:
'
#e5e7eb
'
,
grid
:
isDarkMode
.
value
?
'
#374151
'
:
'
#e5e7eb
'
,
input
:
'
#3b82f6
'
,
input
:
'
#3b82f6
'
,
output
:
'
#10b981
'
,
output
:
'
#10b981
'
,
cache
:
'
#f59e0b
'
,
cache
:
'
#f59e0b
'
}))
}))
const
chartData
=
computed
(()
=>
{
const
chartData
=
computed
(()
=>
{
if
(
!
props
.
trendData
?.
length
)
return
null
if
(
!
props
.
trendData
?.
length
)
return
null
return
{
return
{
labels
:
props
.
trendData
.
map
(
d
=>
d
.
date
),
labels
:
props
.
trendData
.
map
(
(
d
)
=>
d
.
date
),
datasets
:
[
datasets
:
[
{
{
label
:
'
Input
'
,
label
:
'
Input
'
,
data
:
props
.
trendData
.
map
(
d
=>
d
.
input_tokens
),
data
:
props
.
trendData
.
map
(
(
d
)
=>
d
.
input_tokens
),
borderColor
:
chartColors
.
value
.
input
,
borderColor
:
chartColors
.
value
.
input
,
backgroundColor
:
`
${
chartColors
.
value
.
input
}
20`
,
backgroundColor
:
`
${
chartColors
.
value
.
input
}
20`
,
fill
:
true
,
fill
:
true
,
tension
:
0.3
,
tension
:
0.3
},
},
{
{
label
:
'
Output
'
,
label
:
'
Output
'
,
data
:
props
.
trendData
.
map
(
d
=>
d
.
output_tokens
),
data
:
props
.
trendData
.
map
(
(
d
)
=>
d
.
output_tokens
),
borderColor
:
chartColors
.
value
.
output
,
borderColor
:
chartColors
.
value
.
output
,
backgroundColor
:
`
${
chartColors
.
value
.
output
}
20`
,
backgroundColor
:
`
${
chartColors
.
value
.
output
}
20`
,
fill
:
true
,
fill
:
true
,
tension
:
0.3
,
tension
:
0.3
},
},
{
{
label
:
'
Cache
'
,
label
:
'
Cache
'
,
data
:
props
.
trendData
.
map
(
d
=>
d
.
cache_tokens
),
data
:
props
.
trendData
.
map
(
(
d
)
=>
d
.
cache_tokens
),
borderColor
:
chartColors
.
value
.
cache
,
borderColor
:
chartColors
.
value
.
cache
,
backgroundColor
:
`
${
chartColors
.
value
.
cache
}
20`
,
backgroundColor
:
`
${
chartColors
.
value
.
cache
}
20`
,
fill
:
true
,
fill
:
true
,
tension
:
0.3
,
tension
:
0.3
}
,
}
]
,
]
}
}
})
})
...
@@ -100,7 +105,7 @@ const lineOptions = computed(() => ({
...
@@ -100,7 +105,7 @@ const lineOptions = computed(() => ({
maintainAspectRatio
:
false
,
maintainAspectRatio
:
false
,
interaction
:
{
interaction
:
{
intersect
:
false
,
intersect
:
false
,
mode
:
'
index
'
as
const
,
mode
:
'
index
'
as
const
},
},
plugins
:
{
plugins
:
{
legend
:
{
legend
:
{
...
@@ -111,9 +116,9 @@ const lineOptions = computed(() => ({
...
@@ -111,9 +116,9 @@ const lineOptions = computed(() => ({
pointStyle
:
'
circle
'
,
pointStyle
:
'
circle
'
,
padding
:
15
,
padding
:
15
,
font
:
{
font
:
{
size
:
11
,
size
:
11
}
,
}
}
,
}
},
},
tooltip
:
{
tooltip
:
{
callbacks
:
{
callbacks
:
{
...
@@ -127,35 +132,35 @@ const lineOptions = computed(() => ({
...
@@ -127,35 +132,35 @@ const lineOptions = computed(() => ({
return
`Actual: $
${
formatCost
(
data
.
actual_cost
)}
| Standard: $
${
formatCost
(
data
.
cost
)}
`
return
`Actual: $
${
formatCost
(
data
.
actual_cost
)}
| Standard: $
${
formatCost
(
data
.
cost
)}
`
}
}
return
''
return
''
}
,
}
}
,
}
}
,
}
},
},
scales
:
{
scales
:
{
x
:
{
x
:
{
grid
:
{
grid
:
{
color
:
chartColors
.
value
.
grid
,
color
:
chartColors
.
value
.
grid
},
},
ticks
:
{
ticks
:
{
color
:
chartColors
.
value
.
text
,
color
:
chartColors
.
value
.
text
,
font
:
{
font
:
{
size
:
10
,
size
:
10
}
,
}
}
,
}
},
},
y
:
{
y
:
{
grid
:
{
grid
:
{
color
:
chartColors
.
value
.
grid
,
color
:
chartColors
.
value
.
grid
},
},
ticks
:
{
ticks
:
{
color
:
chartColors
.
value
.
text
,
color
:
chartColors
.
value
.
text
,
font
:
{
font
:
{
size
:
10
,
size
:
10
},
},
callback
:
(
value
:
string
|
number
)
=>
formatTokens
(
Number
(
value
))
,
callback
:
(
value
:
string
|
number
)
=>
formatTokens
(
Number
(
value
))
}
,
}
}
,
}
}
,
}
}))
}))
const
formatTokens
=
(
value
:
number
):
string
=>
{
const
formatTokens
=
(
value
:
number
):
string
=>
{
...
...
frontend/src/components/common/ConfirmDialog.vue
View file @
5deef27e
...
@@ -9,7 +9,7 @@
...
@@ -9,7 +9,7 @@
<button
<button
@
click=
"handleCancel"
@
click=
"handleCancel"
type=
"button"
type=
"button"
class=
"px-4 py-2 text-sm font-medium text-gray-700
dark:text-gray-200 bg-white dark:bg-dark-700 border
border-
gray-3
00 dark:b
order
-dark-
6
00
rounded-md hover:bg
-gray-
5
0 dark:hover:bg-dark-600
focus:outline-none focus:ring-2 focus:ring-offset-2
dark:focus:ring-offset-dark-800
focus:ring-primary-500
"
class=
"
rounded-md border border-gray-300 bg-white
px-4 py-2 text-sm font-medium text-gray-700
hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:
border-
dark-6
00 dark:b
g
-dark-
7
00
dark:text
-gray-
20
0 dark:hover:bg-dark-600 dark:focus:ring-offset-dark-800"
>
>
{{
cancelText
}}
{{
cancelText
}}
</button>
</button>
...
@@ -17,7 +17,7 @@
...
@@ -17,7 +17,7 @@
@
click=
"handleConfirm"
@
click=
"handleConfirm"
type=
"button"
type=
"button"
:class=
"[
:class=
"[
'px-4 py-2 text-sm font-medium text-white
rounded-md
focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-dark-800',
'
rounded-md
px-4 py-2 text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-dark-800',
danger
danger
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
: 'bg-primary-600 hover:bg-primary-700 focus:ring-primary-500'
: 'bg-primary-600 hover:bg-primary-700 focus:ring-primary-500'
...
...
frontend/src/components/common/DataTable.vue
View file @
5deef27e
...
@@ -7,7 +7,7 @@
...
@@ -7,7 +7,7 @@
v-for=
"column in columns"
v-for=
"column in columns"
:key=
"column.key"
:key=
"column.key"
scope=
"col"
scope=
"col"
class=
"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-dark-400
uppercase tracking-wider
"
class=
"px-6 py-3 text-left text-xs font-medium
uppercase tracking-wider
text-gray-500 dark:text-dark-400"
:class=
"
{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable }"
:class=
"
{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable }"
@click="column.sortable
&&
handleSort(column.key)"
@click="column.sortable
&&
handleSort(column.key)"
>
>
...
@@ -16,8 +16,8 @@
...
@@ -16,8 +16,8 @@
<span
v-if=
"column.sortable"
class=
"text-gray-400 dark:text-dark-500"
>
<span
v-if=
"column.sortable"
class=
"text-gray-400 dark:text-dark-500"
>
<svg
<svg
v-if=
"sortKey === column.key"
v-if=
"sortKey === column.key"
class=
"
w
-4
h
-4"
class=
"
h
-4
w
-4"
:class=
"
{ '
transform
rotate-180': sortOrder === 'desc' }"
:class=
"
{ 'rotate-180
transform
': sortOrder === 'desc' }"
fill="currentColor"
fill="currentColor"
viewBox="0 0 20 20"
viewBox="0 0 20 20"
>
>
...
@@ -27,7 +27,7 @@
...
@@ -27,7 +27,7 @@
clip-rule=
"evenodd"
clip-rule=
"evenodd"
/>
/>
</svg>
</svg>
<svg
v-else
class=
"
w
-4
h
-4"
fill=
"currentColor"
viewBox=
"0 0 20 20"
>
<svg
v-else
class=
"
h
-4
w
-4"
fill=
"currentColor"
viewBox=
"0 0 20 20"
>
<path
<path
d=
"M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
d=
"M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
/>
/>
...
@@ -37,22 +37,30 @@
...
@@ -37,22 +37,30 @@
</th>
</th>
</tr>
</tr>
</thead>
</thead>
<tbody
class=
"
bg-white dark:bg-dark-900
divide-y divide-gray-200 dark:divide-dark-700"
>
<tbody
class=
"divide-y divide-gray-200
bg-white
dark:divide-dark-700
dark:bg-dark-900
"
>
<!-- Loading skeleton -->
<!-- Loading skeleton -->
<tr
v-if=
"loading"
v-for=
"i in 5"
:key=
"i"
>
<tr
v-if=
"loading"
v-for=
"i in 5"
:key=
"i"
>
<td
v-for=
"column in columns"
:key=
"column.key"
class=
"
px-6 py-4
whitespace-nowrap"
>
<td
v-for=
"column in columns"
:key=
"column.key"
class=
"whitespace-nowrap
px-6 py-4
"
>
<div
class=
"animate-pulse"
>
<div
class=
"animate-pulse"
>
<div
class=
"h-4 bg-gray-200 dark:bg-dark-700
rounded w-3/4
"
></div>
<div
class=
"h-4
w-3/4 rounded
bg-gray-200 dark:bg-dark-700"
></div>
</div>
</div>
</td>
</td>
</tr>
</tr>
<!-- Empty state -->
<!-- Empty state -->
<tr
v-else-if=
"!data || data.length === 0"
>
<tr
v-else-if=
"!data || data.length === 0"
>
<td
:colspan=
"columns.length"
class=
"px-6 py-12 text-center text-gray-500 dark:text-dark-400"
>
<td
:colspan=
"columns.length"
class=
"px-6 py-12 text-center text-gray-500 dark:text-dark-400"
>
<slot
name=
"empty"
>
<slot
name=
"empty"
>
<div
class=
"flex flex-col items-center"
>
<div
class=
"flex flex-col items-center"
>
<svg
class=
"w-12 h-12 text-gray-400 dark:text-dark-500 mb-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<svg
class=
"mb-4 h-12 w-12 text-gray-400 dark:text-dark-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
<path
stroke-linecap=
"round"
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-linejoin=
"round"
...
@@ -60,18 +68,25 @@
...
@@ -60,18 +68,25 @@
d=
"M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
d=
"M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/>
/>
</svg>
</svg>
<p
class=
"text-lg font-medium text-gray-900 dark:text-gray-100"
>
{{
t
(
'
empty.noData
'
)
}}
</p>
<p
class=
"text-lg font-medium text-gray-900 dark:text-gray-100"
>
{{
t
(
'
empty.noData
'
)
}}
</p>
</div>
</div>
</slot>
</slot>
</td>
</td>
</tr>
</tr>
<!-- Data rows -->
<!-- Data rows -->
<tr
v-else
v-for=
"(row, index) in sortedData"
:key=
"index"
class=
"hover:bg-gray-50 dark:hover:bg-dark-800"
>
<tr
v-else
v-for=
"(row, index) in sortedData"
:key=
"index"
class=
"hover:bg-gray-50 dark:hover:bg-dark-800"
>
<td
<td
v-for=
"column in columns"
v-for=
"column in columns"
:key=
"column.key"
:key=
"column.key"
class=
"
px-6 py-4
whitespace-nowrap text-sm text-gray-900 dark:text-gray-100"
class=
"whitespace-nowrap
px-6 py-4
text-sm text-gray-900 dark:text-gray-100"
>
>
<slot
:name=
"`cell-$
{column.key}`" :row="row" :value="row[column.key]">
<slot
:name=
"`cell-$
{column.key}`" :row="row" :value="row[column.key]">
{{
column
.
formatter
?
column
.
formatter
(
row
[
column
.
key
],
row
)
:
row
[
column
.
key
]
}}
{{
column
.
formatter
?
column
.
formatter
(
row
[
column
.
key
],
row
)
:
row
[
column
.
key
]
}}
...
...
frontend/src/components/common/DateRangePicker.vue
View file @
5deef27e
...
@@ -3,14 +3,21 @@
...
@@ -3,14 +3,21 @@
<button
<button
type=
"button"
type=
"button"
@
click=
"toggle"
@
click=
"toggle"
:class=
"[
:class=
"['date-picker-trigger', isOpen && 'date-picker-trigger-open']"
'date-picker-trigger',
isOpen && 'date-picker-trigger-open'
]"
>
>
<span
class=
"date-picker-icon"
>
<span
class=
"date-picker-icon"
>
<svg
class=
"w-4 h-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<svg
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
/>
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=
"M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
/>
</svg>
</svg>
</span>
</span>
<span
class=
"date-picker-value"
>
<span
class=
"date-picker-value"
>
...
@@ -18,7 +25,7 @@
...
@@ -18,7 +25,7 @@
</span>
</span>
<span
class=
"date-picker-chevron"
>
<span
class=
"date-picker-chevron"
>
<svg
<svg
:class=
"['
w
-4
h
-4 transition-transform duration-200', isOpen && 'rotate-180']"
:class=
"['
h
-4
w
-4 transition-transform duration-200', isOpen && 'rotate-180']"
fill=
"none"
fill=
"none"
stroke=
"currentColor"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
viewBox=
"0 0 24 24"
...
@@ -30,20 +37,14 @@
...
@@ -30,20 +37,14 @@
</button>
</button>
<Transition
name=
"date-picker-dropdown"
>
<Transition
name=
"date-picker-dropdown"
>
<div
<div
v-if=
"isOpen"
class=
"date-picker-dropdown"
>
v-if=
"isOpen"
class=
"date-picker-dropdown"
>
<!-- Quick presets -->
<!-- Quick presets -->
<div
class=
"date-picker-presets"
>
<div
class=
"date-picker-presets"
>
<button
<button
v-for=
"preset in presets"
v-for=
"preset in presets"
:key=
"preset.value"
:key=
"preset.value"
@
click=
"selectPreset(preset)"
@
click=
"selectPreset(preset)"
:class=
"[
:class=
"['date-picker-preset', isPresetActive(preset) && 'date-picker-preset-active']"
'date-picker-preset',
isPresetActive(preset) && 'date-picker-preset-active'
]"
>
>
{{
t
(
preset
.
labelKey
)
}}
{{
t
(
preset
.
labelKey
)
}}
</button>
</button>
...
@@ -64,8 +65,18 @@
...
@@ -64,8 +65,18 @@
/>
/>
</div>
</div>
<div
class=
"date-picker-separator"
>
<div
class=
"date-picker-separator"
>
<svg
class=
"w-4 h-4 text-gray-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<svg
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3"
/>
class=
"h-4 w-4 text-gray-400"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3"
/>
</svg>
</svg>
</div>
</div>
<div
class=
"date-picker-field"
>
<div
class=
"date-picker-field"
>
...
@@ -83,10 +94,7 @@
...
@@ -83,10 +94,7 @@
<!-- Apply button -->
<!-- Apply button -->
<div
class=
"date-picker-actions"
>
<div
class=
"date-picker-actions"
>
<button
<button
@
click=
"apply"
class=
"date-picker-apply"
>
@
click=
"apply"
class=
"date-picker-apply"
>
{{
t
(
'
dates.apply
'
)
}}
{{
t
(
'
dates.apply
'
)
}}
</button>
</button>
</div>
</div>
...
@@ -204,7 +212,7 @@ const presets: DatePreset[] = [
...
@@ -204,7 +212,7 @@ const presets: DatePreset[] = [
const
displayValue
=
computed
(()
=>
{
const
displayValue
=
computed
(()
=>
{
if
(
activePreset
.
value
)
{
if
(
activePreset
.
value
)
{
const
preset
=
presets
.
find
(
p
=>
p
.
value
===
activePreset
.
value
)
const
preset
=
presets
.
find
(
(
p
)
=>
p
.
value
===
activePreset
.
value
)
if
(
preset
)
return
t
(
preset
.
labelKey
)
if
(
preset
)
return
t
(
preset
.
labelKey
)
}
}
...
@@ -275,15 +283,21 @@ const handleEscape = (event: KeyboardEvent) => {
...
@@ -275,15 +283,21 @@ const handleEscape = (event: KeyboardEvent) => {
}
}
// Sync local state with props
// Sync local state with props
watch
(()
=>
props
.
startDate
,
(
val
)
=>
{
watch
(
localStartDate
.
value
=
val
()
=>
props
.
startDate
,
onDateChange
()
(
val
)
=>
{
})
localStartDate
.
value
=
val
onDateChange
()
}
)
watch
(()
=>
props
.
endDate
,
(
val
)
=>
{
watch
(
localEndDate
.
value
=
val
()
=>
props
.
endDate
,
onDateChange
()
(
val
)
=>
{
})
localEndDate
.
value
=
val
onDateChange
()
}
)
onMounted
(()
=>
{
onMounted
(()
=>
{
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
...
@@ -301,18 +315,18 @@ onUnmounted(() => {
...
@@ -301,18 +315,18 @@ onUnmounted(() => {
<
style
scoped
>
<
style
scoped
>
.date-picker-trigger
{
.date-picker-trigger
{
@apply
flex
items-center
gap-2;
@apply
flex
items-center
gap-2;
@apply
px-3
py-2
rounded-lg
text-sm;
@apply
rounded-lg
px-3
py-2
text-sm;
@apply
bg-white
dark
:
bg-dark-800
;
@apply
bg-white
dark
:
bg-dark-800
;
@apply
border
border-gray-200
dark
:
border-dark-600
;
@apply
border
border-gray-200
dark
:
border-dark-600
;
@apply
text-gray-700
dark
:
text-gray-300
;
@apply
text-gray-700
dark
:
text-gray-300
;
@apply
transition-all
duration-200;
@apply
transition-all
duration-200;
@apply
focus
:
outline-none
focus
:
ring-2
focus
:
ring-primary-500
/
30
focus
:
border-primary-500
;
@apply
focus
:
border-primary-500
focus
:
outline-none
focus
:
ring-2
focus
:
ring-primary-500
/
30
;
@apply
hover
:
border-gray-300
dark
:
hover
:
border-dark-500
;
@apply
hover
:
border-gray-300
dark
:
hover
:
border-dark-500
;
@apply
cursor-pointer;
@apply
cursor-pointer;
}
}
.date-picker-trigger-open
{
.date-picker-trigger-open
{
@apply
ring-2
ring-primary-500/30
border-primary-500
;
@apply
border-primary-500
ring-2
ring-primary-500/30;
}
}
.date-picker-icon
{
.date-picker-icon
{
...
@@ -328,7 +342,7 @@ onUnmounted(() => {
...
@@ -328,7 +342,7 @@ onUnmounted(() => {
}
}
.date-picker-dropdown
{
.date-picker-dropdown
{
@apply
absolute
z-[100]
mt-2
left-0
;
@apply
absolute
left-0
z-[100]
mt-2;
@apply
bg-white
dark
:
bg-dark-800
;
@apply
bg-white
dark
:
bg-dark-800
;
@apply
rounded-xl;
@apply
rounded-xl;
@apply
border
border-gray-200
dark
:
border-dark-700
;
@apply
border
border-gray-200
dark
:
border-dark-700
;
...
@@ -342,7 +356,7 @@ onUnmounted(() => {
...
@@ -342,7 +356,7 @@ onUnmounted(() => {
}
}
.date-picker-preset
{
.date-picker-preset
{
@apply
px-3
py-1.5
text-xs
font-medium
rounded-md
;
@apply
rounded-md
px-3
py-1.5
text-xs
font-medium;
@apply
text-gray-600
dark
:
text-gray-400
;
@apply
text-gray-600
dark
:
text-gray-400
;
@apply
hover
:
bg-gray-100
dark
:
hover
:
bg-dark-700
;
@apply
hover
:
bg-gray-100
dark
:
hover
:
bg-dark-700
;
@apply
transition-colors
duration-150;
@apply
transition-colors
duration-150;
...
@@ -366,15 +380,15 @@ onUnmounted(() => {
...
@@ -366,15 +380,15 @@ onUnmounted(() => {
}
}
.date-picker-label
{
.date-picker-label
{
@apply
block
text-xs
font-medium
text-gray-500
dark
:
text-gray-400
mb-1
;
@apply
mb-1
block
text-xs
font-medium
text-gray-500
dark
:
text-gray-400
;
}
}
.date-picker-input
{
.date-picker-input
{
@apply
w-full
px-2
py-1.5
text-sm
rounded-md
;
@apply
w-full
rounded-md
px-2
py-1.5
text-sm;
@apply
bg-gray-50
dark
:
bg-dark-700
;
@apply
bg-gray-50
dark
:
bg-dark-700
;
@apply
border
border-gray-200
dark
:
border-dark-600
;
@apply
border
border-gray-200
dark
:
border-dark-600
;
@apply
text-gray-900
dark
:
text-gray-100
;
@apply
text-gray-900
dark
:
text-gray-100
;
@apply
focus
:
outline-none
focus
:
ring-2
focus
:
ring-primary-500
/
30
focus
:
border-primary-500
;
@apply
focus
:
border-primary-500
focus
:
outline-none
focus
:
ring-2
focus
:
ring-primary-500
/
30
;
}
}
.date-picker-input
::-webkit-calendar-picker-indicator
{
.date-picker-input
::-webkit-calendar-picker-indicator
{
...
@@ -395,7 +409,7 @@ onUnmounted(() => {
...
@@ -395,7 +409,7 @@ onUnmounted(() => {
}
}
.date-picker-apply
{
.date-picker-apply
{
@apply
px-4
py-1.5
text-sm
font-medium
rounded-lg
;
@apply
rounded-lg
px-4
py-1.5
text-sm
font-medium;
@apply
bg-primary-600
text-white;
@apply
bg-primary-600
text-white;
@apply
hover
:
bg-primary-700
;
@apply
hover
:
bg-primary-700
;
@apply
transition-colors
duration-150;
@apply
transition-colors
duration-150;
...
...
frontend/src/components/common/EmptyState.vue
View file @
5deef27e
<
template
>
<
template
>
<div
class=
"empty-state"
>
<div
class=
"empty-state"
>
<!-- Icon -->
<!-- Icon -->
<div
class=
"w-20 h-20 mb-5 rounded-2xl bg-gray-100 dark:bg-dark-800 flex items-center justify-center"
>
<div
class=
"mb-5 flex h-20 w-20 items-center justify-center rounded-2xl bg-gray-100 dark:bg-dark-800"
>
<slot
name=
"icon"
>
<slot
name=
"icon"
>
<component
<component
v-if=
"icon"
:is=
"icon"
class=
"empty-state-icon h-10 w-10"
aria-hidden=
"true"
/>
v-if=
"icon"
:is=
"icon"
class=
"empty-state-icon w-10 h-10"
aria-hidden=
"true"
/>
<svg
<svg
v-else
v-else
class=
"empty-state-icon
w
-10
h
-10"
class=
"empty-state-icon
h
-10
w
-10"
fill=
"none"
fill=
"none"
stroke=
"currentColor"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
viewBox=
"0 0 24 24"
...
@@ -48,17 +45,13 @@
...
@@ -48,17 +45,13 @@
>
>
<svg
<svg
v-if=
"actionIcon"
v-if=
"actionIcon"
class=
"
w-5
h-5
mr-2
"
class=
"
mr-2
h-5
w-5
"
fill=
"none"
fill=
"none"
stroke=
"currentColor"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
stroke-width=
"1.5"
>
>
<path
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 4.5v15m7.5-7.5h-15"
/>
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 4.5v15m7.5-7.5h-15"
/>
</svg>
</svg>
{{
actionText
}}
{{
actionText
}}
</component>
</component>
...
...
frontend/src/components/common/GroupBadge.vue
View file @
5deef27e
<
template
>
<
template
>
<span
<span
:class=
"[
:class=
"[
'inline-flex items-center gap-1.5 px-2 py-0.5
rounded-md
text-xs font-medium transition-colors',
'inline-flex items-center gap-1.5
rounded-md
px-2 py-0.5 text-xs font-medium transition-colors',
badgeClass
badgeClass
]"
]"
>
>
...
@@ -10,10 +10,7 @@
...
@@ -10,10 +10,7 @@
<!-- Group name -->
<!-- Group name -->
<span
class=
"truncate"
>
{{
name
}}
</span>
<span
class=
"truncate"
>
{{
name
}}
</span>
<!-- Right side label -->
<!-- Right side label -->
<span
<span
v-if=
"showLabel"
:class=
"labelClass"
>
v-if=
"showLabel"
:class=
"labelClass"
>
{{
labelText
}}
{{
labelText
}}
</span>
</span>
</span>
</span>
...
@@ -31,7 +28,7 @@ interface Props {
...
@@ -31,7 +28,7 @@ interface Props {
subscriptionType
?:
SubscriptionType
subscriptionType
?:
SubscriptionType
rateMultiplier
?:
number
rateMultiplier
?:
number
showRate
?:
boolean
showRate
?:
boolean
daysRemaining
?:
number
|
null
// 剩余天数(订阅类型时使用)
daysRemaining
?:
number
|
null
// 剩余天数(订阅类型时使用)
}
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
...
@@ -97,6 +94,9 @@ const labelClass = computed(() => {
...
@@ -97,6 +94,9 @@ const labelClass = computed(() => {
if
(
props
.
platform
===
'
openai
'
)
{
if
(
props
.
platform
===
'
openai
'
)
{
return
`
${
base
}
bg-emerald-200/60 text-emerald-800 dark:bg-emerald-800/40 dark:text-emerald-300`
return
`
${
base
}
bg-emerald-200/60 text-emerald-800 dark:bg-emerald-800/40 dark:text-emerald-300`
}
}
if
(
props
.
platform
===
'
gemini
'
)
{
return
`
${
base
}
bg-blue-200/60 text-blue-800 dark:bg-blue-800/40 dark:text-blue-300`
}
return
`
${
base
}
bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300`
return
`
${
base
}
bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300`
})
})
...
@@ -113,6 +113,11 @@ const badgeClass = computed(() => {
...
@@ -113,6 +113,11 @@ const badgeClass = computed(() => {
?
'
bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400
'
?
'
bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400
'
:
'
bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400
'
:
'
bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400
'
}
}
if
(
props
.
platform
===
'
gemini
'
)
{
return
isSubscription
.
value
?
'
bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400
'
:
'
bg-sky-50 text-sky-700 dark:bg-sky-900/20 dark:text-sky-400
'
}
// Fallback: original colors
// Fallback: original colors
return
isSubscription
.
value
return
isSubscription
.
value
?
'
bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400
'
?
'
bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400
'
...
...
frontend/src/components/common/GroupSelector.vue
View file @
5deef27e
...
@@ -2,15 +2,15 @@
...
@@ -2,15 +2,15 @@
<div>
<div>
<label
class=
"input-label"
>
<label
class=
"input-label"
>
Groups
Groups
<span
class=
"text-gray-400
font-normal
"
>
(
{{
modelValue
.
length
}}
selected)
</span>
<span
class=
"
font-normal
text-gray-400"
>
(
{{
modelValue
.
length
}}
selected)
</span>
</label>
</label>
<div
<div
class=
"grid grid-cols-2 gap-1
max-h-32
overflow-y-auto
p-2
border border-gray-200 dark:border-dark-600
rounded-lg bg-gray-50
dark:bg-dark-800"
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"
>
>
<label
<label
v-for=
"group in filteredGroups"
v-for=
"group in filteredGroups"
:key=
"group.id"
:key=
"group.id"
class=
"flex items-center gap-2 px-2 py-1.5
rounded
hover:bg-white dark:hover:bg-dark-700
cursor-pointer transition-colors
"
class=
"flex
cursor-pointer
items-center gap-2
rounded
px-2 py-1.5
transition-colors
hover:bg-white dark:hover:bg-dark-700"
:title=
"`$
{group.rate_multiplier}x rate · ${group.account_count || 0} accounts`"
:title=
"`$
{group.rate_multiplier}x rate · ${group.account_count || 0} accounts`"
>
>
<input
<input
...
@@ -18,19 +18,19 @@
...
@@ -18,19 +18,19 @@
:value=
"group.id"
:value=
"group.id"
:checked=
"modelValue.includes(group.id)"
:checked=
"modelValue.includes(group.id)"
@
change=
"handleChange(group.id, ($event.target as HTMLInputElement).checked)"
@
change=
"handleChange(group.id, ($event.target as HTMLInputElement).checked)"
class=
"
w
-3.5
h
-3.5
text-primary-500
border-gray-300
dark:border-d
ar
k
-500
rounded
focus:ring-primary-500
shrink-
0"
class=
"
h
-3.5
w
-3.5
shrink-0 rounded
border-gray-300
text-prim
ar
y
-500 focus:ring-primary-500
dark:border-dark-50
0"
/>
/>
<GroupBadge
<GroupBadge
:name=
"group.name"
:name=
"group.name"
:subscription-type=
"group.subscription_type"
:subscription-type=
"group.subscription_type"
:rate-multiplier=
"group.rate_multiplier"
:rate-multiplier=
"group.rate_multiplier"
class=
"
flex-1
min-w-0"
class=
"min-w-0
flex-1
"
/>
/>
<span
class=
"text-xs text-gray-400
shrink-0
"
>
{{
group
.
account_count
||
0
}}
</span>
<span
class=
"
shrink-0
text-xs text-gray-400"
>
{{
group
.
account_count
||
0
}}
</span>
</label>
</label>
<div
<div
v-if=
"filteredGroups.length === 0"
v-if=
"filteredGroups.length === 0"
class=
"col-span-2 text-center text-sm text-gray-500 dark:text-gray-400
py-2
"
class=
"col-span-2
py-2
text-center text-sm text-gray-500 dark:text-gray-400"
>
>
No groups available
No groups available
</div>
</div>
...
@@ -59,13 +59,13 @@ const filteredGroups = computed(() => {
...
@@ -59,13 +59,13 @@ const filteredGroups = computed(() => {
if
(
!
props
.
platform
)
{
if
(
!
props
.
platform
)
{
return
props
.
groups
return
props
.
groups
}
}
return
props
.
groups
.
filter
(
g
=>
g
.
platform
===
props
.
platform
)
return
props
.
groups
.
filter
(
(
g
)
=>
g
.
platform
===
props
.
platform
)
})
})
const
handleChange
=
(
groupId
:
number
,
checked
:
boolean
)
=>
{
const
handleChange
=
(
groupId
:
number
,
checked
:
boolean
)
=>
{
const
newValue
=
checked
const
newValue
=
checked
?
[...
props
.
modelValue
,
groupId
]
?
[...
props
.
modelValue
,
groupId
]
:
props
.
modelValue
.
filter
(
id
=>
id
!==
groupId
)
:
props
.
modelValue
.
filter
(
(
id
)
=>
id
!==
groupId
)
emit
(
'
update:modelValue
'
,
newValue
)
emit
(
'
update:modelValue
'
,
newValue
)
}
}
</
script
>
</
script
>
frontend/src/components/common/LocaleSwitcher.vue
View file @
5deef27e
...
@@ -2,13 +2,13 @@
...
@@ -2,13 +2,13 @@
<div
class=
"relative"
ref=
"dropdownRef"
>
<div
class=
"relative"
ref=
"dropdownRef"
>
<button
<button
@
click=
"toggleDropdown"
@
click=
"toggleDropdown"
class=
"flex items-center gap-1.5 px-2 py-1.5
rounded-lg
text-sm font-medium text-gray-600
dark:text-gray-300
hover:bg-gray-100 dark:
hover:bg-dark-700 transition-colors
"
class=
"flex items-center gap-1.5
rounded-lg
px-2 py-1.5 text-sm font-medium text-gray-600
transition-colors
hover:bg-gray-100 dark:
text-gray-300 dark:hover:bg-dark-700
"
:title=
"currentLocale?.name"
:title=
"currentLocale?.name"
>
>
<span
class=
"text-base"
>
{{
currentLocale
?.
flag
}}
</span>
<span
class=
"text-base"
>
{{
currentLocale
?.
flag
}}
</span>
<span
class=
"hidden sm:inline"
>
{{
currentLocale
?.
code
.
toUpperCase
()
}}
</span>
<span
class=
"hidden sm:inline"
>
{{
currentLocale
?.
code
.
toUpperCase
()
}}
</span>
<svg
<svg
class=
"
w
-3.5
h
-3.5 text-gray-400 transition-transform duration-200"
class=
"
h
-3.5
w
-3.5 text-gray-400 transition-transform duration-200"
:class=
"
{ 'rotate-180': isOpen }"
:class=
"
{ 'rotate-180': isOpen }"
fill="none"
fill="none"
viewBox="0 0 24 24"
viewBox="0 0 24 24"
...
@@ -22,20 +22,23 @@
...
@@ -22,20 +22,23 @@
<transition
name=
"dropdown"
>
<transition
name=
"dropdown"
>
<div
<div
v-if=
"isOpen"
v-if=
"isOpen"
class=
"absolute right-0 mt-1 w-32
rounded-lg bg-white dark:bg-dark-800 shadow
-lg border border-gray-200 dark:border-dark-700
overflow-hidden z-5
0"
class=
"absolute right-0
z-50
mt-1 w-32
overflow-hidden rounded
-lg border border-gray-200
bg-white shadow-lg
dark:border-dark-700
dark:bg-dark-80
0"
>
>
<button
<button
v-for=
"locale in availableLocales"
v-for=
"locale in availableLocales"
:key=
"locale.code"
:key=
"locale.code"
@
click=
"selectLocale(locale.code)"
@
click=
"selectLocale(locale.code)"
class=
"w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
class=
"flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-dark-700"
:class=
"
{ 'bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400': locale.code === currentLocaleCode }"
:class=
"
{
'bg-primary-50 text-primary-600 dark:bg-primary-900/20 dark:text-primary-400':
locale.code === currentLocaleCode
}"
>
>
<span
class=
"text-base"
>
{{
locale
.
flag
}}
</span>
<span
class=
"text-base"
>
{{
locale
.
flag
}}
</span>
<span>
{{
locale
.
name
}}
</span>
<span>
{{
locale
.
name
}}
</span>
<svg
<svg
v-if=
"locale.code === currentLocaleCode"
v-if=
"locale.code === currentLocaleCode"
class=
"
w-4 h-4 ml-auto
text-primary-500"
class=
"
ml-auto h-4 w-4
text-primary-500"
fill=
"none"
fill=
"none"
viewBox=
"0 0 24 24"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke=
"currentColor"
...
@@ -60,7 +63,7 @@ const isOpen = ref(false)
...
@@ -60,7 +63,7 @@ const isOpen = ref(false)
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
currentLocaleCode
=
computed
(()
=>
locale
.
value
)
const
currentLocaleCode
=
computed
(()
=>
locale
.
value
)
const
currentLocale
=
computed
(()
=>
availableLocales
.
find
(
l
=>
l
.
code
===
locale
.
value
))
const
currentLocale
=
computed
(()
=>
availableLocales
.
find
(
(
l
)
=>
l
.
code
===
locale
.
value
))
function
toggleDropdown
()
{
function
toggleDropdown
()
{
isOpen
.
value
=
!
isOpen
.
value
isOpen
.
value
=
!
isOpen
.
value
...
...
frontend/src/components/common/Modal.vue
View file @
5deef27e
...
@@ -9,24 +9,24 @@
...
@@ -9,24 +9,24 @@
@
click.self=
"handleClose"
@
click.self=
"handleClose"
>
>
<!-- Modal panel -->
<!-- Modal panel -->
<div
<div
:class=
"['modal-content', sizeClasses]"
@
click.stop
>
:class=
"['modal-content', sizeClasses]"
@
click.stop
>
<!-- Header -->
<!-- Header -->
<div
class=
"modal-header"
>
<div
class=
"modal-header"
>
<h3
<h3
id=
"modal-title"
class=
"modal-title"
>
id=
"modal-title"
class=
"modal-title"
>
{{
title
}}
{{
title
}}
</h3>
</h3>
<button
<button
@
click=
"emit('close')"
@
click=
"emit('close')"
class=
"
p-2
-mr-2 rounded-xl text-gray-400
dark:text-dark-5
00 hover:text-gray-600 dark:
hover:
text-dark-
3
00 hover:bg-
gray-1
00 dark:hover:
bg
-dark-
7
00
transition-colors
"
class=
"-mr-2 rounded-xl
p-2
text-gray-400
transition-colors hover:bg-gray-1
00 hover:text-gray-600 dark:text-dark-
5
00
dark:
hover:bg-
dark-7
00 dark:hover:
text
-dark-
3
00"
aria-label=
"Close modal"
aria-label=
"Close modal"
>
>
<svg
class=
"w-5 h-5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
>
<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=
"M6 18L18 6M6 6l12 12"
/>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M6 18L18 6M6 6l12 12"
/>
</svg>
</svg>
</button>
</button>
...
@@ -38,10 +38,7 @@
...
@@ -38,10 +38,7 @@
</div>
</div>
<!-- Footer -->
<!-- Footer -->
<div
<div
v-if=
"$slots.footer"
class=
"modal-footer"
>
v-if=
"$slots.footer"
class=
"modal-footer"
>
<slot
name=
"footer"
></slot>
<slot
name=
"footer"
></slot>
</div>
</div>
</div>
</div>
...
...
frontend/src/components/common/Pagination.vue
View file @
5deef27e
<
template
>
<
template
>
<div
class=
"flex items-center justify-between px-4 py-3 bg-white dark:bg-dark-800 border-t border-gray-200 dark:border-dark-700 sm:px-6"
>
<div
<div
class=
"flex items-center justify-between flex-1 sm:hidden"
>
class=
"flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 dark:border-dark-700 dark:bg-dark-800 sm:px-6"
>
<div
class=
"flex flex-1 items-center justify-between sm:hidden"
>
<!-- Mobile pagination -->
<!-- Mobile pagination -->
<button
<button
@
click=
"goToPage(page - 1)"
@
click=
"goToPage(page - 1)"
:disabled=
"page === 1"
:disabled=
"page === 1"
class=
"relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700
dark:text-gray-200 bg-white dark:bg-dark-700 border
border-
gray-3
00 dark:b
order
-dark-
6
00
rounded-md hover:bg
-gray-
5
0 dark:hover:bg-dark-600
disabled:opacity-50 disabled:cursor-not-allowed
"
class=
"relative inline-flex items-center
rounded-md border border-gray-300 bg-white
px-4 py-2 text-sm font-medium text-gray-700
hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:
border-
dark-6
00 dark:b
g
-dark-
7
00
dark:text
-gray-
20
0 dark:hover:bg-dark-600"
>
>
{{
t
(
'
pagination.previous
'
)
}}
{{
t
(
'
pagination.previous
'
)
}}
</button>
</button>
...
@@ -15,7 +17,7 @@
...
@@ -15,7 +17,7 @@
<
button
<
button
@
click
=
"
goToPage(page + 1)
"
@
click
=
"
goToPage(page + 1)
"
:
disabled
=
"
page === totalPages
"
:
disabled
=
"
page === totalPages
"
class
=
"
relative inline-flex items-center px-4 py-2
ml-3
text-sm font-medium text-gray-700
dark:text-gray-200 bg-white dark:bg-dark-700 border
border-
gray-3
00 dark:b
order
-dark-
6
00
rounded-md hover:bg
-gray-
5
0 dark:hover:bg-dark-600
disabled:opacity-50 disabled:cursor-not-allowed
"
class
=
"
relative
ml-3
inline-flex items-center
rounded-md border border-gray-300 bg-white
px-4 py-2 text-sm font-medium text-gray-700
hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:
border-
dark-6
00 dark:b
g
-dark-
7
00
dark:text
-gray-
20
0 dark:hover:bg-dark-600
"
>
>
{{
t
(
'
pagination.next
'
)
}}
{{
t
(
'
pagination.next
'
)
}}
<
/button
>
<
/button
>
...
@@ -36,8 +38,10 @@
...
@@ -36,8 +38,10 @@
<!--
Page
size
selector
-->
<!--
Page
size
selector
-->
<
div
class
=
"
flex items-center space-x-2
"
>
<
div
class
=
"
flex items-center space-x-2
"
>
<
span
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
pagination.perPage
'
)
}}
:
<
/span
>
<
span
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
<
div
class
=
"
w-20 page-size-select
"
>
>
{{
t
(
'
pagination.perPage
'
)
}}
:
<
/spa
n
>
<
div
class
=
"
page-size-select w-20
"
>
<
Select
<
Select
:
model
-
value
=
"
pageSize
"
:
model
-
value
=
"
pageSize
"
:
options
=
"
pageSizeSelectOptions
"
:
options
=
"
pageSizeSelectOptions
"
...
@@ -56,10 +60,10 @@
...
@@ -56,10 +60,10 @@
<
button
<
button
@
click
=
"
goToPage(page - 1)
"
@
click
=
"
goToPage(page - 1)
"
:
disabled
=
"
page === 1
"
:
disabled
=
"
page === 1
"
class
=
"
relative inline-flex items-center
px-2 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 bg-white dark:bg-dark-700 border
border-
gray-3
00 dark:b
order
-dark-
6
00
rounded-l-md hover:bg
-gray-
5
0 dark:hover:bg-dark-600
disabled:opacity-50 disabled:cursor-not-allowed
"
class
=
"
relative inline-flex items-center
rounded-l-md border border-gray-300 bg-white px-2 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:
border-
dark-6
00 dark:b
g
-dark-
7
00
dark:text
-gray-
40
0 dark:hover:bg-dark-600
"
:
aria
-
label
=
"
t('pagination.previous')
"
:
aria
-
label
=
"
t('pagination.previous')
"
>
>
<
svg
class
=
"
w
-5
h
-5
"
fill
=
"
currentColor
"
viewBox
=
"
0 0 20 20
"
>
<
svg
class
=
"
h
-5
w
-5
"
fill
=
"
currentColor
"
viewBox
=
"
0 0 20 20
"
>
<
path
<
path
fill
-
rule
=
"
evenodd
"
fill
-
rule
=
"
evenodd
"
d
=
"
M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z
"
d
=
"
M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z
"
...
@@ -75,13 +79,15 @@
...
@@ -75,13 +79,15 @@
@
click
=
"
typeof pageNum === 'number' && goToPage(pageNum)
"
@
click
=
"
typeof pageNum === 'number' && goToPage(pageNum)
"
:
disabled
=
"
typeof pageNum !== 'number'
"
:
disabled
=
"
typeof pageNum !== 'number'
"
:
class
=
"
[
:
class
=
"
[
'relative inline-flex items-center px-4 py-2 text-sm font-medium
border
',
'relative inline-flex items-center
border
px-4 py-2 text-sm font-medium',
pageNum === page
pageNum === page
? 'z-10 b
g
-primary-50
dark:
bg-primary-
900/30 border
-primary-
5
00
text
-primary-
60
0 dark:text-primary-400'
? 'z-10 b
order
-primary-50
0
bg-primary-
50 text
-primary-
6
00
dark:bg
-primary-
900/3
0 dark:text-primary-400'
: 'b
g-white dark:bg-dark-700 border
-gray-
30
0 dark:border-dark-600
text-gray
-700 dark:text-gray-300
hover:bg-gray-50
dark:hover:bg-dark-600',
: 'b
order-gray-300 bg-white text-gray-700 hover:bg
-gray-
5
0 dark:border-dark-600
dark:bg-dark
-700 dark:text-gray-300 dark:hover:bg-dark-600',
typeof pageNum !== 'number' && 'cursor-default'
typeof pageNum !== 'number' && 'cursor-default'
]
"
]
"
:
aria
-
label
=
"
typeof pageNum === 'number' ? t('pagination.goToPage', { page: pageNum
}
) : undefined
"
:
aria
-
label
=
"
typeof pageNum === 'number' ? t('pagination.goToPage', { page: pageNum
}
) : undefined
"
:
aria
-
current
=
"
pageNum === page ? 'page' : undefined
"
:
aria
-
current
=
"
pageNum === page ? 'page' : undefined
"
>
>
{{
pageNum
}}
{{
pageNum
}}
...
@@ -91,10 +97,10 @@
...
@@ -91,10 +97,10 @@
<
button
<
button
@
click
=
"
goToPage(page + 1)
"
@
click
=
"
goToPage(page + 1)
"
:
disabled
=
"
page === totalPages
"
:
disabled
=
"
page === totalPages
"
class
=
"
relative inline-flex items-center
px-2 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 bg-white dark:bg-dark-700 border
border-
gray-3
00 dark:b
order
-dark-
6
00
rounded-r-md hover:bg
-gray-
5
0 dark:hover:bg-dark-600
disabled:opacity-50 disabled:cursor-not-allowed
"
class
=
"
relative inline-flex items-center
rounded-r-md border border-gray-300 bg-white px-2 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:
border-
dark-6
00 dark:b
g
-dark-
7
00
dark:text
-gray-
40
0 dark:hover:bg-dark-600
"
:
aria
-
label
=
"
t('pagination.next')
"
:
aria
-
label
=
"
t('pagination.next')
"
>
>
<
svg
class
=
"
w
-5
h
-5
"
fill
=
"
currentColor
"
viewBox
=
"
0 0 20 20
"
>
<
svg
class
=
"
h
-5
w
-5
"
fill
=
"
currentColor
"
viewBox
=
"
0 0 20 20
"
>
<
path
<
path
fill
-
rule
=
"
evenodd
"
fill
-
rule
=
"
evenodd
"
d
=
"
M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z
"
d
=
"
M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z
"
...
@@ -145,7 +151,7 @@ const toItem = computed(() => {
...
@@ -145,7 +151,7 @@ const toItem = computed(() => {
}
)
}
)
const
pageSizeSelectOptions
=
computed
(()
=>
{
const
pageSizeSelectOptions
=
computed
(()
=>
{
return
props
.
pageSizeOptions
.
map
(
size
=>
({
return
props
.
pageSizeOptions
.
map
(
(
size
)
=>
({
value
:
size
,
value
:
size
,
label
:
String
(
size
)
label
:
String
(
size
)
}
))
}
))
...
@@ -209,6 +215,6 @@ const handlePageSizeChange = (value: string | number | null) => {
...
@@ -209,6 +215,6 @@ const handlePageSizeChange = (value: string | number | null) => {
<
style
scoped
>
<
style
scoped
>
.
page
-
size
-
select
:
deep
(.
select
-
trigger
)
{
.
page
-
size
-
select
:
deep
(.
select
-
trigger
)
{
@
apply
py
-
1.5
px
-
3
text
-
sm
;
@
apply
p
x
-
3
p
y
-
1.5
text
-
sm
;
}
}
<
/style
>
<
/style
>
Prev
1
2
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