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
377bffe2
Commit
377bffe2
authored
Feb 03, 2026
by
yangjianbo
Browse files
Merge branch 'main' into test
parents
99250ec5
31fe0178
Changes
235
Hide whitespace changes
Inline
Side-by-side
frontend/src/api/admin/announcements.ts
0 → 100644
View file @
377bffe2
/**
* Admin Announcements API endpoints
*/
import
{
apiClient
}
from
'
../client
'
import
type
{
Announcement
,
AnnouncementUserReadStatus
,
BasePaginationResponse
,
CreateAnnouncementRequest
,
UpdateAnnouncementRequest
}
from
'
@/types
'
export
async
function
list
(
page
:
number
=
1
,
pageSize
:
number
=
20
,
filters
?:
{
status
?:
string
search
?:
string
}
):
Promise
<
BasePaginationResponse
<
Announcement
>>
{
const
{
data
}
=
await
apiClient
.
get
<
BasePaginationResponse
<
Announcement
>>
(
'
/admin/announcements
'
,
{
params
:
{
page
,
page_size
:
pageSize
,
...
filters
}
})
return
data
}
export
async
function
getById
(
id
:
number
):
Promise
<
Announcement
>
{
const
{
data
}
=
await
apiClient
.
get
<
Announcement
>
(
`/admin/announcements/
${
id
}
`
)
return
data
}
export
async
function
create
(
request
:
CreateAnnouncementRequest
):
Promise
<
Announcement
>
{
const
{
data
}
=
await
apiClient
.
post
<
Announcement
>
(
'
/admin/announcements
'
,
request
)
return
data
}
export
async
function
update
(
id
:
number
,
request
:
UpdateAnnouncementRequest
):
Promise
<
Announcement
>
{
const
{
data
}
=
await
apiClient
.
put
<
Announcement
>
(
`/admin/announcements/
${
id
}
`
,
request
)
return
data
}
export
async
function
deleteAnnouncement
(
id
:
number
):
Promise
<
{
message
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
delete
<
{
message
:
string
}
>
(
`/admin/announcements/
${
id
}
`
)
return
data
}
export
async
function
getReadStatus
(
id
:
number
,
page
:
number
=
1
,
pageSize
:
number
=
20
,
search
:
string
=
''
):
Promise
<
BasePaginationResponse
<
AnnouncementUserReadStatus
>>
{
const
{
data
}
=
await
apiClient
.
get
<
BasePaginationResponse
<
AnnouncementUserReadStatus
>>
(
`/admin/announcements/
${
id
}
/read-status`
,
{
params
:
{
page
,
page_size
:
pageSize
,
search
}
}
)
return
data
}
const
announcementsAPI
=
{
list
,
getById
,
create
,
update
,
delete
:
deleteAnnouncement
,
getReadStatus
}
export
default
announcementsAPI
frontend/src/api/admin/index.ts
View file @
377bffe2
...
...
@@ -10,6 +10,7 @@ import accountsAPI from './accounts'
import
proxiesAPI
from
'
./proxies
'
import
redeemAPI
from
'
./redeem
'
import
promoAPI
from
'
./promo
'
import
announcementsAPI
from
'
./announcements
'
import
settingsAPI
from
'
./settings
'
import
systemAPI
from
'
./system
'
import
subscriptionsAPI
from
'
./subscriptions
'
...
...
@@ -30,6 +31,7 @@ export const adminAPI = {
proxies
:
proxiesAPI
,
redeem
:
redeemAPI
,
promo
:
promoAPI
,
announcements
:
announcementsAPI
,
settings
:
settingsAPI
,
system
:
systemAPI
,
subscriptions
:
subscriptionsAPI
,
...
...
@@ -48,6 +50,7 @@ export {
proxiesAPI
,
redeemAPI
,
promoAPI
,
announcementsAPI
,
settingsAPI
,
systemAPI
,
subscriptionsAPI
,
...
...
@@ -59,3 +62,6 @@ export {
}
export
default
adminAPI
// Re-export types used by components
export
type
{
BalanceHistoryItem
}
from
'
./users
'
frontend/src/api/admin/ops.ts
View file @
377bffe2
...
...
@@ -353,6 +353,7 @@ export interface PlatformAvailability {
total_accounts
:
number
available_count
:
number
rate_limit_count
:
number
scope_rate_limit_count
?:
Record
<
string
,
number
>
error_count
:
number
}
...
...
@@ -363,6 +364,7 @@ export interface GroupAvailability {
total_accounts
:
number
available_count
:
number
rate_limit_count
:
number
scope_rate_limit_count
?:
Record
<
string
,
number
>
error_count
:
number
}
...
...
@@ -377,6 +379,7 @@ export interface AccountAvailability {
is_rate_limited
:
boolean
rate_limit_reset_at
?:
string
rate_limit_remaining_sec
?:
number
scope_rate_limits
?:
Record
<
string
,
number
>
is_overloaded
:
boolean
overload_until
?:
string
overload_remaining_sec
?:
number
...
...
@@ -776,6 +779,7 @@ export interface OpsAdvancedSettings {
ignore_count_tokens_errors
:
boolean
ignore_context_canceled
:
boolean
ignore_no_available_accounts
:
boolean
ignore_invalid_api_key_errors
:
boolean
auto_refresh_enabled
:
boolean
auto_refresh_interval_seconds
:
number
}
...
...
frontend/src/api/admin/settings.ts
View file @
377bffe2
...
...
@@ -13,6 +13,10 @@ export interface SystemSettings {
registration_enabled
:
boolean
email_verify_enabled
:
boolean
promo_code_enabled
:
boolean
password_reset_enabled
:
boolean
invitation_code_enabled
:
boolean
totp_enabled
:
boolean
// TOTP 双因素认证
totp_encryption_key_configured
:
boolean
// TOTP 加密密钥是否已配置
// Default settings
default_balance
:
number
default_concurrency
:
number
...
...
@@ -25,6 +29,8 @@ export interface SystemSettings {
doc_url
:
string
home_content
:
string
hide_ccs_import_button
:
boolean
purchase_subscription_enabled
:
boolean
purchase_subscription_url
:
string
// SMTP settings
smtp_host
:
string
smtp_port
:
number
...
...
@@ -66,6 +72,9 @@ export interface UpdateSettingsRequest {
registration_enabled
?:
boolean
email_verify_enabled
?:
boolean
promo_code_enabled
?:
boolean
password_reset_enabled
?:
boolean
invitation_code_enabled
?:
boolean
totp_enabled
?:
boolean
// TOTP 双因素认证
default_balance
?:
number
default_concurrency
?:
number
site_name
?:
string
...
...
@@ -76,6 +85,8 @@ export interface UpdateSettingsRequest {
doc_url
?:
string
home_content
?:
string
hide_ccs_import_button
?:
boolean
purchase_subscription_enabled
?:
boolean
purchase_subscription_url
?:
string
smtp_host
?:
string
smtp_port
?:
number
smtp_username
?:
string
...
...
frontend/src/api/admin/subscriptions.ts
View file @
377bffe2
...
...
@@ -17,7 +17,7 @@ import type {
* List all subscriptions with pagination
* @param page - Page number (default: 1)
* @param pageSize - Items per page (default: 20)
* @param filters - Optional filters (status, user_id, group_id)
* @param filters - Optional filters (status, user_id, group_id
, sort_by, sort_order
)
* @returns Paginated list of subscriptions
*/
export
async
function
list
(
...
...
@@ -27,6 +27,8 @@ export async function list(
status
?:
'
active
'
|
'
expired
'
|
'
revoked
'
user_id
?:
number
group_id
?:
number
sort_by
?:
string
sort_order
?:
'
asc
'
|
'
desc
'
},
options
?:
{
signal
?:
AbortSignal
...
...
frontend/src/api/admin/users.ts
View file @
377bffe2
...
...
@@ -174,6 +174,53 @@ export async function getUserUsageStats(
return
data
}
/**
* Balance history item returned from the API
*/
export
interface
BalanceHistoryItem
{
id
:
number
code
:
string
type
:
string
value
:
number
status
:
string
used_by
:
number
|
null
used_at
:
string
|
null
created_at
:
string
group_id
:
number
|
null
validity_days
:
number
notes
:
string
user
?:
{
id
:
number
;
email
:
string
}
|
null
group
?:
{
id
:
number
;
name
:
string
}
|
null
}
// Balance history response extends pagination with total_recharged summary
export
interface
BalanceHistoryResponse
extends
PaginatedResponse
<
BalanceHistoryItem
>
{
total_recharged
:
number
}
/**
* Get user's balance/concurrency change history
* @param id - User ID
* @param page - Page number
* @param pageSize - Items per page
* @param type - Optional type filter (balance, admin_balance, concurrency, admin_concurrency, subscription)
* @returns Paginated balance history with total_recharged
*/
export
async
function
getUserBalanceHistory
(
id
:
number
,
page
:
number
=
1
,
pageSize
:
number
=
20
,
type
?:
string
):
Promise
<
BalanceHistoryResponse
>
{
const
params
:
Record
<
string
,
any
>
=
{
page
,
page_size
:
pageSize
}
if
(
type
)
params
.
type
=
type
const
{
data
}
=
await
apiClient
.
get
<
BalanceHistoryResponse
>
(
`/admin/users/
${
id
}
/balance-history`
,
{
params
}
)
return
data
}
export
const
usersAPI
=
{
list
,
getById
,
...
...
@@ -184,7 +231,8 @@ export const usersAPI = {
updateConcurrency
,
toggleStatus
,
getUserApiKeys
,
getUserUsageStats
getUserUsageStats
,
getUserBalanceHistory
}
export
default
usersAPI
frontend/src/api/announcements.ts
0 → 100644
View file @
377bffe2
/**
* User Announcements API endpoints
*/
import
{
apiClient
}
from
'
./client
'
import
type
{
UserAnnouncement
}
from
'
@/types
'
export
async
function
list
(
unreadOnly
:
boolean
=
false
):
Promise
<
UserAnnouncement
[]
>
{
const
{
data
}
=
await
apiClient
.
get
<
UserAnnouncement
[]
>
(
'
/announcements
'
,
{
params
:
unreadOnly
?
{
unread_only
:
1
}
:
{}
})
return
data
}
export
async
function
markRead
(
id
:
number
):
Promise
<
{
message
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
post
<
{
message
:
string
}
>
(
`/announcements/
${
id
}
/read`
)
return
data
}
const
announcementsAPI
=
{
list
,
markRead
}
export
default
announcementsAPI
frontend/src/api/auth.ts
View file @
377bffe2
...
...
@@ -11,9 +11,23 @@ import type {
CurrentUserResponse
,
SendVerifyCodeRequest
,
SendVerifyCodeResponse
,
PublicSettings
PublicSettings
,
TotpLoginResponse
,
TotpLogin2FARequest
}
from
'
@/types
'
/**
* Login response type - can be either full auth or 2FA required
*/
export
type
LoginResponse
=
AuthResponse
|
TotpLoginResponse
/**
* Type guard to check if login response requires 2FA
*/
export
function
isTotp2FARequired
(
response
:
LoginResponse
):
response
is
TotpLoginResponse
{
return
'
requires_2fa
'
in
response
&&
response
.
requires_2fa
===
true
}
/**
* Store authentication token in localStorage
*/
...
...
@@ -38,11 +52,28 @@ export function clearAuthToken(): void {
/**
* User login
* @param credentials - Username and password
* @param credentials - Email and password
* @returns Authentication response with token and user data, or 2FA required response
*/
export
async
function
login
(
credentials
:
LoginRequest
):
Promise
<
LoginResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
LoginResponse
>
(
'
/auth/login
'
,
credentials
)
// Only store token if 2FA is not required
if
(
!
isTotp2FARequired
(
data
))
{
setAuthToken
(
data
.
access_token
)
localStorage
.
setItem
(
'
auth_user
'
,
JSON
.
stringify
(
data
.
user
))
}
return
data
}
/**
* Complete login with 2FA code
* @param request - Temp token and TOTP code
* @returns Authentication response with token and user data
*/
export
async
function
login
(
credentials
:
LoginRequest
):
Promise
<
AuthResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
AuthResponse
>
(
'
/auth/login
'
,
c
re
dentials
)
export
async
function
login
2FA
(
request
:
Totp
Login
2FA
Request
):
Promise
<
AuthResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
AuthResponse
>
(
'
/auth/login
/2fa
'
,
re
quest
)
// Store token and user data
setAuthToken
(
data
.
access_token
)
...
...
@@ -133,8 +164,79 @@ export async function validatePromoCode(code: string): Promise<ValidatePromoCode
return
data
}
/**
* Validate invitation code response
*/
export
interface
ValidateInvitationCodeResponse
{
valid
:
boolean
error_code
?:
string
}
/**
* Validate invitation code (public endpoint, no auth required)
* @param code - Invitation code to validate
* @returns Validation result
*/
export
async
function
validateInvitationCode
(
code
:
string
):
Promise
<
ValidateInvitationCodeResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
ValidateInvitationCodeResponse
>
(
'
/auth/validate-invitation-code
'
,
{
code
})
return
data
}
/**
* Forgot password request
*/
export
interface
ForgotPasswordRequest
{
email
:
string
turnstile_token
?:
string
}
/**
* Forgot password response
*/
export
interface
ForgotPasswordResponse
{
message
:
string
}
/**
* Request password reset link
* @param request - Email and optional Turnstile token
* @returns Response with message
*/
export
async
function
forgotPassword
(
request
:
ForgotPasswordRequest
):
Promise
<
ForgotPasswordResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
ForgotPasswordResponse
>
(
'
/auth/forgot-password
'
,
request
)
return
data
}
/**
* Reset password request
*/
export
interface
ResetPasswordRequest
{
email
:
string
token
:
string
new_password
:
string
}
/**
* Reset password response
*/
export
interface
ResetPasswordResponse
{
message
:
string
}
/**
* Reset password with token
* @param request - Email, token, and new password
* @returns Response with message
*/
export
async
function
resetPassword
(
request
:
ResetPasswordRequest
):
Promise
<
ResetPasswordResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
ResetPasswordResponse
>
(
'
/auth/reset-password
'
,
request
)
return
data
}
export
const
authAPI
=
{
login
,
login2FA
,
isTotp2FARequired
,
register
,
getCurrentUser
,
logout
,
...
...
@@ -144,7 +246,10 @@ export const authAPI = {
clearAuthToken
,
getPublicSettings
,
sendVerifyCode
,
validatePromoCode
validatePromoCode
,
validateInvitationCode
,
forgotPassword
,
resetPassword
}
export
default
authAPI
frontend/src/api/index.ts
View file @
377bffe2
...
...
@@ -7,7 +7,7 @@
export
{
apiClient
}
from
'
./client
'
// Auth API
export
{
authAPI
}
from
'
./auth
'
export
{
authAPI
,
isTotp2FARequired
,
type
LoginResponse
}
from
'
./auth
'
// User APIs
export
{
keysAPI
}
from
'
./keys
'
...
...
@@ -15,6 +15,8 @@ export { usageAPI } from './usage'
export
{
userAPI
}
from
'
./user
'
export
{
redeemAPI
,
type
RedeemHistoryItem
}
from
'
./redeem
'
export
{
userGroupsAPI
}
from
'
./groups
'
export
{
totpAPI
}
from
'
./totp
'
export
{
default
as
announcementsAPI
}
from
'
./announcements
'
// Admin APIs
export
{
adminAPI
}
from
'
./admin
'
...
...
frontend/src/api/redeem.ts
View file @
377bffe2
...
...
@@ -14,7 +14,9 @@ export interface RedeemHistoryItem {
status
:
string
used_at
:
string
created_at
:
string
// 订阅类型专用字段
// Notes from admin for admin_balance/admin_concurrency types
notes
?:
string
// Subscription-specific fields
group_id
?:
number
validity_days
?:
number
group
?:
{
...
...
frontend/src/api/setup.ts
View file @
377bffe2
...
...
@@ -31,6 +31,7 @@ export interface RedisConfig {
port
:
number
password
:
string
db
:
number
enable_tls
:
boolean
}
export
interface
AdminConfig
{
...
...
frontend/src/api/totp.ts
0 → 100644
View file @
377bffe2
/**
* TOTP (2FA) API endpoints
* Handles Two-Factor Authentication with Google Authenticator
*/
import
{
apiClient
}
from
'
./client
'
import
type
{
TotpStatus
,
TotpSetupRequest
,
TotpSetupResponse
,
TotpEnableRequest
,
TotpEnableResponse
,
TotpDisableRequest
,
TotpVerificationMethod
}
from
'
@/types
'
/**
* Get TOTP status for current user
* @returns TOTP status including enabled state and feature availability
*/
export
async
function
getStatus
():
Promise
<
TotpStatus
>
{
const
{
data
}
=
await
apiClient
.
get
<
TotpStatus
>
(
'
/user/totp/status
'
)
return
data
}
/**
* Get verification method for TOTP operations
* @returns Method ('email' or 'password') required for setup/disable
*/
export
async
function
getVerificationMethod
():
Promise
<
TotpVerificationMethod
>
{
const
{
data
}
=
await
apiClient
.
get
<
TotpVerificationMethod
>
(
'
/user/totp/verification-method
'
)
return
data
}
/**
* Send email verification code for TOTP operations
* @returns Success response
*/
export
async
function
sendVerifyCode
():
Promise
<
{
success
:
boolean
}
>
{
const
{
data
}
=
await
apiClient
.
post
<
{
success
:
boolean
}
>
(
'
/user/totp/send-code
'
)
return
data
}
/**
* Initiate TOTP setup - generates secret and QR code
* @param request - Email code or password depending on verification method
* @returns Setup response with secret, QR code URL, and setup token
*/
export
async
function
initiateSetup
(
request
?:
TotpSetupRequest
):
Promise
<
TotpSetupResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
TotpSetupResponse
>
(
'
/user/totp/setup
'
,
request
||
{})
return
data
}
/**
* Complete TOTP setup by verifying the code
* @param request - TOTP code and setup token
* @returns Enable response with success status and enabled timestamp
*/
export
async
function
enable
(
request
:
TotpEnableRequest
):
Promise
<
TotpEnableResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
TotpEnableResponse
>
(
'
/user/totp/enable
'
,
request
)
return
data
}
/**
* Disable TOTP for current user
* @param request - Email code or password depending on verification method
* @returns Success response
*/
export
async
function
disable
(
request
:
TotpDisableRequest
):
Promise
<
{
success
:
boolean
}
>
{
const
{
data
}
=
await
apiClient
.
post
<
{
success
:
boolean
}
>
(
'
/user/totp/disable
'
,
request
)
return
data
}
export
const
totpAPI
=
{
getStatus
,
getVerificationMethod
,
sendVerifyCode
,
initiateSetup
,
enable
,
disable
}
export
default
totpAPI
frontend/src/components/account/AccountStatusIndicator.vue
View file @
377bffe2
...
...
@@ -56,6 +56,65 @@
></div>
</div>
</div>
<!-- Rate Limit Indicator (429) -->
<div
v-if=
"isRateLimited"
class=
"group relative"
>
<span
class=
"inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
>
<Icon
name=
"exclamationTriangle"
size=
"xs"
:stroke-width=
"2"
/>
429
</span>
<!-- Tooltip -->
<div
class=
"pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
{{ t('admin.accounts.status.rateLimitedUntil', { time: formatTime(account.rate_limit_reset_at) }) }}
<div
class=
"absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
></div>
</div>
</div>
<!-- Scope Rate Limit Indicators (Antigravity) -->
<
template
v-if=
"activeScopeRateLimits.length > 0"
>
<div
v-for=
"item in activeScopeRateLimits"
:key=
"item.scope"
class=
"group relative"
>
<span
class=
"inline-flex items-center gap-1 rounded bg-orange-100 px-1.5 py-0.5 text-xs font-medium text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
>
<Icon
name=
"exclamationTriangle"
size=
"xs"
:stroke-width=
"2"
/>
{{
formatScopeName
(
item
.
scope
)
}}
</span>
<!-- Tooltip -->
<div
class=
"pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
{{
t
(
'
admin.accounts.status.scopeRateLimitedUntil
'
,
{
scope
:
formatScopeName
(
item
.
scope
),
time
:
formatTime
(
item
.
reset_at
)
}
)
}}
<
div
class
=
"
absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700
"
><
/div
>
<
/div
>
<
/div
>
<
/template
>
<!--
Overload
Indicator
(
529
)
-->
<
div
v
-
if
=
"
isOverloaded
"
class
=
"
group relative
"
>
<
span
class
=
"
inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400
"
>
<
Icon
name
=
"
exclamationTriangle
"
size
=
"
xs
"
:
stroke
-
width
=
"
2
"
/>
529
<
/span
>
<!--
Tooltip
-->
<
div
class
=
"
pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700
"
>
{{
t
(
'
admin.accounts.status.overloadedUntil
'
,
{
time
:
formatTime
(
account
.
overload_until
)
}
)
}}
<
div
class
=
"
absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700
"
><
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/template
>
...
...
@@ -63,7 +122,7 @@
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
type
{
Account
}
from
'
@/types
'
import
{
formatCountdownWithSuffix
}
from
'
@/utils/format
'
import
{
formatCountdownWithSuffix
,
formatTime
}
from
'
@/utils/format
'
const
{
t
}
=
useI18n
()
...
...
@@ -81,6 +140,25 @@ const isRateLimited = computed(() => {
return
new
Date
(
props
.
account
.
rate_limit_reset_at
)
>
new
Date
()
}
)
// Computed: active scope rate limits (Antigravity)
const
activeScopeRateLimits
=
computed
(()
=>
{
const
scopeLimits
=
props
.
account
.
scope_rate_limits
if
(
!
scopeLimits
)
return
[]
const
now
=
new
Date
()
return
Object
.
entries
(
scopeLimits
)
.
filter
(([,
info
])
=>
new
Date
(
info
.
reset_at
)
>
now
)
.
map
(([
scope
,
info
])
=>
({
scope
,
reset_at
:
info
.
reset_at
}
))
}
)
const
formatScopeName
=
(
scope
:
string
):
string
=>
{
const
names
:
Record
<
string
,
string
>
=
{
claude
:
'
Claude
'
,
gemini_text
:
'
Gemini
'
,
gemini_image
:
'
Image
'
}
return
names
[
scope
]
||
scope
}
// Computed: is overloaded (529)
const
isOverloaded
=
computed
(()
=>
{
if
(
!
props
.
account
.
overload_until
)
return
false
...
...
frontend/src/components/account/CreateAccountModal.vue
View file @
377bffe2
...
...
@@ -1825,6 +1825,18 @@
<
/div
>
<
/template
>
<
/BaseDialog
>
<!--
Mixed
Channel
Warning
Dialog
-->
<
ConfirmDialog
:
show
=
"
showMixedChannelWarning
"
:
title
=
"
t('admin.accounts.mixedChannelWarningTitle')
"
:
message
=
"
mixedChannelWarningDetails ? t('admin.accounts.mixedChannelWarning', mixedChannelWarningDetails) : ''
"
:
confirm
-
text
=
"
t('common.confirm')
"
:
cancel
-
text
=
"
t('common.cancel')
"
:
danger
=
"
true
"
@
confirm
=
"
handleMixedChannelConfirm
"
@
cancel
=
"
handleMixedChannelCancel
"
/>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
...
...
@@ -1844,6 +1856,7 @@ import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import
{
useAntigravityOAuth
}
from
'
@/composables/useAntigravityOAuth
'
import
type
{
Proxy
,
AdminGroup
,
AccountPlatform
,
AccountType
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
ProxySelector
from
'
@/components/common/ProxySelector.vue
'
import
GroupSelector
from
'
@/components/common/GroupSelector.vue
'
...
...
@@ -1971,6 +1984,11 @@ const tempUnschedEnabled = ref(false)
const
tempUnschedRules
=
ref
<
TempUnschedRuleForm
[]
>
([])
const
geminiOAuthType
=
ref
<
'
code_assist
'
|
'
google_one
'
|
'
ai_studio
'
>
(
'
google_one
'
)
const
geminiAIStudioOAuthEnabled
=
ref
(
false
)
// Mixed channel warning dialog state
const
showMixedChannelWarning
=
ref
(
false
)
const
mixedChannelWarningDetails
=
ref
<
{
groupName
:
string
;
currentPlatform
:
string
;
otherPlatform
:
string
}
|
null
>
(
null
)
const
pendingCreatePayload
=
ref
<
any
>
(
null
)
const
showAdvancedOAuth
=
ref
(
false
)
const
showGeminiHelpDialog
=
ref
(
false
)
...
...
@@ -2388,6 +2406,59 @@ const handleClose = () => {
emit
(
'
close
'
)
}
// Helper function to create account with mixed channel warning handling
const
doCreateAccount
=
async
(
payload
:
any
)
=>
{
submitting
.
value
=
true
try
{
await
adminAPI
.
accounts
.
create
(
payload
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.accountCreated
'
))
emit
(
'
created
'
)
handleClose
()
}
catch
(
error
:
any
)
{
// Handle 409 mixed_channel_warning - show confirmation dialog
if
(
error
.
response
?.
status
===
409
&&
error
.
response
?.
data
?.
error
===
'
mixed_channel_warning
'
)
{
const
details
=
error
.
response
.
data
.
details
||
{
}
mixedChannelWarningDetails
.
value
=
{
groupName
:
details
.
group_name
||
'
Unknown
'
,
currentPlatform
:
details
.
current_platform
||
'
Unknown
'
,
otherPlatform
:
details
.
other_platform
||
'
Unknown
'
}
pendingCreatePayload
.
value
=
payload
showMixedChannelWarning
.
value
=
true
}
else
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.failedToCreate
'
))
}
}
finally
{
submitting
.
value
=
false
}
}
// Handle mixed channel warning confirmation
const
handleMixedChannelConfirm
=
async
()
=>
{
showMixedChannelWarning
.
value
=
false
if
(
pendingCreatePayload
.
value
)
{
pendingCreatePayload
.
value
.
confirm_mixed_channel_risk
=
true
submitting
.
value
=
true
try
{
await
adminAPI
.
accounts
.
create
(
pendingCreatePayload
.
value
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.accountCreated
'
))
emit
(
'
created
'
)
handleClose
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.failedToCreate
'
))
}
finally
{
submitting
.
value
=
false
pendingCreatePayload
.
value
=
null
}
}
}
const
handleMixedChannelCancel
=
()
=>
{
showMixedChannelWarning
.
value
=
false
pendingCreatePayload
.
value
=
null
mixedChannelWarningDetails
.
value
=
null
}
const
handleSubmit
=
async
()
=>
{
// For OAuth-based type, handle OAuth flow (goes to step 2)
if
(
isOAuthFlow
.
value
)
{
...
...
@@ -2444,21 +2515,11 @@ const handleSubmit = async () => {
form
.
credentials
=
credentials
submitting.value = true
try {
await adminAPI.accounts.create({
...form,
group_ids: form.group_ids,
auto_pause_on_expired: autoPauseOnExpired.value
})
appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created')
handleClose()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToCreate'))
} finally {
submitting.value = false
}
await
doCreateAccount
({
...
form
,
group_ids
:
form
.
group_ids
,
auto_pause_on_expired
:
autoPauseOnExpired
.
value
}
)
}
const
goBackToBasicInfo
=
()
=>
{
...
...
frontend/src/components/account/EditAccountModal.vue
View file @
377bffe2
...
...
@@ -875,6 +875,18 @@
<
/div
>
<
/template
>
<
/BaseDialog
>
<!--
Mixed
Channel
Warning
Dialog
-->
<
ConfirmDialog
:
show
=
"
showMixedChannelWarning
"
:
title
=
"
t('admin.accounts.mixedChannelWarningTitle')
"
:
message
=
"
mixedChannelWarningDetails ? t('admin.accounts.mixedChannelWarning', mixedChannelWarningDetails) : ''
"
:
confirm
-
text
=
"
t('common.confirm')
"
:
cancel
-
text
=
"
t('common.cancel')
"
:
danger
=
"
true
"
@
confirm
=
"
handleMixedChannelConfirm
"
@
cancel
=
"
handleMixedChannelCancel
"
/>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
...
...
@@ -885,6 +897,7 @@ import { useAuthStore } from '@/stores/auth'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Account
,
Proxy
,
AdminGroup
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
ProxySelector
from
'
@/components/common/ProxySelector.vue
'
...
...
@@ -951,6 +964,11 @@ const mixedScheduling = ref(false) // For antigravity accounts: enable mixed sch
const
tempUnschedEnabled
=
ref
(
false
)
const
tempUnschedRules
=
ref
<
TempUnschedRuleForm
[]
>
([])
// Mixed channel warning dialog state
const
showMixedChannelWarning
=
ref
(
false
)
const
mixedChannelWarningDetails
=
ref
<
{
groupName
:
string
;
currentPlatform
:
string
;
otherPlatform
:
string
}
|
null
>
(
null
)
const
pendingUpdatePayload
=
ref
<
Record
<
string
,
unknown
>
|
null
>
(
null
)
// Quota control state (Anthropic OAuth/SetupToken only)
const
windowCostEnabled
=
ref
(
false
)
const
windowCostLimit
=
ref
<
number
|
null
>
(
null
)
...
...
@@ -1366,8 +1384,8 @@ const handleSubmit = async () => {
if
(
!
props
.
account
)
return
submitting
.
value
=
true
const
updatePayload
:
Record
<
string
,
unknown
>
=
{
...
form
}
try
{
const
updatePayload
:
Record
<
string
,
unknown
>
=
{
...
form
}
// 后端期望 proxy_id: 0 表示清除代理,而不是 null
if
(
updatePayload
.
proxy_id
===
null
)
{
updatePayload
.
proxy_id
=
0
...
...
@@ -1497,9 +1515,47 @@ const handleSubmit = async () => {
emit
(
'
updated
'
)
handleClose
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
message
||
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.failedToUpdate
'
))
// Handle 409 mixed_channel_warning - show confirmation dialog
if
(
error
.
response
?.
status
===
409
&&
error
.
response
?.
data
?.
error
===
'
mixed_channel_warning
'
)
{
const
details
=
error
.
response
.
data
.
details
||
{
}
mixedChannelWarningDetails
.
value
=
{
groupName
:
details
.
group_name
||
'
Unknown
'
,
currentPlatform
:
details
.
current_platform
||
'
Unknown
'
,
otherPlatform
:
details
.
other_platform
||
'
Unknown
'
}
pendingUpdatePayload
.
value
=
updatePayload
showMixedChannelWarning
.
value
=
true
}
else
{
appStore
.
showError
(
error
.
response
?.
data
?.
message
||
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.failedToUpdate
'
))
}
}
finally
{
submitting
.
value
=
false
}
}
// Handle mixed channel warning confirmation
const
handleMixedChannelConfirm
=
async
()
=>
{
showMixedChannelWarning
.
value
=
false
if
(
pendingUpdatePayload
.
value
&&
props
.
account
)
{
pendingUpdatePayload
.
value
.
confirm_mixed_channel_risk
=
true
submitting
.
value
=
true
try
{
await
adminAPI
.
accounts
.
update
(
props
.
account
.
id
,
pendingUpdatePayload
.
value
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.accountUpdated
'
))
emit
(
'
updated
'
)
handleClose
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
message
||
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.failedToUpdate
'
))
}
finally
{
submitting
.
value
=
false
pendingUpdatePayload
.
value
=
null
}
}
}
const
handleMixedChannelCancel
=
()
=>
{
showMixedChannelWarning
.
value
=
false
pendingUpdatePayload
.
value
=
null
mixedChannelWarningDetails
.
value
=
null
}
<
/script
>
frontend/src/components/admin/announcements/AnnouncementReadStatusDialog.vue
0 → 100644
View file @
377bffe2
<
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 @
377bffe2
<
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/UsageTable.vue
View file @
377bffe2
...
...
@@ -21,6 +21,12 @@
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
value
}}
</span>
</
template
>
<
template
#cell-reasoning_effort=
"{ row }"
>
<span
class=
"text-sm text-gray-900 dark:text-white"
>
{{
formatReasoningEffort
(
row
.
reasoning_effort
)
}}
</span>
</
template
>
<
template
#cell-group=
"{ row }"
>
<span
v-if=
"row.group"
class=
"inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200"
>
{{
row
.
group
.
name
}}
...
...
@@ -232,14 +238,14 @@
</Teleport>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
formatDateTime
}
from
'
@/utils/format
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
type
{
AdminUsageLog
}
from
'
@/types
'
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
formatDateTime
,
formatReasoningEffort
}
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
{
AdminUsageLog
}
from
'
@/types
'
defineProps
([
'
data
'
,
'
loading
'
])
const
{
t
}
=
useI18n
()
...
...
@@ -259,6 +265,7 @@ const cols = computed(() => [
{
key
:
'
api_key
'
,
label
:
t
(
'
usage.apiKeyFilter
'
),
sortable
:
false
},
{
key
:
'
account
'
,
label
:
t
(
'
admin.usage.account
'
),
sortable
:
false
},
{
key
:
'
model
'
,
label
:
t
(
'
usage.model
'
),
sortable
:
true
},
{
key
:
'
reasoning_effort
'
,
label
:
t
(
'
usage.reasoningEffort
'
),
sortable
:
false
},
{
key
:
'
group
'
,
label
:
t
(
'
admin.usage.group
'
),
sortable
:
false
},
{
key
:
'
stream
'
,
label
:
t
(
'
usage.type
'
),
sortable
:
false
},
{
key
:
'
tokens
'
,
label
:
t
(
'
usage.tokens
'
),
sortable
:
false
},
...
...
frontend/src/components/admin/user/UserBalanceHistoryModal.vue
0 → 100644
View file @
377bffe2
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.users.balanceHistoryTitle')"
width=
"wide"
:close-on-click-outside=
"true"
:z-index=
"40"
@
close=
"$emit('close')"
>
<div
v-if=
"user"
class=
"space-y-4"
>
<!-- User header: two-row layout with full user info -->
<div
class=
"rounded-xl bg-gray-50 p-4 dark:bg-dark-700"
>
<!-- Row 1: avatar + email/username/created_at (left) + current balance (right) -->
<div
class=
"flex items-center gap-3"
>
<div
class=
"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30"
>
<span
class=
"text-lg font-medium text-primary-700 dark:text-primary-300"
>
{{
user
.
email
.
charAt
(
0
).
toUpperCase
()
}}
</span>
</div>
<div
class=
"min-w-0 flex-1"
>
<div
class=
"flex items-center gap-2"
>
<p
class=
"truncate font-medium text-gray-900 dark:text-white"
>
{{
user
.
email
}}
</p>
<span
v-if=
"user.username"
class=
"flex-shrink-0 rounded bg-primary-50 px-1.5 py-0.5 text-xs text-primary-600 dark:bg-primary-900/20 dark:text-primary-400"
>
{{
user
.
username
}}
</span>
</div>
<p
class=
"text-xs text-gray-400 dark:text-dark-500"
>
{{
t
(
'
admin.users.createdAt
'
)
}}
:
{{
formatDateTime
(
user
.
created_at
)
}}
</p>
</div>
<!-- Current balance: prominent display on the right -->
<div
class=
"flex-shrink-0 text-right"
>
<p
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
t
(
'
admin.users.currentBalance
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
$
{{
user
.
balance
?.
toFixed
(
2
)
||
'
0.00
'
}}
</p>
</div>
</div>
<!-- Row 2: notes + total recharged -->
<div
class=
"mt-2.5 flex items-center justify-between border-t border-gray-200/60 pt-2.5 dark:border-dark-600/60"
>
<p
class=
"min-w-0 flex-1 truncate text-xs text-gray-500 dark:text-dark-400"
:title=
"user.notes || ''"
>
<template
v-if=
"user.notes"
>
{{
t
(
'
admin.users.notes
'
)
}}
:
{{
user
.
notes
}}
</
template
>
<
template
v-else
>
</
template
>
</p>
<p
class=
"ml-4 flex-shrink-0 text-xs text-gray-500 dark:text-dark-400"
>
{{ t('admin.users.totalRecharged') }}:
<span
class=
"font-semibold text-emerald-600 dark:text-emerald-400"
>
${{ totalRecharged.toFixed(2) }}
</span>
</p>
</div>
</div>
<!-- Type filter + Action buttons -->
<div
class=
"flex items-center gap-3"
>
<Select
v-model=
"typeFilter"
:options=
"typeOptions"
class=
"w-56"
@
change=
"loadHistory(1)"
/>
<!-- Deposit button - matches menu style -->
<button
@
click=
"emit('deposit')"
class=
"flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:bg-dark-700"
>
<Icon
name=
"plus"
size=
"sm"
class=
"text-emerald-500"
:stroke-width=
"2"
/>
{{ t('admin.users.deposit') }}
</button>
<!-- Withdraw button - matches menu style -->
<button
@
click=
"emit('withdraw')"
class=
"flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:bg-dark-700"
>
<svg
class=
"h-4 w-4 text-amber-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M20 12H4"
/>
</svg>
{{ t('admin.users.withdraw') }}
</button>
</div>
<!-- Loading -->
<div
v-if=
"loading"
class=
"flex justify-center py-8"
>
<svg
class=
"h-8 w-8 animate-spin text-primary-500"
fill=
"none"
viewBox=
"0 0 24 24"
>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
/>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
<!-- Empty state -->
<div
v-else-if=
"history.length === 0"
class=
"py-8 text-center"
>
<p
class=
"text-sm text-gray-500"
>
{{ t('admin.users.noBalanceHistory') }}
</p>
</div>
<!-- History list -->
<div
v-else
class=
"max-h-[28rem] space-y-3 overflow-y-auto"
>
<div
v-for=
"item in history"
:key=
"item.id"
class=
"rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-600 dark:bg-dark-800"
>
<div
class=
"flex items-start justify-between"
>
<!-- Left: type icon + description -->
<div
class=
"flex items-start gap-3"
>
<div
:class=
"[
'flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg',
getIconBg(item)
]"
>
<Icon
:name=
"getIconName(item)"
size=
"sm"
:class=
"getIconColor(item)"
/>
</div>
<div>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{ getItemTitle(item) }}
</p>
<!-- Notes (admin adjustment reason) -->
<p
v-if=
"item.notes"
class=
"mt-0.5 text-xs text-gray-500 dark:text-dark-400"
:title=
"item.notes"
>
{{ item.notes.length > 60 ? item.notes.substring(0, 55) + '...' : item.notes }}
</p>
<p
class=
"mt-0.5 text-xs text-gray-400 dark:text-dark-500"
>
{{ formatDateTime(item.used_at || item.created_at) }}
</p>
</div>
</div>
<!-- Right: value -->
<div
class=
"text-right"
>
<p
:class=
"['text-sm font-semibold', getValueColor(item)]"
>
{{ formatValue(item) }}
</p>
<p
v-if=
"isAdminType(item.type)"
class=
"text-xs text-gray-400 dark:text-dark-500"
>
{{ t('redeem.adminAdjustment') }}
</p>
<p
v-else
class=
"font-mono text-xs text-gray-400 dark:text-dark-500"
>
{{ item.code.slice(0, 8) }}...
</p>
</div>
</div>
</div>
</div>
<!-- Pagination -->
<div
v-if=
"totalPages > 1"
class=
"flex items-center justify-center gap-2 pt-2"
>
<button
:disabled=
"currentPage <= 1"
class=
"btn btn-secondary px-3 py-1 text-sm"
@
click=
"loadHistory(currentPage - 1)"
>
{{ t('pagination.previous') }}
</button>
<span
class=
"text-sm text-gray-500 dark:text-dark-400"
>
{{ currentPage }} / {{ totalPages }}
</span>
<button
:disabled=
"currentPage >= totalPages"
class=
"btn btn-secondary px-3 py-1 text-sm"
@
click=
"loadHistory(currentPage + 1)"
>
{{ t('pagination.next') }}
</button>
</div>
</div>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
adminAPI
,
type
BalanceHistoryItem
}
from
'
@/api/admin
'
import
{
formatDateTime
}
from
'
@/utils/format
'
import
type
{
AdminUser
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
const
props
=
defineProps
<
{
show
:
boolean
;
user
:
AdminUser
|
null
}
>
()
const
emit
=
defineEmits
([
'
close
'
,
'
deposit
'
,
'
withdraw
'
])
const
{
t
}
=
useI18n
()
const
history
=
ref
<
BalanceHistoryItem
[]
>
([])
const
loading
=
ref
(
false
)
const
currentPage
=
ref
(
1
)
const
total
=
ref
(
0
)
const
totalRecharged
=
ref
(
0
)
const
pageSize
=
15
const
typeFilter
=
ref
(
''
)
const
totalPages
=
computed
(()
=>
Math
.
ceil
(
total
.
value
/
pageSize
)
||
1
)
// Type filter options
const
typeOptions
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.users.allTypes
'
)
},
{
value
:
'
balance
'
,
label
:
t
(
'
admin.users.typeBalance
'
)
},
{
value
:
'
admin_balance
'
,
label
:
t
(
'
admin.users.typeAdminBalance
'
)
},
{
value
:
'
concurrency
'
,
label
:
t
(
'
admin.users.typeConcurrency
'
)
},
{
value
:
'
admin_concurrency
'
,
label
:
t
(
'
admin.users.typeAdminConcurrency
'
)
},
{
value
:
'
subscription
'
,
label
:
t
(
'
admin.users.typeSubscription
'
)
}
])
// Watch modal open
watch
(()
=>
props
.
show
,
(
v
)
=>
{
if
(
v
&&
props
.
user
)
{
typeFilter
.
value
=
''
loadHistory
(
1
)
}
})
const
loadHistory
=
async
(
page
:
number
)
=>
{
if
(
!
props
.
user
)
return
loading
.
value
=
true
currentPage
.
value
=
page
try
{
const
res
=
await
adminAPI
.
users
.
getUserBalanceHistory
(
props
.
user
.
id
,
page
,
pageSize
,
typeFilter
.
value
||
undefined
)
history
.
value
=
res
.
items
||
[]
total
.
value
=
res
.
total
||
0
totalRecharged
.
value
=
res
.
total_recharged
||
0
}
catch
(
error
)
{
console
.
error
(
'
Failed to load balance history:
'
,
error
)
}
finally
{
loading
.
value
=
false
}
}
// Helper: check if admin type
const
isAdminType
=
(
type
:
string
)
=>
type
===
'
admin_balance
'
||
type
===
'
admin_concurrency
'
// Helper: check if balance type (includes admin_balance)
const
isBalanceType
=
(
type
:
string
)
=>
type
===
'
balance
'
||
type
===
'
admin_balance
'
// Helper: check if subscription type
const
isSubscriptionType
=
(
type
:
string
)
=>
type
===
'
subscription
'
// Icon name based on type
const
getIconName
=
(
item
:
BalanceHistoryItem
)
=>
{
if
(
isBalanceType
(
item
.
type
))
return
'
dollar
'
if
(
isSubscriptionType
(
item
.
type
))
return
'
badge
'
return
'
bolt
'
// concurrency
}
// Icon background color
const
getIconBg
=
(
item
:
BalanceHistoryItem
)
=>
{
if
(
isBalanceType
(
item
.
type
))
{
return
item
.
value
>=
0
?
'
bg-emerald-100 dark:bg-emerald-900/30
'
:
'
bg-red-100 dark:bg-red-900/30
'
}
if
(
isSubscriptionType
(
item
.
type
))
return
'
bg-purple-100 dark:bg-purple-900/30
'
return
item
.
value
>=
0
?
'
bg-blue-100 dark:bg-blue-900/30
'
:
'
bg-orange-100 dark:bg-orange-900/30
'
}
// Icon text color
const
getIconColor
=
(
item
:
BalanceHistoryItem
)
=>
{
if
(
isBalanceType
(
item
.
type
))
{
return
item
.
value
>=
0
?
'
text-emerald-600 dark:text-emerald-400
'
:
'
text-red-600 dark:text-red-400
'
}
if
(
isSubscriptionType
(
item
.
type
))
return
'
text-purple-600 dark:text-purple-400
'
return
item
.
value
>=
0
?
'
text-blue-600 dark:text-blue-400
'
:
'
text-orange-600 dark:text-orange-400
'
}
// Value text color
const
getValueColor
=
(
item
:
BalanceHistoryItem
)
=>
{
if
(
isBalanceType
(
item
.
type
))
{
return
item
.
value
>=
0
?
'
text-emerald-600 dark:text-emerald-400
'
:
'
text-red-600 dark:text-red-400
'
}
if
(
isSubscriptionType
(
item
.
type
))
return
'
text-purple-600 dark:text-purple-400
'
return
item
.
value
>=
0
?
'
text-blue-600 dark:text-blue-400
'
:
'
text-orange-600 dark:text-orange-400
'
}
// Item title
const
getItemTitle
=
(
item
:
BalanceHistoryItem
)
=>
{
switch
(
item
.
type
)
{
case
'
balance
'
:
return
t
(
'
redeem.balanceAddedRedeem
'
)
case
'
admin_balance
'
:
return
item
.
value
>=
0
?
t
(
'
redeem.balanceAddedAdmin
'
)
:
t
(
'
redeem.balanceDeductedAdmin
'
)
case
'
concurrency
'
:
return
t
(
'
redeem.concurrencyAddedRedeem
'
)
case
'
admin_concurrency
'
:
return
item
.
value
>=
0
?
t
(
'
redeem.concurrencyAddedAdmin
'
)
:
t
(
'
redeem.concurrencyReducedAdmin
'
)
case
'
subscription
'
:
return
t
(
'
redeem.subscriptionAssigned
'
)
default
:
return
t
(
'
common.unknown
'
)
}
}
// Format display value
const
formatValue
=
(
item
:
BalanceHistoryItem
)
=>
{
if
(
isBalanceType
(
item
.
type
))
{
const
sign
=
item
.
value
>=
0
?
'
+
'
:
''
return
`
${
sign
}
$
${
item
.
value
.
toFixed
(
2
)}
`
}
if
(
isSubscriptionType
(
item
.
type
))
{
const
days
=
item
.
validity_days
||
Math
.
round
(
item
.
value
)
const
groupName
=
item
.
group
?.
name
||
''
return
groupName
?
`
${
days
}
d -
${
groupName
}
`
:
`
${
days
}
d`
}
// concurrency types
const
sign
=
item
.
value
>=
0
?
'
+
'
:
''
return
`
${
sign
}${
item
.
value
}
`
}
</
script
>
frontend/src/components/auth/TotpLoginModal.vue
0 → 100644
View file @
377bffe2
<
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
>
Prev
1
…
6
7
8
9
10
11
12
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