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
31fe0178
Commit
31fe0178
authored
Feb 03, 2026
by
yangjianbo
Browse files
Merge branch 'main' of
https://github.com/mt21625457/aicodex2api
parents
d9e345f2
ba5a0d47
Changes
235
Show whitespace changes
Inline
Side-by-side
frontend/src/api/admin/announcements.ts
0 → 100644
View file @
31fe0178
/**
* 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 @
31fe0178
...
...
@@ -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 @
31fe0178
...
...
@@ -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 @
31fe0178
...
...
@@ -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 @
31fe0178
...
...
@@ -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 @
31fe0178
...
...
@@ -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 @
31fe0178
/**
* 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 @
31fe0178
...
...
@@ -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 @
31fe0178
...
...
@@ -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 @
31fe0178
...
...
@@ -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 @
31fe0178
...
...
@@ -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 @
31fe0178
/**
* 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 @
31fe0178
...
...
@@ -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 @
31fe0178
...
...
@@ -1799,6 +1799,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
"
>
...
...
@@ -1818,6 +1830,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
'
...
...
@@ -1944,6 +1957,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
)
...
...
@@ -2360,6 +2378,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
)
{
...
...
@@ -2416,21 +2487,11 @@ const handleSubmit = async () => {
form
.
credentials
=
credentials
submitting
.
value
=
true
try
{
await
adminAPI
.
accounts
.
create
({
await
doCreateAccount
({
...
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
}
}
const
goBackToBasicInfo
=
()
=>
{
...
...
frontend/src/components/account/EditAccountModal.vue
View file @
31fe0178
...
...
@@ -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
try
{
const
updatePayload
:
Record
<
string
,
unknown
>
=
{
...
form
}
try
{
// 后端期望 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
)
{
// 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 @
31fe0178
<
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 @
31fe0178
<
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 @
31fe0178
...
...
@@ -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 @
31fe0178
<
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 @
31fe0178
<
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