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
0170d19f
Commit
0170d19f
authored
Feb 02, 2026
by
song
Browse files
merge upstream main
parent
7ade9baa
Changes
319
Show whitespace changes
Inline
Side-by-side
frontend/src/components/account/AccountStatusIndicator.vue
View file @
0170d19f
<
template
>
<div
class=
"flex items-center gap-2"
>
<!-- Main Status Badge -->
<!-- Rate Limit Display (429) - Two-line layout -->
<div
v-if=
"isRateLimited"
class=
"flex flex-col items-center gap-1"
>
<span
class=
"badge text-xs badge-warning"
>
{{
t
(
'
admin.accounts.status.rateLimited
'
)
}}
</span>
<span
class=
"text-[11px] text-gray-400 dark:text-gray-500"
>
{{
rateLimitCountdown
}}
</span>
</div>
<!-- Overload Display (529) - Two-line layout -->
<div
v-else-if=
"isOverloaded"
class=
"flex flex-col items-center gap-1"
>
<span
class=
"badge text-xs badge-danger"
>
{{
t
(
'
admin.accounts.status.overloaded
'
)
}}
</span>
<span
class=
"text-[11px] text-gray-400 dark:text-gray-500"
>
{{
overloadCountdown
}}
</span>
</div>
<!-- Main Status Badge (shown when not rate limited/overloaded) -->
<template
v-else
>
<button
v-if=
"isTempUnschedulable"
type=
"button"
...
...
@@ -13,6 +26,7 @@
<span
v-else
:class=
"['badge text-xs', statusClass]"
>
{{
statusText
}}
</span>
</
template
>
<!-- Error Info Indicator -->
<div
v-if=
"hasError && account.error_message"
class=
"group/error relative"
>
...
...
@@ -42,7 +56,6 @@
></div>
</div>
</div>
<!-- Rate Limit Indicator (429) -->
<div
v-if=
"isRateLimited"
class=
"group relative"
>
<span
...
...
@@ -108,8 +121,7 @@
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
type
{
Account
}
from
'
@/types
'
import
{
formatTime
}
from
'
@/utils/format
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
formatCountdownWithSuffix
}
from
'
@/utils/format
'
const
{
t
}
=
useI18n
()
...
...
@@ -163,6 +175,16 @@ const hasError = computed(() => {
return
props
.
account
.
status
===
'
error
'
}
)
// Computed: countdown text for rate limit (429)
const
rateLimitCountdown
=
computed
(()
=>
{
return
formatCountdownWithSuffix
(
props
.
account
.
rate_limit_reset_at
)
}
)
// Computed: countdown text for overload (529)
const
overloadCountdown
=
computed
(()
=>
{
return
formatCountdownWithSuffix
(
props
.
account
.
overload_until
)
}
)
// Computed: status badge class
const
statusClass
=
computed
(()
=>
{
if
(
hasError
.
value
)
{
...
...
@@ -171,7 +193,7 @@ const statusClass = computed(() => {
if
(
isTempUnschedulable
.
value
)
{
return
'
badge-warning
'
}
if
(
!
props
.
account
.
schedulable
||
isRateLimited
.
value
||
isOverloaded
.
value
)
{
if
(
!
props
.
account
.
schedulable
)
{
return
'
badge-gray
'
}
switch
(
props
.
account
.
status
)
{
...
...
@@ -197,9 +219,6 @@ const statusText = computed(() => {
if
(
!
props
.
account
.
schedulable
)
{
return
t
(
'
admin.accounts.status.paused
'
)
}
if
(
isRateLimited
.
value
||
isOverloaded
.
value
)
{
return
t
(
'
admin.accounts.status.limited
'
)
}
return
t
(
`admin.accounts.status.${props.account.status
}
`
)
}
)
...
...
@@ -207,5 +226,4 @@ const handleTempUnschedClick = () => {
if
(
!
isTempUnschedulable
.
value
)
return
emit
(
'
show-temp-unsched
'
,
props
.
account
)
}
<
/script
>
frontend/src/components/account/AccountTestModal.vue
View file @
0170d19f
...
...
@@ -292,8 +292,11 @@ const loadAvailableModels = async () => {
if
(
availableModels
.
value
.
length
>
0
)
{
if
(
props
.
account
.
platform
===
'
gemini
'
)
{
const
preferred
=
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-2.0-flash
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-2.5-flash
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-2.5-pro
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-3-pro
'
)
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-3-flash-preview
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-3-pro-preview
'
)
selectedModelId
.
value
=
preferred
?.
id
||
availableModels
.
value
[
0
].
id
}
else
{
// Try to select Sonnet as default, otherwise use first model
...
...
frontend/src/components/account/BulkEditAccountModal.vue
View file @
0170d19f
...
...
@@ -648,7 +648,7 @@ import { ref, watch, computed } from 'vue'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Proxy
,
Group
}
from
'
@/types
'
import
type
{
Proxy
,
Admin
Group
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
ProxySelector
from
'
@/components/common/ProxySelector.vue
'
...
...
@@ -659,7 +659,7 @@ interface Props {
show
:
boolean
accountIds
:
number
[]
proxies
:
Proxy
[]
groups
:
Group
[]
groups
:
Admin
Group
[]
}
const
props
=
defineProps
<
Props
>
()
...
...
frontend/src/components/account/CreateAccountModal.vue
View file @
0170d19f
...
...
@@ -1159,9 +1159,9 @@
<
/div
>
<
/div
>
<!--
Intercept
Warmup
Requests
(
Anthropic
/
Antigravit
y
)
-->
<!--
Intercept
Warmup
Requests
(
Anthropic
onl
y
)
-->
<
div
v
-
if
=
"
form.platform === 'anthropic'
|| form.platform === 'antigravity'
"
v
-
if
=
"
form.platform === 'anthropic'
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600
"
>
<
div
class
=
"
flex items-center justify-between
"
>
...
...
@@ -1191,6 +1191,190 @@
<
/div
>
<
/div
>
<!--
Quota
Control
Section
(
Anthropic
OAuth
/
SetupToken
only
)
-->
<
div
v
-
if
=
"
form.platform === 'anthropic' && accountCategory === 'oauth-based'
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4
"
>
<
div
class
=
"
mb-3
"
>
<
h3
class
=
"
input-label mb-0 text-base font-semibold
"
>
{{
t
(
'
admin.accounts.quotaControl.title
'
)
}}
<
/h3
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.quotaControl.hint
'
)
}}
<
/p
>
<
/div
>
<!--
Window
Cost
Limit
-->
<
div
class
=
"
rounded-lg border border-gray-200 p-4 dark:border-dark-600
"
>
<
div
class
=
"
mb-3 flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.quotaControl.windowCost.label
'
)
}}
<
/label
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.quotaControl.windowCost.hint
'
)
}}
<
/p
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
windowCostEnabled = !windowCostEnabled
"
:
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',
windowCostEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]
"
>
<
span
:
class
=
"
[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
windowCostEnabled ? 'translate-x-5' : 'translate-x-0'
]
"
/>
<
/button
>
<
/div
>
<
div
v
-
if
=
"
windowCostEnabled
"
class
=
"
grid grid-cols-2 gap-4
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaControl.windowCost.limit
'
)
}}
<
/label
>
<
div
class
=
"
relative
"
>
<
span
class
=
"
absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400
"
>
$
<
/span
>
<
input
v
-
model
.
number
=
"
windowCostLimit
"
type
=
"
number
"
min
=
"
0
"
step
=
"
1
"
class
=
"
input pl-7
"
:
placeholder
=
"
t('admin.accounts.quotaControl.windowCost.limitPlaceholder')
"
/>
<
/div
>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.quotaControl.windowCost.limitHint
'
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaControl.windowCost.stickyReserve
'
)
}}
<
/label
>
<
div
class
=
"
relative
"
>
<
span
class
=
"
absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400
"
>
$
<
/span
>
<
input
v
-
model
.
number
=
"
windowCostStickyReserve
"
type
=
"
number
"
min
=
"
0
"
step
=
"
1
"
class
=
"
input pl-7
"
:
placeholder
=
"
t('admin.accounts.quotaControl.windowCost.stickyReservePlaceholder')
"
/>
<
/div
>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.quotaControl.windowCost.stickyReserveHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
<!--
Session
Limit
-->
<
div
class
=
"
rounded-lg border border-gray-200 p-4 dark:border-dark-600
"
>
<
div
class
=
"
mb-3 flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionLimit.label
'
)
}}
<
/label
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionLimit.hint
'
)
}}
<
/p
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
sessionLimitEnabled = !sessionLimitEnabled
"
:
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',
sessionLimitEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]
"
>
<
span
:
class
=
"
[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
sessionLimitEnabled ? 'translate-x-5' : 'translate-x-0'
]
"
/>
<
/button
>
<
/div
>
<
div
v
-
if
=
"
sessionLimitEnabled
"
class
=
"
grid grid-cols-2 gap-4
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionLimit.maxSessions
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
maxSessions
"
type
=
"
number
"
min
=
"
1
"
step
=
"
1
"
class
=
"
input
"
:
placeholder
=
"
t('admin.accounts.quotaControl.sessionLimit.maxSessionsPlaceholder')
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionLimit.maxSessionsHint
'
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionLimit.idleTimeout
'
)
}}
<
/label
>
<
div
class
=
"
relative
"
>
<
input
v
-
model
.
number
=
"
sessionIdleTimeout
"
type
=
"
number
"
min
=
"
1
"
step
=
"
1
"
class
=
"
input pr-12
"
:
placeholder
=
"
t('admin.accounts.quotaControl.sessionLimit.idleTimeoutPlaceholder')
"
/>
<
span
class
=
"
absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
common.minutes
'
)
}}
<
/span
>
<
/div
>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionLimit.idleTimeoutHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
<!--
TLS
Fingerprint
-->
<
div
class
=
"
rounded-lg border border-gray-200 p-4 dark:border-dark-600
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.quotaControl.tlsFingerprint.label
'
)
}}
<
/label
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.quotaControl.tlsFingerprint.hint
'
)
}}
<
/p
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
tlsFingerprintEnabled = !tlsFingerprintEnabled
"
:
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',
tlsFingerprintEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]
"
>
<
span
:
class
=
"
[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
tlsFingerprintEnabled ? 'translate-x-5' : 'translate-x-0'
]
"
/>
<
/button
>
<
/div
>
<
/div
>
<!--
Session
ID
Masking
-->
<
div
class
=
"
rounded-lg border border-gray-200 p-4 dark:border-dark-600
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionIdMasking.label
'
)
}}
<
/label
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionIdMasking.hint
'
)
}}
<
/p
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
sessionIdMaskingEnabled = !sessionIdMaskingEnabled
"
:
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',
sessionIdMaskingEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]
"
>
<
span
:
class
=
"
[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
sessionIdMaskingEnabled ? 'translate-x-5' : 'translate-x-0'
]
"
/>
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.proxy
'
)
}}
<
/label
>
<
ProxySelector
v
-
model
=
"
form.proxy_id
"
:
proxies
=
"
proxies
"
/>
...
...
@@ -1214,7 +1398,7 @@
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.billingRateMultiplier
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
form.rate_multiplier
"
type
=
"
number
"
min
=
"
0
"
step
=
"
0.01
"
class
=
"
input
"
/>
<
input
v
-
model
.
number
=
"
form.rate_multiplier
"
type
=
"
number
"
min
=
"
0
"
step
=
"
0.
0
01
"
class
=
"
input
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.billingRateMultiplierHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
...
...
@@ -1632,7 +1816,7 @@ import {
import
{
useOpenAIOAuth
}
from
'
@/composables/useOpenAIOAuth
'
import
{
useGeminiOAuth
}
from
'
@/composables/useGeminiOAuth
'
import
{
useAntigravityOAuth
}
from
'
@/composables/useAntigravityOAuth
'
import
type
{
Proxy
,
Group
,
AccountPlatform
,
AccountType
}
from
'
@/types
'
import
type
{
Proxy
,
Admin
Group
,
AccountPlatform
,
AccountType
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
ProxySelector
from
'
@/components/common/ProxySelector.vue
'
...
...
@@ -1678,7 +1862,7 @@ const apiKeyHint = computed(() => {
interface
Props
{
show
:
boolean
proxies
:
Proxy
[]
groups
:
Group
[]
groups
:
Admin
Group
[]
}
const
props
=
defineProps
<
Props
>
()
...
...
@@ -1763,6 +1947,16 @@ const geminiAIStudioOAuthEnabled = ref(false)
const
showAdvancedOAuth
=
ref
(
false
)
const
showGeminiHelpDialog
=
ref
(
false
)
// Quota control state (Anthropic OAuth/SetupToken only)
const
windowCostEnabled
=
ref
(
false
)
const
windowCostLimit
=
ref
<
number
|
null
>
(
null
)
const
windowCostStickyReserve
=
ref
<
number
|
null
>
(
null
)
const
sessionLimitEnabled
=
ref
(
false
)
const
maxSessions
=
ref
<
number
|
null
>
(
null
)
const
sessionIdleTimeout
=
ref
<
number
|
null
>
(
null
)
const
tlsFingerprintEnabled
=
ref
(
false
)
const
sessionIdMaskingEnabled
=
ref
(
false
)
// Gemini tier selection (used as fallback when auto-detection is unavailable/fails)
const
geminiTierGoogleOne
=
ref
<
'
google_one_free
'
|
'
google_ai_pro
'
|
'
google_ai_ultra
'
>
(
'
google_one_free
'
)
const
geminiTierGcp
=
ref
<
'
gcp_standard
'
|
'
gcp_enterprise
'
>
(
'
gcp_standard
'
)
...
...
@@ -2140,6 +2334,15 @@ const resetForm = () => {
customErrorCodeInput
.
value
=
null
interceptWarmupRequests
.
value
=
false
autoPauseOnExpired
.
value
=
true
// Reset quota control state
windowCostEnabled
.
value
=
false
windowCostLimit
.
value
=
null
windowCostStickyReserve
.
value
=
null
sessionLimitEnabled
.
value
=
false
maxSessions
.
value
=
null
sessionIdleTimeout
.
value
=
null
tlsFingerprintEnabled
.
value
=
false
sessionIdMaskingEnabled
.
value
=
false
tempUnschedEnabled
.
value
=
false
tempUnschedRules
.
value
=
[]
geminiOAuthType
.
value
=
'
code_assist
'
...
...
@@ -2407,7 +2610,32 @@ const handleAnthropicExchange = async (authCode: string) => {
...
proxyConfig
}
)
const
extra
=
oauth
.
buildExtraInfo
(
tokenInfo
)
// Build extra with quota control settings
const
baseExtra
=
oauth
.
buildExtraInfo
(
tokenInfo
)
||
{
}
const
extra
:
Record
<
string
,
unknown
>
=
{
...
baseExtra
}
// Add window cost limit settings
if
(
windowCostEnabled
.
value
&&
windowCostLimit
.
value
!=
null
&&
windowCostLimit
.
value
>
0
)
{
extra
.
window_cost_limit
=
windowCostLimit
.
value
extra
.
window_cost_sticky_reserve
=
windowCostStickyReserve
.
value
??
10
}
// Add session limit settings
if
(
sessionLimitEnabled
.
value
&&
maxSessions
.
value
!=
null
&&
maxSessions
.
value
>
0
)
{
extra
.
max_sessions
=
maxSessions
.
value
extra
.
session_idle_timeout_minutes
=
sessionIdleTimeout
.
value
??
5
}
// Add TLS fingerprint settings
if
(
tlsFingerprintEnabled
.
value
)
{
extra
.
enable_tls_fingerprint
=
true
}
// Add session ID masking settings
if
(
sessionIdMaskingEnabled
.
value
)
{
extra
.
session_id_masking_enabled
=
true
}
const
credentials
=
{
...
tokenInfo
,
...(
interceptWarmupRequests
.
value
?
{
intercept_warmup_requests
:
true
}
:
{
}
)
...
...
@@ -2475,7 +2703,32 @@ const handleCookieAuth = async (sessionKey: string) => {
...
proxyConfig
}
)
const
extra
=
oauth
.
buildExtraInfo
(
tokenInfo
)
// Build extra with quota control settings
const
baseExtra
=
oauth
.
buildExtraInfo
(
tokenInfo
)
||
{
}
const
extra
:
Record
<
string
,
unknown
>
=
{
...
baseExtra
}
// Add window cost limit settings
if
(
windowCostEnabled
.
value
&&
windowCostLimit
.
value
!=
null
&&
windowCostLimit
.
value
>
0
)
{
extra
.
window_cost_limit
=
windowCostLimit
.
value
extra
.
window_cost_sticky_reserve
=
windowCostStickyReserve
.
value
??
10
}
// Add session limit settings
if
(
sessionLimitEnabled
.
value
&&
maxSessions
.
value
!=
null
&&
maxSessions
.
value
>
0
)
{
extra
.
max_sessions
=
maxSessions
.
value
extra
.
session_idle_timeout_minutes
=
sessionIdleTimeout
.
value
??
5
}
// Add TLS fingerprint settings
if
(
tlsFingerprintEnabled
.
value
)
{
extra
.
enable_tls_fingerprint
=
true
}
// Add session ID masking settings
if
(
sessionIdMaskingEnabled
.
value
)
{
extra
.
session_id_masking_enabled
=
true
}
const
accountName
=
keys
.
length
>
1
?
`${form.name
}
#${i + 1
}
`
:
form
.
name
// Merge interceptWarmupRequests into credentials
...
...
frontend/src/components/account/EditAccountModal.vue
View file @
0170d19f
...
...
@@ -512,9 +512,9 @@
<
/div
>
<
/div
>
<!--
Intercept
Warmup
Requests
(
Anthropic
/
Antigravit
y
)
-->
<!--
Intercept
Warmup
Requests
(
Anthropic
onl
y
)
-->
<
div
v
-
if
=
"
account?.platform === 'anthropic'
|| account?.platform === 'antigravity'
"
v
-
if
=
"
account?.platform === 'anthropic'
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600
"
>
<
div
class
=
"
flex items-center justify-between
"
>
...
...
@@ -566,7 +566,7 @@
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.billingRateMultiplier
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
form.rate_multiplier
"
type
=
"
number
"
min
=
"
0
"
step
=
"
0.01
"
class
=
"
input
"
/>
<
input
v
-
model
.
number
=
"
form.rate_multiplier
"
type
=
"
number
"
min
=
"
0
"
step
=
"
0.
0
01
"
class
=
"
input
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.billingRateMultiplierHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
...
...
@@ -732,6 +732,60 @@
<
/div
>
<
/div
>
<
/div
>
<!--
TLS
Fingerprint
-->
<
div
class
=
"
rounded-lg border border-gray-200 p-4 dark:border-dark-600
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.quotaControl.tlsFingerprint.label
'
)
}}
<
/label
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.quotaControl.tlsFingerprint.hint
'
)
}}
<
/p
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
tlsFingerprintEnabled = !tlsFingerprintEnabled
"
:
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',
tlsFingerprintEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]
"
>
<
span
:
class
=
"
[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
tlsFingerprintEnabled ? 'translate-x-5' : 'translate-x-0'
]
"
/>
<
/button
>
<
/div
>
<
/div
>
<!--
Session
ID
Masking
-->
<
div
class
=
"
rounded-lg border border-gray-200 p-4 dark:border-dark-600
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionIdMasking.label
'
)
}}
<
/label
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionIdMasking.hint
'
)
}}
<
/p
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
sessionIdMaskingEnabled = !sessionIdMaskingEnabled
"
:
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',
sessionIdMaskingEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]
"
>
<
span
:
class
=
"
[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
sessionIdMaskingEnabled ? 'translate-x-5' : 'translate-x-0'
]
"
/>
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<
div
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600
"
>
...
...
@@ -829,7 +883,7 @@ import { useI18n } from 'vue-i18n'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Account
,
Proxy
,
Group
}
from
'
@/types
'
import
type
{
Account
,
Proxy
,
Admin
Group
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
...
...
@@ -847,7 +901,7 @@ interface Props {
show
:
boolean
account
:
Account
|
null
proxies
:
Proxy
[]
groups
:
Group
[]
groups
:
Admin
Group
[]
}
const
props
=
defineProps
<
Props
>
()
...
...
@@ -904,6 +958,8 @@ const windowCostStickyReserve = ref<number | null>(null)
const
sessionLimitEnabled
=
ref
(
false
)
const
maxSessions
=
ref
<
number
|
null
>
(
null
)
const
sessionIdleTimeout
=
ref
<
number
|
null
>
(
null
)
const
tlsFingerprintEnabled
=
ref
(
false
)
const
sessionIdMaskingEnabled
=
ref
(
false
)
// Computed: current preset mappings based on platform
const
presetMappings
=
computed
(()
=>
getPresetMappingsByPlatform
(
props
.
account
?.
platform
||
'
anthropic
'
))
...
...
@@ -1237,6 +1293,8 @@ function loadQuotaControlSettings(account: Account) {
sessionLimitEnabled
.
value
=
false
maxSessions
.
value
=
null
sessionIdleTimeout
.
value
=
null
tlsFingerprintEnabled
.
value
=
false
sessionIdMaskingEnabled
.
value
=
false
// Only applies to Anthropic OAuth/SetupToken accounts
if
(
account
.
platform
!==
'
anthropic
'
||
(
account
.
type
!==
'
oauth
'
&&
account
.
type
!==
'
setup-token
'
))
{
...
...
@@ -1255,6 +1313,16 @@ function loadQuotaControlSettings(account: Account) {
maxSessions
.
value
=
account
.
max_sessions
sessionIdleTimeout
.
value
=
account
.
session_idle_timeout_minutes
??
5
}
// Load TLS fingerprint setting
if
(
account
.
enable_tls_fingerprint
===
true
)
{
tlsFingerprintEnabled
.
value
=
true
}
// Load session ID masking setting
if
(
account
.
session_id_masking_enabled
===
true
)
{
sessionIdMaskingEnabled
.
value
=
true
}
}
function
formatTempUnschedKeywords
(
value
:
unknown
)
{
...
...
@@ -1407,6 +1475,20 @@ const handleSubmit = async () => {
delete
newExtra
.
session_idle_timeout_minutes
}
// TLS fingerprint setting
if
(
tlsFingerprintEnabled
.
value
)
{
newExtra
.
enable_tls_fingerprint
=
true
}
else
{
delete
newExtra
.
enable_tls_fingerprint
}
// Session ID masking setting
if
(
sessionIdMaskingEnabled
.
value
)
{
newExtra
.
session_id_masking_enabled
=
true
}
else
{
delete
newExtra
.
session_id_masking_enabled
}
updatePayload
.
extra
=
newExtra
}
...
...
frontend/src/components/admin/account/AccountActionMenu.vue
View file @
0170d19f
<
template
>
<Teleport
to=
"body"
>
<div
v-if=
"show && position"
class=
"action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800"
:style=
"
{ top: position.top + 'px', left: position.left + 'px' }">
<div
v-if=
"show && position"
>
<!-- Backdrop: click anywhere outside to close -->
<div
class=
"fixed inset-0 z-[9998]"
@
click=
"emit('close')"
></div>
<div
class=
"action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800"
:style=
"
{ top: position.top + 'px', left: position.left + 'px' }"
@click.stop
>
<div
class=
"py-1"
>
<template
v-if=
"account"
>
<button
@
click=
"$emit('test', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
>
...
...
@@ -33,18 +40,39 @@
</template>
</div>
</div>
</div>
</Teleport>
</template>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
computed
,
watch
,
onUnmounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
Icon
}
from
'
@/components/icons
'
import
type
{
Account
}
from
'
@/types
'
const
props
=
defineProps
<
{
show
:
boolean
;
account
:
Account
|
null
;
position
:
{
top
:
number
;
left
:
number
}
|
null
}
>
()
defineEmits
([
'
close
'
,
'
test
'
,
'
stats
'
,
'
reauth
'
,
'
refresh-token
'
,
'
reset-status
'
,
'
clear-rate-limit
'
])
const
emit
=
defineEmits
([
'
close
'
,
'
test
'
,
'
stats
'
,
'
reauth
'
,
'
refresh-token
'
,
'
reset-status
'
,
'
clear-rate-limit
'
])
const
{
t
}
=
useI18n
()
const
isRateLimited
=
computed
(()
=>
props
.
account
?.
rate_limit_reset_at
&&
new
Date
(
props
.
account
.
rate_limit_reset_at
)
>
new
Date
())
const
isOverloaded
=
computed
(()
=>
props
.
account
?.
overload_until
&&
new
Date
(
props
.
account
.
overload_until
)
>
new
Date
())
const
handleKeydown
=
(
event
:
KeyboardEvent
)
=>
{
if
(
event
.
key
===
'
Escape
'
)
emit
(
'
close
'
)
}
watch
(
()
=>
props
.
show
,
(
visible
)
=>
{
if
(
visible
)
{
window
.
addEventListener
(
'
keydown
'
,
handleKeydown
)
}
else
{
window
.
removeEventListener
(
'
keydown
'
,
handleKeydown
)
}
},
{
immediate
:
true
}
)
onUnmounted
(()
=>
{
window
.
removeEventListener
(
'
keydown
'
,
handleKeydown
)
})
</
script
>
frontend/src/components/admin/account/AccountTableActions.vue
View file @
0170d19f
<
template
>
<div
class=
"flex flex-wrap items-center gap-3"
>
<slot
name=
"before"
></slot>
<button
@
click=
"$emit('refresh')"
:disabled=
"loading"
class=
"btn btn-secondary"
>
<Icon
name=
"refresh"
size=
"md"
:class=
"[loading ? 'animate-spin' : '']"
/>
</button>
<slot
name=
"after"
></slot>
<button
@
click=
"$emit('sync')"
class=
"btn btn-secondary"
>
{{
t
(
'
admin.accounts.syncFromCrs
'
)
}}
</button>
<button
@
click=
"$emit('create')"
class=
"btn btn-primary"
>
{{
t
(
'
admin.accounts.createAccount
'
)
}}
</button>
</div>
...
...
frontend/src/components/admin/account/AccountTestModal.vue
View file @
0170d19f
...
...
@@ -232,8 +232,11 @@ const loadAvailableModels = async () => {
if
(
availableModels
.
value
.
length
>
0
)
{
if
(
props
.
account
.
platform
===
'
gemini
'
)
{
const
preferred
=
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-2.0-flash
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-2.5-flash
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-2.5-pro
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-3-pro
'
)
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-3-flash-preview
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-3-pro-preview
'
)
selectedModelId
.
value
=
preferred
?.
id
||
availableModels
.
value
[
0
].
id
}
else
{
// Try to select Sonnet as default, otherwise use first model
...
...
frontend/src/components/admin/announcements/AnnouncementReadStatusDialog.vue
0 → 100644
View file @
0170d19f
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.announcements.readStatus')"
width=
"extra-wide"
@
close=
"handleClose"
>
<div
class=
"space-y-4"
>
<div
class=
"flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<div
class=
"flex-1"
>
<input
v-model=
"search"
type=
"text"
class=
"input"
:placeholder=
"t('admin.announcements.searchUsers')"
@
input=
"handleSearch"
/>
</div>
<button
@
click=
"load"
:disabled=
"loading"
class=
"btn btn-secondary"
:title=
"t('common.refresh')"
>
<Icon
name=
"refresh"
size=
"md"
:class=
"loading ? 'animate-spin' : ''"
/>
</button>
</div>
<DataTable
:columns=
"columns"
:data=
"items"
:loading=
"loading"
>
<template
#cell-email
="
{ value }">
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
value
}}
</span>
</
template
>
<
template
#cell-balance=
"{ value }"
>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
$
{{
Number
(
value
??
0
).
toFixed
(
2
)
}}
</span>
</
template
>
<
template
#cell-eligible=
"{ value }"
>
<span
:class=
"['badge', value ? 'badge-success' : 'badge-gray']"
>
{{
value
?
t
(
'
admin.announcements.eligible
'
)
:
t
(
'
common.no
'
)
}}
</span>
</
template
>
<
template
#cell-read_at=
"{ value }"
>
<span
class=
"text-sm text-gray-500 dark:text-dark-400"
>
{{
value
?
formatDateTime
(
value
)
:
t
(
'
admin.announcements.unread
'
)
}}
</span>
</
template
>
</DataTable>
<Pagination
v-if=
"pagination.total > 0"
:page=
"pagination.page"
:total=
"pagination.total"
:page-size=
"pagination.page_size"
@
update:page=
"handlePageChange"
@
update:pageSize=
"handlePageSizeChange"
/>
</div>
<
template
#footer
>
<div
class=
"flex justify-end"
>
<button
type=
"button"
class=
"btn btn-secondary"
@
click=
"handleClose"
>
{{
t
(
'
common.close
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
computed
,
onMounted
,
reactive
,
ref
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
formatDateTime
}
from
'
@/utils/format
'
import
type
{
AnnouncementUserReadStatus
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
props
=
defineProps
<
{
show
:
boolean
announcementId
:
number
|
null
}
>
()
const
emit
=
defineEmits
<
{
(
e
:
'
close
'
):
void
}
>
()
const
loading
=
ref
(
false
)
const
search
=
ref
(
''
)
const
pagination
=
reactive
({
page
:
1
,
page_size
:
20
,
total
:
0
,
pages
:
0
})
const
items
=
ref
<
AnnouncementUserReadStatus
[]
>
([])
const
columns
=
computed
<
Column
[]
>
(()
=>
[
{
key
:
'
email
'
,
label
:
t
(
'
common.email
'
)
},
{
key
:
'
username
'
,
label
:
t
(
'
admin.users.columns.username
'
)
},
{
key
:
'
balance
'
,
label
:
t
(
'
common.balance
'
)
},
{
key
:
'
eligible
'
,
label
:
t
(
'
admin.announcements.eligible
'
)
},
{
key
:
'
read_at
'
,
label
:
t
(
'
admin.announcements.readAt
'
)
}
])
let
currentController
:
AbortController
|
null
=
null
async
function
load
()
{
if
(
!
props
.
show
||
!
props
.
announcementId
)
return
if
(
currentController
)
currentController
.
abort
()
currentController
=
new
AbortController
()
try
{
loading
.
value
=
true
const
res
=
await
adminAPI
.
announcements
.
getReadStatus
(
props
.
announcementId
,
pagination
.
page
,
pagination
.
page_size
,
search
.
value
)
items
.
value
=
res
.
items
pagination
.
total
=
res
.
total
pagination
.
pages
=
res
.
pages
pagination
.
page
=
res
.
page
pagination
.
page_size
=
res
.
page_size
}
catch
(
error
:
any
)
{
if
(
currentController
.
signal
.
aborted
||
error
?.
name
===
'
AbortError
'
)
return
console
.
error
(
'
Failed to load read status:
'
,
error
)
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.announcements.failedToLoadReadStatus
'
))
}
finally
{
loading
.
value
=
false
}
}
function
handlePageChange
(
page
:
number
)
{
pagination
.
page
=
page
load
()
}
function
handlePageSizeChange
(
pageSize
:
number
)
{
pagination
.
page_size
=
pageSize
pagination
.
page
=
1
load
()
}
let
searchDebounceTimer
:
number
|
null
=
null
function
handleSearch
()
{
if
(
searchDebounceTimer
)
window
.
clearTimeout
(
searchDebounceTimer
)
searchDebounceTimer
=
window
.
setTimeout
(()
=>
{
pagination
.
page
=
1
load
()
},
300
)
}
function
handleClose
()
{
emit
(
'
close
'
)
}
watch
(
()
=>
props
.
show
,
(
v
)
=>
{
if
(
!
v
)
return
pagination
.
page
=
1
load
()
}
)
watch
(
()
=>
props
.
announcementId
,
()
=>
{
if
(
!
props
.
show
)
return
pagination
.
page
=
1
load
()
}
)
onMounted
(()
=>
{
// noop
})
</
script
>
frontend/src/components/admin/announcements/AnnouncementTargetingEditor.vue
0 → 100644
View file @
0170d19f
<
template
>
<div
class=
"rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-700 dark:bg-dark-800/50"
>
<div
class=
"flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<div
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
admin.announcements.form.targetingMode
'
)
}}
</div>
<div
class=
"mt-1 text-xs text-gray-500 dark:text-dark-400"
>
{{
mode
===
'
all
'
?
t
(
'
admin.announcements.form.targetingAll
'
)
:
t
(
'
admin.announcements.form.targetingCustom
'
)
}}
</div>
</div>
<div
class=
"flex items-center gap-3"
>
<label
class=
"flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300"
>
<input
type=
"radio"
name=
"announcement-targeting-mode"
value=
"all"
:checked=
"mode === 'all'"
@
change=
"setMode('all')"
class=
"h-4 w-4"
/>
{{
t
(
'
admin.announcements.form.targetingAll
'
)
}}
</label>
<label
class=
"flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300"
>
<input
type=
"radio"
name=
"announcement-targeting-mode"
value=
"custom"
:checked=
"mode === 'custom'"
@
change=
"setMode('custom')"
class=
"h-4 w-4"
/>
{{
t
(
'
admin.announcements.form.targetingCustom
'
)
}}
</label>
</div>
</div>
<div
v-if=
"mode === 'custom'"
class=
"mt-4 space-y-4"
>
<div
class=
"flex items-center justify-between"
>
<div
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
OR
<span
class=
"ml-1 text-xs font-normal text-gray-500 dark:text-dark-400"
>
(
{{
anyOf
.
length
}}
/50)
</span>
</div>
<button
type=
"button"
class=
"btn btn-secondary"
:disabled=
"anyOf.length >= 50"
@
click=
"addOrGroup"
>
<Icon
name=
"plus"
size=
"sm"
class=
"mr-1"
/>
{{
t
(
'
admin.announcements.form.addOrGroup
'
)
}}
</button>
</div>
<div
v-if=
"anyOf.length === 0"
class=
"rounded-xl border border-dashed border-gray-300 p-4 text-sm text-gray-500 dark:border-dark-600 dark:text-dark-400"
>
{{
t
(
'
admin.announcements.form.targetingCustom
'
)
}}
:
{{
t
(
'
admin.announcements.form.addOrGroup
'
)
}}
</div>
<div
v-for=
"(group, groupIndex) in anyOf"
:key=
"groupIndex"
class=
"rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-dark-700 dark:bg-dark-800"
>
<div
class=
"flex items-start justify-between gap-3"
>
<div
class=
"min-w-0"
>
<div
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
admin.announcements.form.targetingCustom
'
)
}}
#
{{
groupIndex
+
1
}}
<span
class=
"ml-2 text-xs font-normal text-gray-500 dark:text-dark-400"
>
AND (
{{
(
group
.
all_of
?.
length
||
0
)
}}
/50)
</span>
</div>
<div
class=
"mt-1 text-xs text-gray-500 dark:text-dark-400"
>
{{
t
(
'
admin.announcements.form.addAndCondition
'
)
}}
</div>
</div>
<button
type=
"button"
class=
"btn btn-secondary"
@
click=
"removeOrGroup(groupIndex)"
>
<Icon
name=
"trash"
size=
"sm"
class=
"mr-1"
/>
{{
t
(
'
common.delete
'
)
}}
</button>
</div>
<div
class=
"mt-4 space-y-3"
>
<div
v-for=
"(cond, condIndex) in (group.all_of || [])"
:key=
"condIndex"
class=
"rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-dark-700 dark:bg-dark-900/30"
>
<div
class=
"flex flex-col gap-3 md:flex-row md:items-end"
>
<div
class=
"w-full md:w-52"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.announcements.form.conditionType
'
)
}}
</label>
<Select
:model-value=
"cond.type"
:options=
"conditionTypeOptions"
@
update:model-value=
"(v) => setConditionType(groupIndex, condIndex, v as any)"
/>
</div>
<div
v-if=
"cond.type === 'subscription'"
class=
"flex-1"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.announcements.form.selectPackages
'
)
}}
</label>
<GroupSelector
v-model=
"subscriptionSelections[groupIndex][condIndex]"
:groups=
"groups"
/>
</div>
<div
v-else
class=
"flex flex-1 flex-col gap-3 sm:flex-row"
>
<div
class=
"w-full sm:w-44"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.announcements.form.operator
'
)
}}
</label>
<Select
:model-value=
"cond.operator"
:options=
"balanceOperatorOptions"
@
update:model-value=
"(v) => setOperator(groupIndex, condIndex, v as any)"
/>
</div>
<div
class=
"w-full sm:flex-1"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.announcements.form.balanceValue
'
)
}}
</label>
<input
:value=
"String(cond.value ?? '')"
type=
"number"
step=
"any"
class=
"input"
@
input=
"(e) => setBalanceValue(groupIndex, condIndex, (e.target as HTMLInputElement).value)"
/>
</div>
</div>
<div
class=
"flex justify-end"
>
<button
type=
"button"
class=
"btn btn-secondary"
@
click=
"removeAndCondition(groupIndex, condIndex)"
>
<Icon
name=
"trash"
size=
"sm"
class=
"mr-1"
/>
{{
t
(
'
common.delete
'
)
}}
</button>
</div>
</div>
</div>
<div
class=
"flex justify-end"
>
<button
type=
"button"
class=
"btn btn-secondary"
:disabled=
"(group.all_of?.length || 0) >= 50"
@
click=
"addAndCondition(groupIndex)"
>
<Icon
name=
"plus"
size=
"sm"
class=
"mr-1"
/>
{{
t
(
'
admin.announcements.form.addAndCondition
'
)
}}
</button>
</div>
</div>
</div>
<div
v-if=
"validationError"
class=
"rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900/30 dark:bg-red-900/10 dark:text-red-300"
>
{{
validationError
}}
</div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
,
reactive
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
type
{
AdminGroup
,
AnnouncementTargeting
,
AnnouncementCondition
,
AnnouncementConditionGroup
,
AnnouncementConditionType
,
AnnouncementOperator
}
from
'
@/types
'
import
Select
from
'
@/components/common/Select.vue
'
import
GroupSelector
from
'
@/components/common/GroupSelector.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
const
{
t
}
=
useI18n
()
const
props
=
defineProps
<
{
modelValue
:
AnnouncementTargeting
groups
:
AdminGroup
[]
}
>
()
const
emit
=
defineEmits
<
{
(
e
:
'
update:modelValue
'
,
value
:
AnnouncementTargeting
):
void
}
>
()
const
anyOf
=
computed
(()
=>
props
.
modelValue
?.
any_of
??
[])
type
Mode
=
'
all
'
|
'
custom
'
const
mode
=
computed
<
Mode
>
(()
=>
(
anyOf
.
value
.
length
===
0
?
'
all
'
:
'
custom
'
))
const
conditionTypeOptions
=
computed
(()
=>
[
{
value
:
'
subscription
'
,
label
:
t
(
'
admin.announcements.form.conditionSubscription
'
)
},
{
value
:
'
balance
'
,
label
:
t
(
'
admin.announcements.form.conditionBalance
'
)
}
])
const
balanceOperatorOptions
=
computed
(()
=>
[
{
value
:
'
gt
'
,
label
:
t
(
'
admin.announcements.operators.gt
'
)
},
{
value
:
'
gte
'
,
label
:
t
(
'
admin.announcements.operators.gte
'
)
},
{
value
:
'
lt
'
,
label
:
t
(
'
admin.announcements.operators.lt
'
)
},
{
value
:
'
lte
'
,
label
:
t
(
'
admin.announcements.operators.lte
'
)
},
{
value
:
'
eq
'
,
label
:
t
(
'
admin.announcements.operators.eq
'
)
}
])
function
setMode
(
next
:
Mode
)
{
if
(
next
===
'
all
'
)
{
emit
(
'
update:modelValue
'
,
{
any_of
:
[]
})
return
}
if
(
anyOf
.
value
.
length
===
0
)
{
emit
(
'
update:modelValue
'
,
{
any_of
:
[{
all_of
:
[
defaultSubscriptionCondition
()]
}]
})
}
}
function
defaultSubscriptionCondition
():
AnnouncementCondition
{
return
{
type
:
'
subscription
'
as
AnnouncementConditionType
,
operator
:
'
in
'
as
AnnouncementOperator
,
group_ids
:
[]
}
}
function
defaultBalanceCondition
():
AnnouncementCondition
{
return
{
type
:
'
balance
'
as
AnnouncementConditionType
,
operator
:
'
gte
'
as
AnnouncementOperator
,
value
:
0
}
}
type
TargetingDraft
=
{
any_of
:
AnnouncementConditionGroup
[]
}
function
updateTargeting
(
mutator
:
(
draft
:
TargetingDraft
)
=>
void
)
{
const
draft
:
TargetingDraft
=
JSON
.
parse
(
JSON
.
stringify
(
props
.
modelValue
??
{
any_of
:
[]
}))
if
(
!
draft
.
any_of
)
draft
.
any_of
=
[]
mutator
(
draft
)
emit
(
'
update:modelValue
'
,
draft
)
}
function
addOrGroup
()
{
updateTargeting
((
draft
)
=>
{
if
(
draft
.
any_of
.
length
>=
50
)
return
draft
.
any_of
.
push
({
all_of
:
[
defaultSubscriptionCondition
()]
})
})
}
function
removeOrGroup
(
groupIndex
:
number
)
{
updateTargeting
((
draft
)
=>
{
draft
.
any_of
.
splice
(
groupIndex
,
1
)
})
}
function
addAndCondition
(
groupIndex
:
number
)
{
updateTargeting
((
draft
)
=>
{
const
group
=
draft
.
any_of
[
groupIndex
]
if
(
!
group
.
all_of
)
group
.
all_of
=
[]
if
(
group
.
all_of
.
length
>=
50
)
return
group
.
all_of
.
push
(
defaultSubscriptionCondition
())
})
}
function
removeAndCondition
(
groupIndex
:
number
,
condIndex
:
number
)
{
updateTargeting
((
draft
)
=>
{
const
group
=
draft
.
any_of
[
groupIndex
]
if
(
!
group
?.
all_of
)
return
group
.
all_of
.
splice
(
condIndex
,
1
)
})
}
function
setConditionType
(
groupIndex
:
number
,
condIndex
:
number
,
nextType
:
AnnouncementConditionType
)
{
updateTargeting
((
draft
)
=>
{
const
group
=
draft
.
any_of
[
groupIndex
]
if
(
!
group
?.
all_of
)
return
if
(
nextType
===
'
subscription
'
)
{
group
.
all_of
[
condIndex
]
=
defaultSubscriptionCondition
()
}
else
{
group
.
all_of
[
condIndex
]
=
defaultBalanceCondition
()
}
})
}
function
setOperator
(
groupIndex
:
number
,
condIndex
:
number
,
op
:
AnnouncementOperator
)
{
updateTargeting
((
draft
)
=>
{
const
group
=
draft
.
any_of
[
groupIndex
]
if
(
!
group
?.
all_of
)
return
const
cond
=
group
.
all_of
[
condIndex
]
if
(
!
cond
)
return
cond
.
operator
=
op
})
}
function
setBalanceValue
(
groupIndex
:
number
,
condIndex
:
number
,
raw
:
string
)
{
const
n
=
raw
===
''
?
0
:
Number
(
raw
)
updateTargeting
((
draft
)
=>
{
const
group
=
draft
.
any_of
[
groupIndex
]
if
(
!
group
?.
all_of
)
return
const
cond
=
group
.
all_of
[
condIndex
]
if
(
!
cond
)
return
cond
.
value
=
Number
.
isFinite
(
n
)
?
n
:
0
})
}
// We keep group_ids selection in a parallel reactive map because GroupSelector is numeric list.
// Then we mirror it back to targeting.group_ids via a watcher.
const
subscriptionSelections
=
reactive
<
Record
<
number
,
Record
<
number
,
number
[]
>>>
({})
function
ensureSelectionPath
(
groupIndex
:
number
,
condIndex
:
number
)
{
if
(
!
subscriptionSelections
[
groupIndex
])
subscriptionSelections
[
groupIndex
]
=
{}
if
(
!
subscriptionSelections
[
groupIndex
][
condIndex
])
subscriptionSelections
[
groupIndex
][
condIndex
]
=
[]
}
// Sync from modelValue to subscriptionSelections (one-way: model -> local state)
watch
(
()
=>
props
.
modelValue
,
(
v
)
=>
{
const
groups
=
v
?.
any_of
??
[]
for
(
let
gi
=
0
;
gi
<
groups
.
length
;
gi
++
)
{
const
allOf
=
groups
[
gi
]?.
all_of
??
[]
for
(
let
ci
=
0
;
ci
<
allOf
.
length
;
ci
++
)
{
const
c
=
allOf
[
ci
]
if
(
c
?.
type
===
'
subscription
'
)
{
ensureSelectionPath
(
gi
,
ci
)
// Only update if different to avoid triggering unnecessary updates
const
newIds
=
(
c
.
group_ids
??
[]).
slice
()
const
currentIds
=
subscriptionSelections
[
gi
]?.[
ci
]
??
[]
if
(
JSON
.
stringify
(
newIds
.
sort
())
!==
JSON
.
stringify
(
currentIds
.
sort
()))
{
subscriptionSelections
[
gi
][
ci
]
=
newIds
}
}
}
}
},
{
immediate
:
true
}
)
// Sync from subscriptionSelections to modelValue (one-way: local state -> model)
// Use a debounced approach to avoid infinite loops
let
syncTimeout
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
watch
(
()
=>
subscriptionSelections
,
()
=>
{
// Debounce the sync to avoid rapid fire updates
if
(
syncTimeout
)
clearTimeout
(
syncTimeout
)
syncTimeout
=
setTimeout
(()
=>
{
// Build the new targeting state
const
newTargeting
:
TargetingDraft
=
JSON
.
parse
(
JSON
.
stringify
(
props
.
modelValue
??
{
any_of
:
[]
}))
if
(
!
newTargeting
.
any_of
)
newTargeting
.
any_of
=
[]
const
groups
=
newTargeting
.
any_of
??
[]
for
(
let
gi
=
0
;
gi
<
groups
.
length
;
gi
++
)
{
const
allOf
=
groups
[
gi
]?.
all_of
??
[]
for
(
let
ci
=
0
;
ci
<
allOf
.
length
;
ci
++
)
{
const
c
=
allOf
[
ci
]
if
(
c
?.
type
===
'
subscription
'
)
{
ensureSelectionPath
(
gi
,
ci
)
c
.
operator
=
'
in
'
as
AnnouncementOperator
c
.
group_ids
=
(
subscriptionSelections
[
gi
]?.[
ci
]
??
[]).
slice
()
}
}
}
// Only emit if there's an actual change (deep comparison)
if
(
JSON
.
stringify
(
props
.
modelValue
)
!==
JSON
.
stringify
(
newTargeting
))
{
emit
(
'
update:modelValue
'
,
newTargeting
)
}
},
0
)
},
{
deep
:
true
}
)
const
validationError
=
computed
(()
=>
{
if
(
mode
.
value
!==
'
custom
'
)
return
''
const
groups
=
anyOf
.
value
if
(
groups
.
length
===
0
)
return
t
(
'
admin.announcements.form.addOrGroup
'
)
if
(
groups
.
length
>
50
)
return
'
any_of > 50
'
for
(
const
g
of
groups
)
{
const
allOf
=
g
?.
all_of
??
[]
if
(
allOf
.
length
===
0
)
return
t
(
'
admin.announcements.form.addAndCondition
'
)
if
(
allOf
.
length
>
50
)
return
'
all_of > 50
'
for
(
const
c
of
allOf
)
{
if
(
c
.
type
===
'
subscription
'
)
{
if
(
!
c
.
group_ids
||
c
.
group_ids
.
length
===
0
)
return
t
(
'
admin.announcements.form.selectPackages
'
)
}
}
}
return
''
})
</
script
>
frontend/src/components/admin/usage/UsageCleanupDialog.vue
0 → 100644
View file @
0170d19f
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.usage.cleanup.title')"
width=
"wide"
@
close=
"handleClose"
>
<div
class=
"space-y-4"
>
<UsageFilters
v-model=
"localFilters"
v-model:startDate=
"localStartDate"
v-model:endDate=
"localEndDate"
:exporting=
"false"
:show-actions=
"false"
@
change=
"noop"
/>
<div
class=
"rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200"
>
{{
t
(
'
admin.usage.cleanup.warning
'
)
}}
</div>
<div
class=
"rounded-xl border border-gray-200 p-4 dark:border-dark-700"
>
<div
class=
"flex items-center justify-between"
>
<h4
class=
"text-sm font-semibold text-gray-700 dark:text-gray-200"
>
{{
t
(
'
admin.usage.cleanup.recentTasks
'
)
}}
</h4>
<button
type=
"button"
class=
"btn btn-ghost btn-sm"
@
click=
"loadTasks"
>
{{
t
(
'
common.refresh
'
)
}}
</button>
</div>
<div
class=
"mt-3 space-y-2"
>
<div
v-if=
"tasksLoading"
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.usage.cleanup.loadingTasks
'
)
}}
</div>
<div
v-else-if=
"tasks.length === 0"
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.usage.cleanup.noTasks
'
)
}}
</div>
<div
v-else
class=
"space-y-2"
>
<div
v-for=
"task in tasks"
:key=
"task.id"
class=
"flex flex-col gap-2 rounded-lg border border-gray-100 px-3 py-2 text-sm text-gray-600 dark:border-dark-700 dark:text-gray-300"
>
<div
class=
"flex flex-wrap items-center justify-between gap-2"
>
<div
class=
"flex items-center gap-2"
>
<span
:class=
"statusClass(task.status)"
class=
"rounded-full px-2 py-0.5 text-xs font-semibold"
>
{{
statusLabel
(
task
.
status
)
}}
</span>
<span
class=
"text-xs text-gray-400"
>
#
{{
task
.
id
}}
</span>
<button
v-if=
"canCancel(task)"
type=
"button"
class=
"btn btn-ghost btn-xs text-rose-600 hover:text-rose-700 dark:text-rose-300"
@
click=
"openCancelConfirm(task)"
>
{{
t
(
'
admin.usage.cleanup.cancel
'
)
}}
</button>
</div>
<div
class=
"text-xs text-gray-400"
>
{{
formatDateTime
(
task
.
created_at
)
}}
</div>
</div>
<div
class=
"flex flex-wrap items-center gap-4 text-xs text-gray-500 dark:text-gray-400"
>
<span>
{{
t
(
'
admin.usage.cleanup.range
'
)
}}
:
{{
formatRange
(
task
)
}}
</span>
<span>
{{
t
(
'
admin.usage.cleanup.deletedRows
'
)
}}
:
{{
task
.
deleted_rows
.
toLocaleString
()
}}
</span>
</div>
<div
v-if=
"task.error_message"
class=
"text-xs text-rose-500"
>
{{
task
.
error_message
}}
</div>
</div>
</div>
</div>
<Pagination
v-if=
"tasksTotal > tasksPageSize"
class=
"mt-4"
:total=
"tasksTotal"
:page=
"tasksPage"
:page-size=
"tasksPageSize"
:page-size-options=
"[5]"
:show-page-size-selector=
"false"
:show-jump=
"true"
@
update:page=
"handleTaskPageChange"
@
update:pageSize=
"handleTaskPageSizeChange"
/>
</div>
</div>
<template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
type=
"button"
class=
"btn btn-secondary"
@
click=
"handleClose"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"button"
class=
"btn btn-danger"
:disabled=
"submitting"
@
click=
"openConfirm"
>
{{
submitting
?
t
(
'
admin.usage.cleanup.submitting
'
)
:
t
(
'
admin.usage.cleanup.submit
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
<ConfirmDialog
:show=
"confirmVisible"
:title=
"t('admin.usage.cleanup.confirmTitle')"
:message=
"t('admin.usage.cleanup.confirmMessage')"
:confirm-text=
"t('admin.usage.cleanup.confirmSubmit')"
danger
@
confirm=
"submitCleanup"
@
cancel=
"confirmVisible = false"
/>
<ConfirmDialog
:show=
"cancelConfirmVisible"
:title=
"t('admin.usage.cleanup.cancelConfirmTitle')"
:message=
"t('admin.usage.cleanup.cancelConfirmMessage')"
:confirm-text=
"t('admin.usage.cleanup.cancelConfirm')"
danger
@
confirm=
"cancelTask"
@
cancel=
"cancelConfirmVisible = false"
/>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
watch
,
onUnmounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
UsageFilters
from
'
@/components/admin/usage/UsageFilters.vue
'
import
{
adminUsageAPI
}
from
'
@/api/admin/usage
'
import
type
{
AdminUsageQueryParams
,
UsageCleanupTask
,
CreateUsageCleanupTaskRequest
}
from
'
@/api/admin/usage
'
interface
Props
{
show
:
boolean
filters
:
AdminUsageQueryParams
startDate
:
string
endDate
:
string
}
const
props
=
defineProps
<
Props
>
()
const
emit
=
defineEmits
([
'
close
'
])
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
localFilters
=
ref
<
AdminUsageQueryParams
>
({})
const
localStartDate
=
ref
(
''
)
const
localEndDate
=
ref
(
''
)
const
tasks
=
ref
<
UsageCleanupTask
[]
>
([])
const
tasksLoading
=
ref
(
false
)
const
tasksPage
=
ref
(
1
)
const
tasksPageSize
=
ref
(
5
)
const
tasksTotal
=
ref
(
0
)
const
submitting
=
ref
(
false
)
const
confirmVisible
=
ref
(
false
)
const
cancelConfirmVisible
=
ref
(
false
)
const
canceling
=
ref
(
false
)
const
cancelTarget
=
ref
<
UsageCleanupTask
|
null
>
(
null
)
let
pollTimer
:
number
|
null
=
null
const
noop
=
()
=>
{}
const
resetFilters
=
()
=>
{
localFilters
.
value
=
{
...
props
.
filters
}
localStartDate
.
value
=
props
.
startDate
localEndDate
.
value
=
props
.
endDate
localFilters
.
value
.
start_date
=
localStartDate
.
value
localFilters
.
value
.
end_date
=
localEndDate
.
value
tasksPage
.
value
=
1
tasksTotal
.
value
=
0
}
const
startPolling
=
()
=>
{
stopPolling
()
pollTimer
=
window
.
setInterval
(()
=>
{
loadTasks
()
},
10000
)
}
const
stopPolling
=
()
=>
{
if
(
pollTimer
!==
null
)
{
window
.
clearInterval
(
pollTimer
)
pollTimer
=
null
}
}
const
handleClose
=
()
=>
{
stopPolling
()
confirmVisible
.
value
=
false
cancelConfirmVisible
.
value
=
false
canceling
.
value
=
false
cancelTarget
.
value
=
null
submitting
.
value
=
false
emit
(
'
close
'
)
}
const
statusLabel
=
(
status
:
string
)
=>
{
const
map
:
Record
<
string
,
string
>
=
{
pending
:
t
(
'
admin.usage.cleanup.status.pending
'
),
running
:
t
(
'
admin.usage.cleanup.status.running
'
),
succeeded
:
t
(
'
admin.usage.cleanup.status.succeeded
'
),
failed
:
t
(
'
admin.usage.cleanup.status.failed
'
),
canceled
:
t
(
'
admin.usage.cleanup.status.canceled
'
)
}
return
map
[
status
]
||
status
}
const
statusClass
=
(
status
:
string
)
=>
{
const
map
:
Record
<
string
,
string
>
=
{
pending
:
'
bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200
'
,
running
:
'
bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-200
'
,
succeeded
:
'
bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200
'
,
failed
:
'
bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-200
'
,
canceled
:
'
bg-gray-200 text-gray-600 dark:bg-dark-600 dark:text-gray-300
'
}
return
map
[
status
]
||
'
bg-gray-100 text-gray-600
'
}
const
formatDateTime
=
(
value
?:
string
|
null
)
=>
{
if
(
!
value
)
return
'
--
'
const
date
=
new
Date
(
value
)
if
(
Number
.
isNaN
(
date
.
getTime
()))
return
value
return
date
.
toLocaleString
()
}
const
formatRange
=
(
task
:
UsageCleanupTask
)
=>
{
const
start
=
formatDateTime
(
task
.
filters
.
start_time
)
const
end
=
formatDateTime
(
task
.
filters
.
end_time
)
return
`
${
start
}
~
${
end
}
`
}
const
getUserTimezone
=
()
=>
{
try
{
return
Intl
.
DateTimeFormat
().
resolvedOptions
().
timeZone
}
catch
{
return
'
UTC
'
}
}
const
loadTasks
=
async
()
=>
{
if
(
!
props
.
show
)
return
tasksLoading
.
value
=
true
try
{
const
res
=
await
adminUsageAPI
.
listCleanupTasks
({
page
:
tasksPage
.
value
,
page_size
:
tasksPageSize
.
value
})
tasks
.
value
=
res
.
items
||
[]
tasksTotal
.
value
=
res
.
total
||
0
if
(
res
.
page
)
{
tasksPage
.
value
=
res
.
page
}
if
(
res
.
page_size
)
{
tasksPageSize
.
value
=
res
.
page_size
}
}
catch
(
error
)
{
console
.
error
(
'
Failed to load cleanup tasks:
'
,
error
)
appStore
.
showError
(
t
(
'
admin.usage.cleanup.loadFailed
'
))
}
finally
{
tasksLoading
.
value
=
false
}
}
const
handleTaskPageChange
=
(
page
:
number
)
=>
{
tasksPage
.
value
=
page
loadTasks
()
}
const
handleTaskPageSizeChange
=
(
size
:
number
)
=>
{
if
(
!
Number
.
isFinite
(
size
)
||
size
<=
0
)
return
tasksPageSize
.
value
=
size
tasksPage
.
value
=
1
loadTasks
()
}
const
openConfirm
=
()
=>
{
confirmVisible
.
value
=
true
}
const
canCancel
=
(
task
:
UsageCleanupTask
)
=>
{
return
task
.
status
===
'
pending
'
||
task
.
status
===
'
running
'
}
const
openCancelConfirm
=
(
task
:
UsageCleanupTask
)
=>
{
cancelTarget
.
value
=
task
cancelConfirmVisible
.
value
=
true
}
const
buildPayload
=
():
CreateUsageCleanupTaskRequest
|
null
=>
{
if
(
!
localStartDate
.
value
||
!
localEndDate
.
value
)
{
appStore
.
showError
(
t
(
'
admin.usage.cleanup.missingRange
'
))
return
null
}
const
payload
:
CreateUsageCleanupTaskRequest
=
{
start_date
:
localStartDate
.
value
,
end_date
:
localEndDate
.
value
,
timezone
:
getUserTimezone
()
}
if
(
localFilters
.
value
.
user_id
&&
localFilters
.
value
.
user_id
>
0
)
{
payload
.
user_id
=
localFilters
.
value
.
user_id
}
if
(
localFilters
.
value
.
api_key_id
&&
localFilters
.
value
.
api_key_id
>
0
)
{
payload
.
api_key_id
=
localFilters
.
value
.
api_key_id
}
if
(
localFilters
.
value
.
account_id
&&
localFilters
.
value
.
account_id
>
0
)
{
payload
.
account_id
=
localFilters
.
value
.
account_id
}
if
(
localFilters
.
value
.
group_id
&&
localFilters
.
value
.
group_id
>
0
)
{
payload
.
group_id
=
localFilters
.
value
.
group_id
}
if
(
localFilters
.
value
.
model
)
{
payload
.
model
=
localFilters
.
value
.
model
}
if
(
localFilters
.
value
.
stream
!==
null
&&
localFilters
.
value
.
stream
!==
undefined
)
{
payload
.
stream
=
localFilters
.
value
.
stream
}
if
(
localFilters
.
value
.
billing_type
!==
null
&&
localFilters
.
value
.
billing_type
!==
undefined
)
{
payload
.
billing_type
=
localFilters
.
value
.
billing_type
}
return
payload
}
const
submitCleanup
=
async
()
=>
{
const
payload
=
buildPayload
()
if
(
!
payload
)
{
confirmVisible
.
value
=
false
return
}
submitting
.
value
=
true
confirmVisible
.
value
=
false
try
{
await
adminUsageAPI
.
createCleanupTask
(
payload
)
appStore
.
showSuccess
(
t
(
'
admin.usage.cleanup.submitSuccess
'
))
loadTasks
()
}
catch
(
error
)
{
console
.
error
(
'
Failed to create cleanup task:
'
,
error
)
appStore
.
showError
(
t
(
'
admin.usage.cleanup.submitFailed
'
))
}
finally
{
submitting
.
value
=
false
}
}
const
cancelTask
=
async
()
=>
{
const
task
=
cancelTarget
.
value
if
(
!
task
)
{
cancelConfirmVisible
.
value
=
false
return
}
canceling
.
value
=
true
cancelConfirmVisible
.
value
=
false
try
{
await
adminUsageAPI
.
cancelCleanupTask
(
task
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.usage.cleanup.cancelSuccess
'
))
loadTasks
()
}
catch
(
error
)
{
console
.
error
(
'
Failed to cancel cleanup task:
'
,
error
)
appStore
.
showError
(
t
(
'
admin.usage.cleanup.cancelFailed
'
))
}
finally
{
canceling
.
value
=
false
cancelTarget
.
value
=
null
}
}
watch
(
()
=>
props
.
show
,
(
show
)
=>
{
if
(
show
)
{
resetFilters
()
loadTasks
()
startPolling
()
}
else
{
stopPolling
()
}
}
)
onUnmounted
(()
=>
{
stopPolling
()
})
</
script
>
frontend/src/components/admin/usage/UsageFilters.vue
View file @
0170d19f
...
...
@@ -127,6 +127,12 @@
<
Select
v
-
model
=
"
filters.stream
"
:
options
=
"
streamTypeOptions
"
@
change
=
"
emitChange
"
/>
<
/div
>
<!--
Billing
Type
Filter
-->
<
div
class
=
"
w-full sm:w-auto sm:min-w-[200px]
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.usage.billingType
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
filters.billing_type
"
:
options
=
"
billingTypeOptions
"
@
change
=
"
emitChange
"
/>
<
/div
>
<!--
Group
Filter
-->
<
div
class
=
"
w-full sm:w-auto sm:min-w-[200px]
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.usage.group
'
)
}}
<
/label
>
...
...
@@ -147,10 +153,13 @@
<
/div
>
<!--
Right
:
actions
-->
<
div
class
=
"
flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto
"
>
<
div
v
-
if
=
"
showActions
"
class
=
"
flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto
"
>
<
button
type
=
"
button
"
@
click
=
"
$emit('reset')
"
class
=
"
btn btn-secondary
"
>
{{
t
(
'
common.reset
'
)
}}
<
/button
>
<
button
type
=
"
button
"
@
click
=
"
$emit('cleanup')
"
class
=
"
btn btn-danger
"
>
{{
t
(
'
admin.usage.cleanup.button
'
)
}}
<
/button
>
<
button
type
=
"
button
"
@
click
=
"
$emit('export')
"
:
disabled
=
"
exporting
"
class
=
"
btn btn-primary
"
>
{{
t
(
'
usage.exportExcel
'
)
}}
<
/button
>
...
...
@@ -174,16 +183,20 @@ interface Props {
exporting
:
boolean
startDate
:
string
endDate
:
string
showActions
?:
boolean
}
const
props
=
defineProps
<
Props
>
()
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
showActions
:
true
}
)
const
emit
=
defineEmits
([
'
update:modelValue
'
,
'
update:startDate
'
,
'
update:endDate
'
,
'
change
'
,
'
reset
'
,
'
export
'
'
export
'
,
'
cleanup
'
])
const
{
t
}
=
useI18n
()
...
...
@@ -221,6 +234,12 @@ const streamTypeOptions = ref<SelectOption[]>([
{
value
:
false
,
label
:
t
(
'
usage.sync
'
)
}
])
const
billingTypeOptions
=
ref
<
SelectOption
[]
>
([
{
value
:
null
,
label
:
t
(
'
admin.usage.allBillingTypes
'
)
}
,
{
value
:
0
,
label
:
t
(
'
admin.usage.billingTypeBalance
'
)
}
,
{
value
:
1
,
label
:
t
(
'
admin.usage.billingTypeSubscription
'
)
}
])
const
emitChange
=
()
=>
emit
(
'
change
'
)
const
updateStartDate
=
(
value
:
string
)
=>
{
...
...
frontend/src/components/admin/usage/UsageTable.vue
View file @
0170d19f
...
...
@@ -239,7 +239,7 @@ import { formatDateTime } from '@/utils/format'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
type
{
UsageLog
}
from
'
@/types
'
import
type
{
Admin
UsageLog
}
from
'
@/types
'
defineProps
([
'
data
'
,
'
loading
'
])
const
{
t
}
=
useI18n
()
...
...
@@ -247,12 +247,12 @@ const { t } = useI18n()
// Tooltip state - cost
const
tooltipVisible
=
ref
(
false
)
const
tooltipPosition
=
ref
({
x
:
0
,
y
:
0
})
const
tooltipData
=
ref
<
UsageLog
|
null
>
(
null
)
const
tooltipData
=
ref
<
Admin
UsageLog
|
null
>
(
null
)
// Tooltip state - token
const
tokenTooltipVisible
=
ref
(
false
)
const
tokenTooltipPosition
=
ref
({
x
:
0
,
y
:
0
})
const
tokenTooltipData
=
ref
<
UsageLog
|
null
>
(
null
)
const
tokenTooltipData
=
ref
<
Admin
UsageLog
|
null
>
(
null
)
const
cols
=
computed
(()
=>
[
{
key
:
'
user
'
,
label
:
t
(
'
admin.usage.user
'
),
sortable
:
false
},
...
...
@@ -296,7 +296,7 @@ const formatDuration = (ms: number | null | undefined): string => {
}
// Cost tooltip functions
const
showTooltip
=
(
event
:
MouseEvent
,
row
:
UsageLog
)
=>
{
const
showTooltip
=
(
event
:
MouseEvent
,
row
:
Admin
UsageLog
)
=>
{
const
target
=
event
.
currentTarget
as
HTMLElement
const
rect
=
target
.
getBoundingClientRect
()
tooltipData
.
value
=
row
...
...
@@ -311,7 +311,7 @@ const hideTooltip = () => {
}
// Token tooltip functions
const
showTokenTooltip
=
(
event
:
MouseEvent
,
row
:
UsageLog
)
=>
{
const
showTokenTooltip
=
(
event
:
MouseEvent
,
row
:
Admin
UsageLog
)
=>
{
const
target
=
event
.
currentTarget
as
HTMLElement
const
rect
=
target
.
getBoundingClientRect
()
tokenTooltipData
.
value
=
row
...
...
frontend/src/components/admin/user/UserAllowedGroupsModal.vue
View file @
0170d19f
...
...
@@ -39,10 +39,10 @@ import { ref, watch } from 'vue'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
User
,
Group
}
from
'
@/types
'
import
type
{
Admin
User
,
Group
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
User
|
null
}
>
()
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
Admin
User
|
null
}
>
()
const
emit
=
defineEmits
([
'
close
'
,
'
success
'
]);
const
{
t
}
=
useI18n
();
const
appStore
=
useAppStore
()
const
groups
=
ref
<
Group
[]
>
([]);
const
selectedIds
=
ref
<
number
[]
>
([]);
const
loading
=
ref
(
false
);
const
submitting
=
ref
(
false
)
...
...
frontend/src/components/admin/user/UserApiKeysModal.vue
View file @
0170d19f
...
...
@@ -32,10 +32,10 @@ import { ref, watch } from 'vue'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
formatDateTime
}
from
'
@/utils/format
'
import
type
{
User
,
ApiKey
}
from
'
@/types
'
import
type
{
Admin
User
,
ApiKey
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
User
|
null
}
>
()
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
Admin
User
|
null
}
>
()
defineEmits
([
'
close
'
]);
const
{
t
}
=
useI18n
()
const
apiKeys
=
ref
<
ApiKey
[]
>
([]);
const
loading
=
ref
(
false
)
...
...
frontend/src/components/admin/user/UserBalanceModal.vue
View file @
0170d19f
...
...
@@ -29,10 +29,10 @@ import { reactive, ref, watch } from 'vue'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
User
}
from
'
@/types
'
import
type
{
Admin
User
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
User
|
null
,
operation
:
'
add
'
|
'
subtract
'
}
>
()
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
Admin
User
|
null
,
operation
:
'
add
'
|
'
subtract
'
}
>
()
const
emit
=
defineEmits
([
'
close
'
,
'
success
'
]);
const
{
t
}
=
useI18n
();
const
appStore
=
useAppStore
()
const
submitting
=
ref
(
false
);
const
form
=
reactive
({
amount
:
0
,
notes
:
''
})
...
...
frontend/src/components/admin/user/UserEditModal.vue
View file @
0170d19f
...
...
@@ -56,12 +56,12 @@ import { useI18n } from 'vue-i18n'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
User
,
UserAttributeValuesMap
}
from
'
@/types
'
import
type
{
Admin
User
,
UserAttributeValuesMap
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
UserAttributeForm
from
'
@/components/user/UserAttributeForm.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
User
|
null
}
>
()
const
props
=
defineProps
<
{
show
:
boolean
,
user
:
Admin
User
|
null
}
>
()
const
emit
=
defineEmits
([
'
close
'
,
'
success
'
])
const
{
t
}
=
useI18n
();
const
appStore
=
useAppStore
();
const
{
copyToClipboard
}
=
useClipboard
()
...
...
frontend/src/components/auth/TotpLoginModal.vue
0 → 100644
View file @
0170d19f
<
template
>
<div
class=
"fixed inset-0 z-50 overflow-y-auto"
>
<div
class=
"flex min-h-full items-center justify-center p-4"
>
<div
class=
"fixed inset-0 bg-black/50 transition-opacity"
></div>
<div
class=
"relative w-full max-w-md transform rounded-xl bg-white p-6 shadow-xl transition-all dark:bg-dark-800"
>
<!-- Header -->
<div
class=
"mb-6 text-center"
>
<div
class=
"mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30"
>
<svg
class=
"h-6 w-6 text-primary-600 dark:text-primary-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"1.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
</div>
<h3
class=
"mt-4 text-xl font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
profile.totp.loginTitle
'
)
}}
</h3>
<p
class=
"mt-2 text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
profile.totp.loginHint
'
)
}}
</p>
<p
v-if=
"userEmailMasked"
class=
"mt-1 text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
userEmailMasked
}}
</p>
</div>
<!-- Code Input -->
<div
class=
"mb-6"
>
<div
class=
"flex justify-center gap-2"
>
<input
v-for=
"(_, index) in 6"
:key=
"index"
:ref=
"(el) => setInputRef(el, index)"
type=
"text"
maxlength=
"1"
inputmode=
"numeric"
pattern=
"[0-9]"
class=
"h-12 w-10 rounded-lg border border-gray-300 text-center text-lg font-semibold focus:border-primary-500 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
:disabled=
"verifying"
@
input=
"handleCodeInput($event, index)"
@
keydown=
"handleKeydown($event, index)"
@
paste=
"handlePaste"
/>
</div>
<!-- Loading indicator -->
<div
v-if=
"verifying"
class=
"mt-3 flex items-center justify-center gap-2 text-sm text-gray-500"
>
<div
class=
"animate-spin rounded-full h-4 w-4 border-b-2 border-primary-500"
></div>
{{
t
(
'
common.verifying
'
)
}}
</div>
</div>
<!-- Error -->
<div
v-if=
"error"
class=
"mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400"
>
{{
error
}}
</div>
<!-- Cancel button only -->
<button
type=
"button"
class=
"btn btn-secondary w-full"
:disabled=
"verifying"
@
click=
"$emit('cancel')"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
</div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
watch
,
nextTick
,
onMounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
defineProps
<
{
tempToken
:
string
userEmailMasked
?:
string
}
>
()
const
emit
=
defineEmits
<
{
verify
:
[
code
:
string
]
cancel
:
[]
}
>
()
const
{
t
}
=
useI18n
()
const
verifying
=
ref
(
false
)
const
error
=
ref
(
''
)
const
code
=
ref
<
string
[]
>
([
''
,
''
,
''
,
''
,
''
,
''
])
const
inputRefs
=
ref
<
(
HTMLInputElement
|
null
)[]
>
([])
// Watch for code changes and auto-submit when 6 digits are entered
watch
(
()
=>
code
.
value
.
join
(
''
),
(
newCode
)
=>
{
if
(
newCode
.
length
===
6
&&
!
verifying
.
value
)
{
emit
(
'
verify
'
,
newCode
)
}
}
)
defineExpose
({
setVerifying
:
(
value
:
boolean
)
=>
{
verifying
.
value
=
value
},
setError
:
(
message
:
string
)
=>
{
error
.
value
=
message
code
.
value
=
[
''
,
''
,
''
,
''
,
''
,
''
]
// Clear input DOM values
inputRefs
.
value
.
forEach
(
input
=>
{
if
(
input
)
input
.
value
=
''
})
nextTick
(()
=>
{
inputRefs
.
value
[
0
]?.
focus
()
})
}
})
const
setInputRef
=
(
el
:
any
,
index
:
number
)
=>
{
inputRefs
.
value
[
index
]
=
el
as
HTMLInputElement
|
null
}
const
handleCodeInput
=
(
event
:
Event
,
index
:
number
)
=>
{
const
input
=
event
.
target
as
HTMLInputElement
const
value
=
input
.
value
.
replace
(
/
[^
0-9
]
/g
,
''
)
code
.
value
[
index
]
=
value
if
(
value
&&
index
<
5
)
{
nextTick
(()
=>
{
inputRefs
.
value
[
index
+
1
]?.
focus
()
})
}
}
const
handleKeydown
=
(
event
:
KeyboardEvent
,
index
:
number
)
=>
{
if
(
event
.
key
===
'
Backspace
'
)
{
const
input
=
event
.
target
as
HTMLInputElement
// If current cell is empty and not the first, move to previous cell
if
(
!
input
.
value
&&
index
>
0
)
{
event
.
preventDefault
()
inputRefs
.
value
[
index
-
1
]?.
focus
()
}
// Otherwise, let the browser handle the backspace naturally
// The input event will sync code.value via handleCodeInput
}
}
const
handlePaste
=
(
event
:
ClipboardEvent
)
=>
{
event
.
preventDefault
()
const
pastedData
=
event
.
clipboardData
?.
getData
(
'
text
'
)
||
''
const
digits
=
pastedData
.
replace
(
/
[^
0-9
]
/g
,
''
).
slice
(
0
,
6
).
split
(
''
)
// Update both the ref and the input elements
digits
.
forEach
((
digit
,
index
)
=>
{
code
.
value
[
index
]
=
digit
if
(
inputRefs
.
value
[
index
])
{
inputRefs
.
value
[
index
]
!
.
value
=
digit
}
})
// Clear remaining inputs if pasted less than 6 digits
for
(
let
i
=
digits
.
length
;
i
<
6
;
i
++
)
{
code
.
value
[
i
]
=
''
if
(
inputRefs
.
value
[
i
])
{
inputRefs
.
value
[
i
]
!
.
value
=
''
}
}
const
focusIndex
=
Math
.
min
(
digits
.
length
,
5
)
nextTick
(()
=>
{
inputRefs
.
value
[
focusIndex
]?.
focus
()
})
}
onMounted
(()
=>
{
nextTick
(()
=>
{
inputRefs
.
value
[
0
]?.
focus
()
})
})
</
script
>
frontend/src/components/common/AnnouncementBell.vue
0 → 100644
View file @
0170d19f
<
template
>
<div>
<!-- 铃铛按钮 -->
<button
@
click=
"openModal"
class=
"relative flex h-9 w-9 items-center justify-center rounded-lg text-gray-600 transition-all hover:bg-gray-100 hover:scale-105 dark:text-gray-400 dark:hover:bg-dark-800"
:class=
"
{ 'text-blue-600 dark:text-blue-400': unreadCount > 0 }"
:aria-label="t('announcements.title')"
>
<Icon
name=
"bell"
size=
"md"
/>
<!-- 未读红点 -->
<span
v-if=
"unreadCount > 0"
class=
"absolute right-1 top-1 flex h-2 w-2"
>
<span
class=
"absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-75"
></span>
<span
class=
"relative inline-flex h-2 w-2 rounded-full bg-red-500"
></span>
</span>
</button>
<!-- 公告列表 Modal -->
<Teleport
to=
"body"
>
<Transition
name=
"modal-fade"
>
<div
v-if=
"isModalOpen"
class=
"fixed inset-0 z-[100] flex items-start justify-center overflow-y-auto bg-gradient-to-br from-black/70 via-black/60 to-black/70 p-4 pt-[8vh] backdrop-blur-md"
@
click=
"closeModal"
>
<div
class=
"w-full max-w-[620px] overflow-hidden rounded-3xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-dark-800 dark:ring-white/10"
@
click.stop
>
<!-- Header with Gradient -->
<div
class=
"relative overflow-hidden border-b border-gray-100/80 bg-gradient-to-br from-blue-50/50 to-indigo-50/30 px-6 py-5 dark:border-dark-700/50 dark:from-blue-900/10 dark:to-indigo-900/5"
>
<div
class=
"relative z-10 flex items-start justify-between"
>
<div>
<div
class=
"flex items-center gap-2"
>
<div
class=
"flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 text-white shadow-lg shadow-blue-500/30"
>
<Icon
name=
"bell"
size=
"sm"
/>
</div>
<h2
class=
"text-lg font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
announcements.title
'
)
}}
</h2>
</div>
<p
v-if=
"unreadCount > 0"
class=
"mt-2 text-sm text-gray-600 dark:text-gray-400"
>
<span
class=
"font-medium text-blue-600 dark:text-blue-400"
>
{{
unreadCount
}}
</span>
{{
t
(
'
announcements.unread
'
)
}}
</p>
</div>
<div
class=
"flex items-center gap-2"
>
<button
v-if=
"unreadCount > 0"
@
click=
"markAllAsRead"
:disabled=
"loading"
class=
"rounded-lg bg-blue-600 px-4 py-2 text-xs font-medium text-white shadow-lg shadow-blue-500/30 transition-all hover:bg-blue-700 hover:shadow-xl disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-600"
>
{{
t
(
'
announcements.markAllRead
'
)
}}
</button>
<button
@
click=
"closeModal"
class=
"flex h-9 w-9 items-center justify-center rounded-lg bg-white/50 text-gray-500 backdrop-blur-sm transition-all hover:bg-white hover:text-gray-700 dark:bg-dark-700/50 dark:text-gray-400 dark:hover:bg-dark-700 dark:hover:text-gray-300"
:aria-label=
"t('common.close')"
>
<Icon
name=
"x"
size=
"sm"
/>
</button>
</div>
</div>
<!-- Decorative gradient -->
<div
class=
"absolute right-0 top-0 h-full w-48 bg-gradient-to-l from-indigo-100/20 to-transparent dark:from-indigo-900/10"
></div>
</div>
<!-- Body -->
<div
class=
"max-h-[65vh] overflow-y-auto"
>
<!-- Loading -->
<div
v-if=
"loading"
class=
"flex items-center justify-center py-16"
>
<div
class=
"relative"
>
<div
class=
"h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-blue-600 dark:border-dark-600 dark:border-t-blue-400"
></div>
<div
class=
"absolute inset-0 h-12 w-12 animate-pulse rounded-full border-4 border-blue-400/30"
></div>
</div>
</div>
<!-- Announcements List -->
<div
v-else-if=
"announcements.length > 0"
>
<div
v-for=
"item in announcements"
:key=
"item.id"
class=
"group relative flex items-center gap-4 border-b border-gray-100 px-6 py-4 transition-all hover:bg-gray-50 dark:border-dark-700 dark:hover:bg-dark-700/30"
:class=
"
{ 'bg-blue-50/30 dark:bg-blue-900/5': !item.read_at }"
style="min-height: 72px"
@click="openDetail(item)"
>
<!-- Status Indicator -->
<div
class=
"flex h-10 w-10 flex-shrink-0 items-center justify-center"
>
<div
v-if=
"!item.read_at"
class=
"relative flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-indigo-600 text-white shadow-lg shadow-blue-500/30"
>
<!-- Pulse ring -->
<span
class=
"absolute inline-flex h-full w-full animate-ping rounded-xl bg-blue-400 opacity-75"
></span>
<!-- Icon -->
<svg
class=
"relative z-10 h-5 w-5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2.5"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div
v-else
class=
"flex h-10 w-10 items-center justify-center rounded-xl bg-gray-100 text-gray-400 dark:bg-dark-700 dark:text-gray-600"
>
<svg
class=
"h-5 w-5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
</div>
<!-- Content -->
<div
class=
"flex min-w-0 flex-1 items-center justify-between gap-4"
>
<div
class=
"min-w-0 flex-1"
>
<h3
class=
"truncate text-sm font-medium text-gray-900 dark:text-white"
>
{{
item
.
title
}}
</h3>
<div
class=
"mt-1 flex items-center gap-2"
>
<time
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
formatRelativeTime
(
item
.
created_at
)
}}
</time>
<span
v-if=
"!item.read_at"
class=
"inline-flex items-center gap-1 rounded-md bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"
>
<span
class=
"relative flex h-1.5 w-1.5"
>
<span
class=
"absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-500 opacity-75"
></span>
<span
class=
"relative inline-flex h-1.5 w-1.5 rounded-full bg-blue-600"
></span>
</span>
{{
t
(
'
announcements.unread
'
)
}}
</span>
</div>
</div>
<!-- Arrow -->
<div
class=
"flex-shrink-0"
>
<svg
class=
"h-5 w-5 text-gray-400 transition-transform group-hover:translate-x-1 dark:text-gray-600"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
<!-- Unread indicator bar -->
<div
v-if=
"!item.read_at"
class=
"absolute left-0 top-0 h-full w-1 bg-gradient-to-b from-blue-500 to-indigo-600"
></div>
</div>
</div>
<!-- Empty State -->
<div
v-else
class=
"flex flex-col items-center justify-center py-16"
>
<div
class=
"relative mb-4"
>
<div
class=
"flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-gray-100 to-gray-200 dark:from-dark-700 dark:to-dark-600"
>
<Icon
name=
"inbox"
size=
"xl"
class=
"text-gray-400 dark:text-gray-500"
/>
</div>
<div
class=
"absolute -right-1 -top-1 flex h-6 w-6 items-center justify-center rounded-full bg-green-500 text-white"
>
<svg
class=
"h-3.5 w-3.5"
fill=
"currentColor"
viewBox=
"0 0 20 20"
>
<path
fill-rule=
"evenodd"
d=
"M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule=
"evenodd"
/>
</svg>
</div>
</div>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
announcements.empty
'
)
}}
</p>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
announcements.emptyDescription
'
)
}}
</p>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- 公告详情 Modal -->
<Teleport
to=
"body"
>
<Transition
name=
"modal-fade"
>
<div
v-if=
"detailModalOpen && selectedAnnouncement"
class=
"fixed inset-0 z-[110] flex items-start justify-center overflow-y-auto bg-gradient-to-br from-black/70 via-black/60 to-black/70 p-4 pt-[6vh] backdrop-blur-md"
@
click=
"closeDetail"
>
<div
class=
"w-full max-w-[780px] overflow-hidden rounded-3xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-dark-800 dark:ring-white/10"
@
click.stop
>
<!-- Header with Decorative Elements -->
<div
class=
"relative overflow-hidden border-b border-gray-100 bg-gradient-to-br from-blue-50/80 via-indigo-50/50 to-purple-50/30 px-8 py-6 dark:border-dark-700 dark:from-blue-900/20 dark:via-indigo-900/10 dark:to-purple-900/5"
>
<!-- Decorative background elements -->
<div
class=
"absolute right-0 top-0 h-full w-64 bg-gradient-to-l from-indigo-100/30 to-transparent dark:from-indigo-900/20"
></div>
<div
class=
"absolute -right-8 -top-8 h-32 w-32 rounded-full bg-gradient-to-br from-blue-400/20 to-indigo-500/20 blur-3xl"
></div>
<div
class=
"absolute -left-4 -bottom-4 h-24 w-24 rounded-full bg-gradient-to-tr from-purple-400/20 to-pink-500/20 blur-2xl"
></div>
<div
class=
"relative z-10 flex items-start justify-between gap-4"
>
<div
class=
"flex-1 min-w-0"
>
<!-- Icon and Category -->
<div
class=
"mb-3 flex items-center gap-2"
>
<div
class=
"flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-indigo-600 text-white shadow-lg shadow-blue-500/30"
>
<svg
class=
"h-5 w-5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div
class=
"flex items-center gap-2"
>
<span
class=
"rounded-lg bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"
>
{{
t
(
'
announcements.title
'
)
}}
</span>
<span
v-if=
"!selectedAnnouncement.read_at"
class=
"inline-flex items-center gap-1.5 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-600 px-2.5 py-1 text-xs font-medium text-white shadow-lg shadow-blue-500/30"
>
<span
class=
"relative flex h-2 w-2"
>
<span
class=
"absolute inline-flex h-full w-full animate-ping rounded-full bg-white opacity-75"
></span>
<span
class=
"relative inline-flex h-2 w-2 rounded-full bg-white"
></span>
</span>
{{
t
(
'
announcements.unread
'
)
}}
</span>
</div>
</div>
<!-- Title -->
<h2
class=
"mb-3 text-2xl font-bold leading-tight text-gray-900 dark:text-white"
>
{{
selectedAnnouncement
.
title
}}
</h2>
<!-- Meta Info -->
<div
class=
"flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400"
>
<div
class=
"flex items-center gap-1.5"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<time>
{{
formatRelativeWithDateTime
(
selectedAnnouncement
.
created_at
)
}}
</time>
</div>
<div
class=
"flex items-center gap-1.5"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
<span>
{{
selectedAnnouncement
.
read_at
?
t
(
'
announcements.read
'
)
:
t
(
'
announcements.unread
'
)
}}
</span>
</div>
</div>
</div>
<!-- Close button -->
<button
@
click=
"closeDetail"
class=
"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-white/50 text-gray-500 backdrop-blur-sm transition-all hover:bg-white hover:text-gray-700 hover:shadow-lg dark:bg-dark-700/50 dark:text-gray-400 dark:hover:bg-dark-700 dark:hover:text-gray-300"
:aria-label=
"t('common.close')"
>
<Icon
name=
"x"
size=
"md"
/>
</button>
</div>
</div>
<!-- Body with Enhanced Markdown -->
<div
class=
"max-h-[60vh] overflow-y-auto bg-white px-8 py-8 dark:bg-dark-800"
>
<!-- Content with decorative border -->
<div
class=
"relative"
>
<!-- Decorative left border -->
<div
class=
"absolute left-0 top-0 bottom-0 w-1 rounded-full bg-gradient-to-b from-blue-500 via-indigo-500 to-purple-500"
></div>
<div
class=
"pl-6"
>
<div
class=
"markdown-body prose prose-sm max-w-none dark:prose-invert"
v-html=
"renderMarkdown(selectedAnnouncement.content)"
></div>
</div>
</div>
</div>
<!-- Footer with Actions -->
<div
class=
"border-t border-gray-100 bg-gray-50/50 px-8 py-5 dark:border-dark-700 dark:bg-dark-900/30"
>
<div
class=
"flex items-center justify-between"
>
<div
class=
"flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>
{{
selectedAnnouncement
.
read_at
?
t
(
'
announcements.readStatus
'
)
:
t
(
'
announcements.markReadHint
'
)
}}
</span>
</div>
<div
class=
"flex items-center gap-3"
>
<button
@
click=
"closeDetail"
class=
"rounded-xl border border-gray-300 bg-white px-5 py-2.5 text-sm font-medium text-gray-700 shadow-sm transition-all hover:bg-gray-50 hover:shadow dark:border-dark-600 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
>
{{
t
(
'
common.close
'
)
}}
</button>
<button
v-if=
"!selectedAnnouncement.read_at"
@
click=
"markAsReadAndClose(selectedAnnouncement.id)"
class=
"rounded-xl bg-gradient-to-r from-blue-600 to-indigo-600 px-5 py-2.5 text-sm font-medium text-white shadow-lg shadow-blue-500/30 transition-all hover:shadow-xl hover:scale-105"
>
<span
class=
"flex items-center gap-2"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M5 13l4 4L19 7"
/>
</svg>
{{
t
(
'
announcements.markRead
'
)
}}
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
onMounted
,
onBeforeUnmount
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
marked
}
from
'
marked
'
import
DOMPurify
from
'
dompurify
'
import
{
announcementsAPI
}
from
'
@/api
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
formatRelativeTime
,
formatRelativeWithDateTime
}
from
'
@/utils/format
'
import
type
{
UserAnnouncement
}
from
'
@/types
'
import
Icon
from
'
@/components/icons/Icon.vue
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
// Configure marked
marked
.
setOptions
({
breaks
:
true
,
gfm
:
true
,
})
// State
const
announcements
=
ref
<
UserAnnouncement
[]
>
([])
const
isModalOpen
=
ref
(
false
)
const
detailModalOpen
=
ref
(
false
)
const
selectedAnnouncement
=
ref
<
UserAnnouncement
|
null
>
(
null
)
const
loading
=
ref
(
false
)
// Computed
const
unreadCount
=
computed
(()
=>
announcements
.
value
.
filter
((
a
)
=>
!
a
.
read_at
).
length
)
// Methods
function
renderMarkdown
(
content
:
string
):
string
{
if
(
!
content
)
return
''
const
html
=
marked
.
parse
(
content
)
as
string
return
DOMPurify
.
sanitize
(
html
)
}
async
function
loadAnnouncements
()
{
try
{
loading
.
value
=
true
const
allAnnouncements
=
await
announcementsAPI
.
list
(
false
)
announcements
.
value
=
allAnnouncements
.
slice
(
0
,
20
)
}
catch
(
err
:
any
)
{
console
.
error
(
'
Failed to load announcements:
'
,
err
)
appStore
.
showError
(
err
?.
message
||
t
(
'
common.unknownError
'
))
}
finally
{
loading
.
value
=
false
}
}
function
openModal
()
{
isModalOpen
.
value
=
true
if
(
announcements
.
value
.
length
===
0
)
{
loadAnnouncements
()
}
}
function
closeModal
()
{
isModalOpen
.
value
=
false
}
function
openDetail
(
announcement
:
UserAnnouncement
)
{
selectedAnnouncement
.
value
=
announcement
detailModalOpen
.
value
=
true
if
(
!
announcement
.
read_at
)
{
markAsRead
(
announcement
.
id
)
}
}
function
closeDetail
()
{
detailModalOpen
.
value
=
false
selectedAnnouncement
.
value
=
null
}
async
function
markAsRead
(
id
:
number
)
{
try
{
await
announcementsAPI
.
markRead
(
id
)
const
announcement
=
announcements
.
value
.
find
((
a
)
=>
a
.
id
===
id
)
if
(
announcement
)
{
announcement
.
read_at
=
new
Date
().
toISOString
()
}
if
(
selectedAnnouncement
.
value
?.
id
===
id
)
{
selectedAnnouncement
.
value
.
read_at
=
new
Date
().
toISOString
()
}
}
catch
(
err
:
any
)
{
appStore
.
showError
(
err
?.
message
||
t
(
'
common.unknownError
'
))
}
}
async
function
markAsReadAndClose
(
id
:
number
)
{
await
markAsRead
(
id
)
appStore
.
showSuccess
(
t
(
'
announcements.markedAsRead
'
))
closeDetail
()
}
async
function
markAllAsRead
()
{
try
{
loading
.
value
=
true
const
unreadAnnouncements
=
announcements
.
value
.
filter
((
a
)
=>
!
a
.
read_at
)
await
Promise
.
all
(
unreadAnnouncements
.
map
((
a
)
=>
announcementsAPI
.
markRead
(
a
.
id
)))
announcements
.
value
.
forEach
((
a
)
=>
{
if
(
!
a
.
read_at
)
{
a
.
read_at
=
new
Date
().
toISOString
()
}
})
appStore
.
showSuccess
(
t
(
'
announcements.allMarkedAsRead
'
))
}
catch
(
err
:
any
)
{
appStore
.
showError
(
err
?.
message
||
t
(
'
common.unknownError
'
))
}
finally
{
loading
.
value
=
false
}
}
function
handleEscape
(
e
:
KeyboardEvent
)
{
if
(
e
.
key
===
'
Escape
'
)
{
if
(
detailModalOpen
.
value
)
{
closeDetail
()
}
else
if
(
isModalOpen
.
value
)
{
closeModal
()
}
}
}
onMounted
(()
=>
{
document
.
addEventListener
(
'
keydown
'
,
handleEscape
)
loadAnnouncements
()
})
onBeforeUnmount
(()
=>
{
document
.
removeEventListener
(
'
keydown
'
,
handleEscape
)
// Restore body overflow in case component is unmounted while modals are open
document
.
body
.
style
.
overflow
=
''
})
watch
([
isModalOpen
,
detailModalOpen
],
([
modal
,
detail
])
=>
{
if
(
modal
||
detail
)
{
document
.
body
.
style
.
overflow
=
'
hidden
'
}
else
{
document
.
body
.
style
.
overflow
=
''
}
})
</
script
>
<
style
scoped
>
/* Modal Animations */
.modal-fade-enter-active
{
transition
:
all
0.3s
cubic-bezier
(
0.16
,
1
,
0.3
,
1
);
}
.modal-fade-leave-active
{
transition
:
all
0.2s
cubic-bezier
(
0.4
,
0
,
1
,
1
);
}
.modal-fade-enter-from
,
.modal-fade-leave-to
{
opacity
:
0
;
}
.modal-fade-enter-from
>
div
{
transform
:
scale
(
0.94
)
translateY
(
-12px
);
opacity
:
0
;
}
.modal-fade-leave-to
>
div
{
transform
:
scale
(
0.96
)
translateY
(
-8px
);
opacity
:
0
;
}
/* Scrollbar Styling */
.overflow-y-auto
::-webkit-scrollbar
{
width
:
8px
;
}
.overflow-y-auto
::-webkit-scrollbar-track
{
background
:
transparent
;
}
.overflow-y-auto
::-webkit-scrollbar-thumb
{
background
:
linear-gradient
(
to
bottom
,
#cbd5e1
,
#94a3b8
);
border-radius
:
4px
;
}
.dark
.overflow-y-auto
::-webkit-scrollbar-thumb
{
background
:
linear-gradient
(
to
bottom
,
#4b5563
,
#374151
);
}
.overflow-y-auto
::-webkit-scrollbar-thumb:hover
{
background
:
linear-gradient
(
to
bottom
,
#94a3b8
,
#64748b
);
}
.dark
.overflow-y-auto
::-webkit-scrollbar-thumb:hover
{
background
:
linear-gradient
(
to
bottom
,
#6b7280
,
#4b5563
);
}
</
style
>
<
style
>
/* Enhanced Markdown Styles */
.markdown-body
{
@apply
text-[15px]
leading-[1.75];
@apply
text-gray-700
dark
:
text-gray-300
;
}
.markdown-body
h1
{
@apply
mb-6
mt-8
border-b
border-gray-200
pb-3
text-3xl
font-bold
text-gray-900
dark
:
border-dark-600
dark
:
text-white
;
}
.markdown-body
h2
{
@apply
mb-4
mt-7
border-b
border-gray-100
pb-2
text-2xl
font-bold
text-gray-900
dark
:
border-dark-700
dark
:
text-white
;
}
.markdown-body
h3
{
@apply
mb-3
mt-6
text-xl
font-semibold
text-gray-900
dark
:
text-white
;
}
.markdown-body
h4
{
@apply
mb-2
mt-5
text-lg
font-semibold
text-gray-900
dark
:
text-white
;
}
.markdown-body
p
{
@apply
mb-4
leading-relaxed;
}
.markdown-body
a
{
@apply
font-medium
text-blue-600
underline
decoration-blue-600/30
decoration-2
underline-offset-2
transition-all
hover
:
decoration-blue-600
dark
:
text-blue-400
dark
:
decoration-blue-400
/
30
dark
:
hover
:
decoration-blue-400
;
}
.markdown-body
ul
,
.markdown-body
ol
{
@apply
mb-4
ml-6
space-y-2;
}
.markdown-body
ul
{
@apply
list-disc;
}
.markdown-body
ol
{
@apply
list-decimal;
}
.markdown-body
li
{
@apply
leading-relaxed;
@apply
pl-2;
}
.markdown-body
li
::marker
{
@apply
text-blue-600
dark
:
text-blue-400
;
}
.markdown-body
blockquote
{
@apply
relative
my-5
border-l-4
border-blue-500
bg-blue-50/50
py-3
pl-5
pr-4
italic
text-gray-700
dark
:
border-blue-400
dark
:
bg-blue-900
/
10
dark
:
text-gray-300
;
}
.markdown-body
blockquote
::before
{
content
:
'"'
;
@apply
absolute
-left-1
top-0
text-5xl
font-serif
text-blue-500/20
dark
:
text-blue-400
/
20
;
}
.markdown-body
code
{
@apply
rounded-lg
bg-gray-100
px-2
py-1
text-[13px]
font-mono
text-pink-600
dark
:
bg-dark-700
dark
:
text-pink-400
;
}
.markdown-body
pre
{
@apply
my-5
overflow-x-auto
rounded-xl
border
border-gray-200
bg-gray-50
p-5
dark
:
border-dark-600
dark
:
bg-dark-900
/
50
;
}
.markdown-body
pre
code
{
@apply
bg-transparent
p-0
text-[13px]
text-gray-800
dark
:
text-gray-200
;
}
.markdown-body
hr
{
@apply
my-8
border-0
border-t-2
border-gray-200
dark
:
border-dark-700
;
}
.markdown-body
table
{
@apply
mb-5
w-full
overflow-hidden
rounded-lg
border
border-gray-200
dark
:
border-dark-600
;
}
.markdown-body
th
,
.markdown-body
td
{
@apply
border-r
border-b
border-gray-200
px-4
py-3
text-left
dark
:
border-dark-600
;
}
.markdown-body
th
:last-child
,
.markdown-body
td
:last-child
{
@apply
border-r-0;
}
.markdown-body
tr
:last-child
td
{
@apply
border-b-0;
}
.markdown-body
th
{
@apply
bg-gradient-to-br
from-blue-50
to-indigo-50
font-semibold
text-gray-900
dark
:
from-blue-900
/
20
dark
:
to-indigo-900
/
10
dark
:
text-white
;
}
.markdown-body
tbody
tr
{
@apply
transition-colors
hover
:
bg-gray-50
dark
:
hover
:
bg-dark-700
/
30
;
}
.markdown-body
img
{
@apply
my-5
max-w-full
rounded-xl
border
border-gray-200
shadow-md
dark
:
border-dark-600
;
}
.markdown-body
strong
{
@apply
font-semibold
text-gray-900
dark
:
text-white
;
}
.markdown-body
em
{
@apply
italic
text-gray-600
dark
:
text-gray-400
;
}
</
style
>
frontend/src/components/common/DataTable.vue
View file @
0170d19f
...
...
@@ -181,6 +181,10 @@ import Icon from '@/components/icons/Icon.vue'
const
{
t
}
=
useI18n
()
const
emit
=
defineEmits
<
{
sort
:
[
key
:
string
,
order
:
'
asc
'
|
'
desc
'
]
}
>
()
// 表格容器引用
const
tableWrapperRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
isScrollable
=
ref
(
false
)
...
...
@@ -279,18 +283,149 @@ interface Props {
expandableActions
?:
boolean
actionsCount
?:
number
// 操作按钮总数,用于判断是否需要展开功能
rowKey
?:
string
|
((
row
:
any
)
=>
string
|
number
)
/**
* Default sort configuration (only applied when there is no persisted sort state)
*/
defaultSortKey
?:
string
defaultSortOrder
?:
'
asc
'
|
'
desc
'
/**
* Persist sort state (key + order) to localStorage using this key.
* If provided, DataTable will load the stored sort state on mount.
*/
sortStorageKey
?:
string
/**
* Enable server-side sorting mode. When true, clicking sort headers
* will emit 'sort' events instead of performing client-side sorting.
*/
serverSideSort
?:
boolean
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
loading
:
false
,
stickyFirstColumn
:
true
,
stickyActionsColumn
:
true
,
expandableActions
:
true
expandableActions
:
true
,
defaultSortOrder
:
'
asc
'
,
serverSideSort
:
false
})
const
sortKey
=
ref
<
string
>
(
''
)
const
sortOrder
=
ref
<
'
asc
'
|
'
desc
'
>
(
'
asc
'
)
const
actionsExpanded
=
ref
(
false
)
type
PersistedSortState
=
{
key
:
string
order
:
'
asc
'
|
'
desc
'
}
const
collator
=
new
Intl
.
Collator
(
undefined
,
{
numeric
:
true
,
sensitivity
:
'
base
'
})
const
getSortableKeys
=
()
=>
{
const
keys
=
new
Set
<
string
>
()
for
(
const
col
of
props
.
columns
)
{
if
(
col
.
sortable
)
keys
.
add
(
col
.
key
)
}
return
keys
}
const
normalizeSortKey
=
(
candidate
:
string
)
=>
{
if
(
!
candidate
)
return
''
const
sortableKeys
=
getSortableKeys
()
return
sortableKeys
.
has
(
candidate
)
?
candidate
:
''
}
const
normalizeSortOrder
=
(
candidate
:
any
):
'
asc
'
|
'
desc
'
=>
{
return
candidate
===
'
desc
'
?
'
desc
'
:
'
asc
'
}
const
readPersistedSortState
=
():
PersistedSortState
|
null
=>
{
if
(
!
props
.
sortStorageKey
)
return
null
try
{
const
raw
=
localStorage
.
getItem
(
props
.
sortStorageKey
)
if
(
!
raw
)
return
null
const
parsed
=
JSON
.
parse
(
raw
)
as
Partial
<
PersistedSortState
>
const
key
=
normalizeSortKey
(
typeof
parsed
.
key
===
'
string
'
?
parsed
.
key
:
''
)
if
(
!
key
)
return
null
return
{
key
,
order
:
normalizeSortOrder
(
parsed
.
order
)
}
}
catch
(
e
)
{
console
.
error
(
'
[DataTable] Failed to read persisted sort state:
'
,
e
)
return
null
}
}
const
writePersistedSortState
=
(
state
:
PersistedSortState
)
=>
{
if
(
!
props
.
sortStorageKey
)
return
try
{
localStorage
.
setItem
(
props
.
sortStorageKey
,
JSON
.
stringify
(
state
))
}
catch
(
e
)
{
console
.
error
(
'
[DataTable] Failed to persist sort state:
'
,
e
)
}
}
const
resolveInitialSortState
=
():
PersistedSortState
|
null
=>
{
const
persisted
=
readPersistedSortState
()
if
(
persisted
)
return
persisted
const
key
=
normalizeSortKey
(
props
.
defaultSortKey
||
''
)
if
(
!
key
)
return
null
return
{
key
,
order
:
normalizeSortOrder
(
props
.
defaultSortOrder
)
}
}
const
applySortState
=
(
state
:
PersistedSortState
|
null
)
=>
{
if
(
!
state
)
return
sortKey
.
value
=
state
.
key
sortOrder
.
value
=
state
.
order
}
const
isNullishOrEmpty
=
(
value
:
any
)
=>
value
===
null
||
value
===
undefined
||
value
===
''
const
toFiniteNumberOrNull
=
(
value
:
any
):
number
|
null
=>
{
if
(
typeof
value
===
'
number
'
)
return
Number
.
isFinite
(
value
)
?
value
:
null
if
(
typeof
value
===
'
boolean
'
)
return
value
?
1
:
0
if
(
typeof
value
===
'
string
'
)
{
const
trimmed
=
value
.
trim
()
if
(
!
trimmed
)
return
null
const
n
=
Number
(
trimmed
)
return
Number
.
isFinite
(
n
)
?
n
:
null
}
return
null
}
const
toSortableString
=
(
value
:
any
):
string
=>
{
if
(
value
===
null
||
value
===
undefined
)
return
''
if
(
typeof
value
===
'
string
'
)
return
value
if
(
typeof
value
===
'
number
'
||
typeof
value
===
'
boolean
'
)
return
String
(
value
)
if
(
value
instanceof
Date
)
return
value
.
toISOString
()
try
{
return
JSON
.
stringify
(
value
)
}
catch
{
return
String
(
value
)
}
}
const
compareSortValues
=
(
a
:
any
,
b
:
any
):
number
=>
{
const
aEmpty
=
isNullishOrEmpty
(
a
)
const
bEmpty
=
isNullishOrEmpty
(
b
)
if
(
aEmpty
&&
bEmpty
)
return
0
if
(
aEmpty
)
return
1
if
(
bEmpty
)
return
-
1
const
aNum
=
toFiniteNumberOrNull
(
a
)
const
bNum
=
toFiniteNumberOrNull
(
b
)
if
(
aNum
!==
null
&&
bNum
!==
null
)
{
if
(
aNum
===
bNum
)
return
0
return
aNum
<
bNum
?
-
1
:
1
}
const
aStr
=
toSortableString
(
a
)
const
bStr
=
toSortableString
(
b
)
const
res
=
collator
.
compare
(
aStr
,
bStr
)
if
(
res
===
0
)
return
0
return
res
<
0
?
-
1
:
1
}
const
resolveRowKey
=
(
row
:
any
,
index
:
number
)
=>
{
if
(
typeof
props
.
rowKey
===
'
function
'
)
{
const
key
=
props
.
rowKey
(
row
)
...
...
@@ -323,26 +458,39 @@ watch(actionsExpanded, async () => {
})
const
handleSort
=
(
key
:
string
)
=>
{
let
newOrder
:
'
asc
'
|
'
desc
'
=
'
asc
'
if
(
sortKey
.
value
===
key
)
{
sortOrder
.
value
=
sortOrder
.
value
===
'
asc
'
?
'
desc
'
:
'
asc
'
newOrder
=
sortOrder
.
value
===
'
asc
'
?
'
desc
'
:
'
asc
'
}
if
(
props
.
serverSideSort
)
{
// Server-side sort mode: emit event and update internal state for UI feedback
sortKey
.
value
=
key
sortOrder
.
value
=
newOrder
emit
(
'
sort
'
,
key
,
newOrder
)
}
else
{
// Client-side sort mode: just update internal state
sortKey
.
value
=
key
sortOrder
.
value
=
'
asc
'
sortOrder
.
value
=
newOrder
}
}
const
sortedData
=
computed
(()
=>
{
if
(
!
sortKey
.
value
||
!
props
.
data
)
return
props
.
data
return
[...
props
.
data
].
sort
((
a
,
b
)
=>
{
const
aVal
=
a
[
sortKey
.
value
]
const
bVal
=
b
[
sortKey
.
value
]
if
(
aVal
===
bVal
)
return
0
const
comparison
=
aVal
>
bVal
?
1
:
-
1
return
sortOrder
.
value
===
'
asc
'
?
comparison
:
-
comparison
// Server-side sort mode: return data as-is (server handles sorting)
if
(
props
.
serverSideSort
||
!
sortKey
.
value
||
!
props
.
data
)
return
props
.
data
const
key
=
sortKey
.
value
const
order
=
sortOrder
.
value
// Stable sort (tie-break with original index) to avoid jitter when values are equal.
return
props
.
data
.
map
((
row
,
index
)
=>
({
row
,
index
}))
.
sort
((
a
,
b
)
=>
{
const
cmp
=
compareSortValues
(
a
.
row
?.[
key
],
b
.
row
?.[
key
])
if
(
cmp
!==
0
)
return
order
===
'
asc
'
?
cmp
:
-
cmp
return
a
.
index
-
b
.
index
})
.
map
(
item
=>
item
.
row
)
})
const
hasActionsColumn
=
computed
(()
=>
{
...
...
@@ -396,6 +544,51 @@ const getAdaptivePaddingClass = () => {
return
'
px-6
'
// 24px (原始值)
}
}
// Init + keep persisted sort state consistent with current columns
const
didInitSort
=
ref
(
false
)
onMounted
(()
=>
{
const
initial
=
resolveInitialSortState
()
applySortState
(
initial
)
didInitSort
.
value
=
true
})
watch
(
()
=>
props
.
columns
,
()
=>
{
// If current sort key is no longer sortable/visible, fall back to default/persisted.
const
normalized
=
normalizeSortKey
(
sortKey
.
value
)
if
(
!
sortKey
.
value
)
{
const
initial
=
resolveInitialSortState
()
applySortState
(
initial
)
return
}
if
(
!
normalized
)
{
const
fallback
=
resolveInitialSortState
()
if
(
fallback
)
{
applySortState
(
fallback
)
}
else
{
sortKey
.
value
=
''
sortOrder
.
value
=
'
asc
'
}
}
},
{
deep
:
true
}
)
watch
(
[
sortKey
,
sortOrder
],
([
nextKey
,
nextOrder
])
=>
{
if
(
!
didInitSort
.
value
)
return
if
(
!
props
.
sortStorageKey
)
return
const
key
=
normalizeSortKey
(
nextKey
)
if
(
!
key
)
return
writePersistedSortState
({
key
,
order
:
normalizeSortOrder
(
nextOrder
)
})
},
{
flush
:
'
post
'
}
)
</
script
>
<
style
scoped
>
...
...
Prev
1
…
10
11
12
13
14
15
16
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