Merge branch 'main' into test
This commit is contained in:
71
frontend/src/api/admin/announcements.ts
Normal file
71
frontend/src/api/admin/announcements.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
26
frontend/src/api/announcements.ts
Normal file
26
frontend/src/api/announcements.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -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', credentials)
|
||||
export async function login2FA(request: TotpLogin2FARequest): Promise<AuthResponse> {
|
||||
const { data } = await apiClient.post<AuthResponse>('/auth/login/2fa', request)
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface RedisConfig {
|
||||
port: number
|
||||
password: string
|
||||
db: number
|
||||
enable_tls: boolean
|
||||
}
|
||||
|
||||
export interface AdminConfig {
|
||||
|
||||
83
frontend/src/api/totp.ts
Normal file
83
frontend/src/api/totp.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 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
|
||||
@@ -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
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<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>
|
||||
@@ -0,0 +1,408 @@
|
||||
<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>
|
||||
@@ -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 },
|
||||
|
||||
320
frontend/src/components/admin/user/UserBalanceHistoryModal.vue
Normal file
320
frontend/src/components/admin/user/UserBalanceHistoryModal.vue
Normal file
@@ -0,0 +1,320 @@
|
||||
<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>
|
||||
176
frontend/src/components/auth/TotpLoginModal.vue
Normal file
176
frontend/src/components/auth/TotpLoginModal.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<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>
|
||||
626
frontend/src/components/common/AnnouncementBell.vue
Normal file
626
frontend/src/components/common/AnnouncementBell.vue
Normal file
@@ -0,0 +1,626 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 铃铛按钮 -->
|
||||
<button
|
||||
@click="openModal"
|
||||
class="relative flex h-9 w-9 items-center justify-center rounded-lg text-gray-600 transition-all hover:bg-gray-100 hover:scale-105 dark:text-gray-400 dark:hover:bg-dark-800"
|
||||
:class="{ 'text-blue-600 dark:text-blue-400': unreadCount > 0 }"
|
||||
:aria-label="t('announcements.title')"
|
||||
>
|
||||
<Icon name="bell" size="md" />
|
||||
<!-- 未读红点 -->
|
||||
<span
|
||||
v-if="unreadCount > 0"
|
||||
class="absolute right-1 top-1 flex h-2 w-2"
|
||||
>
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-75"></span>
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full bg-red-500"></span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- 公告列表 Modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal-fade">
|
||||
<div
|
||||
v-if="isModalOpen"
|
||||
class="fixed inset-0 z-[100] flex items-start justify-center overflow-y-auto bg-gradient-to-br from-black/70 via-black/60 to-black/70 p-4 pt-[8vh] backdrop-blur-md"
|
||||
@click="closeModal"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-[620px] overflow-hidden rounded-3xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-dark-800 dark:ring-white/10"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Header with Gradient -->
|
||||
<div class="relative overflow-hidden border-b border-gray-100/80 bg-gradient-to-br from-blue-50/50 to-indigo-50/30 px-6 py-5 dark:border-dark-700/50 dark:from-blue-900/10 dark:to-indigo-900/5">
|
||||
<div class="relative z-10 flex items-start justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 text-white shadow-lg shadow-blue-500/30">
|
||||
<Icon name="bell" size="sm" />
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('announcements.title') }}
|
||||
</h2>
|
||||
</div>
|
||||
<p v-if="unreadCount > 0" class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span class="font-medium text-blue-600 dark:text-blue-400">{{ unreadCount }}</span>
|
||||
{{ t('announcements.unread') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="unreadCount > 0"
|
||||
@click="markAllAsRead"
|
||||
:disabled="loading"
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 text-xs font-medium text-white shadow-lg shadow-blue-500/30 transition-all hover:bg-blue-700 hover:shadow-xl disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-600"
|
||||
>
|
||||
{{ t('announcements.markAllRead') }}
|
||||
</button>
|
||||
<button
|
||||
@click="closeModal"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg bg-white/50 text-gray-500 backdrop-blur-sm transition-all hover:bg-white hover:text-gray-700 dark:bg-dark-700/50 dark:text-gray-400 dark:hover:bg-dark-700 dark:hover:text-gray-300"
|
||||
:aria-label="t('common.close')"
|
||||
>
|
||||
<Icon name="x" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Decorative gradient -->
|
||||
<div class="absolute right-0 top-0 h-full w-48 bg-gradient-to-l from-indigo-100/20 to-transparent dark:from-indigo-900/10"></div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="max-h-[65vh] overflow-y-auto">
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-16">
|
||||
<div class="relative">
|
||||
<div class="h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-blue-600 dark:border-dark-600 dark:border-t-blue-400"></div>
|
||||
<div class="absolute inset-0 h-12 w-12 animate-pulse rounded-full border-4 border-blue-400/30"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Announcements List -->
|
||||
<div v-else-if="announcements.length > 0">
|
||||
<div
|
||||
v-for="item in announcements"
|
||||
:key="item.id"
|
||||
class="group relative flex items-center gap-4 border-b border-gray-100 px-6 py-4 transition-all hover:bg-gray-50 dark:border-dark-700 dark:hover:bg-dark-700/30"
|
||||
:class="{ 'bg-blue-50/30 dark:bg-blue-900/5': !item.read_at }"
|
||||
style="min-height: 72px"
|
||||
@click="openDetail(item)"
|
||||
>
|
||||
<!-- Status Indicator -->
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center">
|
||||
<div
|
||||
v-if="!item.read_at"
|
||||
class="relative flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-indigo-600 text-white shadow-lg shadow-blue-500/30"
|
||||
>
|
||||
<!-- Pulse ring -->
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-xl bg-blue-400 opacity-75"></span>
|
||||
<!-- Icon -->
|
||||
<svg class="relative z-10 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gray-100 text-gray-400 dark:bg-dark-700 dark:text-gray-600"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex min-w-0 flex-1 items-center justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="truncate text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<time class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatRelativeTime(item.created_at) }}
|
||||
</time>
|
||||
<span
|
||||
v-if="!item.read_at"
|
||||
class="inline-flex items-center gap-1 rounded-md bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"
|
||||
>
|
||||
<span class="relative flex h-1.5 w-1.5">
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-500 opacity-75"></span>
|
||||
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-blue-600"></span>
|
||||
</span>
|
||||
{{ t('announcements.unread') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400 transition-transform group-hover:translate-x-1 dark:text-gray-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unread indicator bar -->
|
||||
<div
|
||||
v-if="!item.read_at"
|
||||
class="absolute left-0 top-0 h-full w-1 bg-gradient-to-b from-blue-500 to-indigo-600"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="flex flex-col items-center justify-center py-16">
|
||||
<div class="relative mb-4">
|
||||
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-gray-100 to-gray-200 dark:from-dark-700 dark:to-dark-600">
|
||||
<Icon name="inbox" size="xl" class="text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<div class="absolute -right-1 -top-1 flex h-6 w-6 items-center justify-center rounded-full bg-green-500 text-white">
|
||||
<svg class="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('announcements.empty') }}</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('announcements.emptyDescription') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- 公告详情 Modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal-fade">
|
||||
<div
|
||||
v-if="detailModalOpen && selectedAnnouncement"
|
||||
class="fixed inset-0 z-[110] flex items-start justify-center overflow-y-auto bg-gradient-to-br from-black/70 via-black/60 to-black/70 p-4 pt-[6vh] backdrop-blur-md"
|
||||
@click="closeDetail"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-[780px] overflow-hidden rounded-3xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-dark-800 dark:ring-white/10"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Header with Decorative Elements -->
|
||||
<div class="relative overflow-hidden border-b border-gray-100 bg-gradient-to-br from-blue-50/80 via-indigo-50/50 to-purple-50/30 px-8 py-6 dark:border-dark-700 dark:from-blue-900/20 dark:via-indigo-900/10 dark:to-purple-900/5">
|
||||
<!-- Decorative background elements -->
|
||||
<div class="absolute right-0 top-0 h-full w-64 bg-gradient-to-l from-indigo-100/30 to-transparent dark:from-indigo-900/20"></div>
|
||||
<div class="absolute -right-8 -top-8 h-32 w-32 rounded-full bg-gradient-to-br from-blue-400/20 to-indigo-500/20 blur-3xl"></div>
|
||||
<div class="absolute -left-4 -bottom-4 h-24 w-24 rounded-full bg-gradient-to-tr from-purple-400/20 to-pink-500/20 blur-2xl"></div>
|
||||
|
||||
<div class="relative z-10 flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Icon and Category -->
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-indigo-600 text-white shadow-lg shadow-blue-500/30">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded-lg bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
|
||||
{{ t('announcements.title') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!selectedAnnouncement.read_at"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-600 px-2.5 py-1 text-xs font-medium text-white shadow-lg shadow-blue-500/30"
|
||||
>
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-white opacity-75"></span>
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full bg-white"></span>
|
||||
</span>
|
||||
{{ t('announcements.unread') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h2 class="mb-3 text-2xl font-bold leading-tight text-gray-900 dark:text-white">
|
||||
{{ selectedAnnouncement.title }}
|
||||
</h2>
|
||||
|
||||
<!-- Meta Info -->
|
||||
<div class="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<time>{{ formatRelativeWithDateTime(selectedAnnouncement.created_at) }}</time>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
<span>{{ selectedAnnouncement.read_at ? t('announcements.read') : t('announcements.unread') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Close button -->
|
||||
<button
|
||||
@click="closeDetail"
|
||||
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-white/50 text-gray-500 backdrop-blur-sm transition-all hover:bg-white hover:text-gray-700 hover:shadow-lg dark:bg-dark-700/50 dark:text-gray-400 dark:hover:bg-dark-700 dark:hover:text-gray-300"
|
||||
:aria-label="t('common.close')"
|
||||
>
|
||||
<Icon name="x" size="md" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body with Enhanced Markdown -->
|
||||
<div class="max-h-[60vh] overflow-y-auto bg-white px-8 py-8 dark:bg-dark-800">
|
||||
<!-- Content with decorative border -->
|
||||
<div class="relative">
|
||||
<!-- Decorative left border -->
|
||||
<div class="absolute left-0 top-0 bottom-0 w-1 rounded-full bg-gradient-to-b from-blue-500 via-indigo-500 to-purple-500"></div>
|
||||
|
||||
<div class="pl-6">
|
||||
<div
|
||||
class="markdown-body prose prose-sm max-w-none dark:prose-invert"
|
||||
v-html="renderMarkdown(selectedAnnouncement.content)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with Actions -->
|
||||
<div class="border-t border-gray-100 bg-gray-50/50 px-8 py-5 dark:border-dark-700 dark:bg-dark-900/30">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ selectedAnnouncement.read_at ? t('announcements.readStatus') : t('announcements.markReadHint') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="closeDetail"
|
||||
class="rounded-xl border border-gray-300 bg-white px-5 py-2.5 text-sm font-medium text-gray-700 shadow-sm transition-all hover:bg-gray-50 hover:shadow dark:border-dark-600 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
|
||||
>
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!selectedAnnouncement.read_at"
|
||||
@click="markAsReadAndClose(selectedAnnouncement.id)"
|
||||
class="rounded-xl bg-gradient-to-r from-blue-600 to-indigo-600 px-5 py-2.5 text-sm font-medium text-white shadow-lg shadow-blue-500/30 transition-all hover:shadow-xl hover:scale-105"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{{ t('announcements.markRead') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { announcementsAPI } from '@/api'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { formatRelativeTime, formatRelativeWithDateTime } from '@/utils/format'
|
||||
import type { UserAnnouncement } from '@/types'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Configure marked
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
})
|
||||
|
||||
// State
|
||||
const announcements = ref<UserAnnouncement[]>([])
|
||||
const isModalOpen = ref(false)
|
||||
const detailModalOpen = ref(false)
|
||||
const selectedAnnouncement = ref<UserAnnouncement | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// Computed
|
||||
const unreadCount = computed(() =>
|
||||
announcements.value.filter((a) => !a.read_at).length
|
||||
)
|
||||
|
||||
// Methods
|
||||
function renderMarkdown(content: string): string {
|
||||
if (!content) return ''
|
||||
const html = marked.parse(content) as string
|
||||
return DOMPurify.sanitize(html)
|
||||
}
|
||||
|
||||
async function loadAnnouncements() {
|
||||
try {
|
||||
loading.value = true
|
||||
const allAnnouncements = await announcementsAPI.list(false)
|
||||
announcements.value = allAnnouncements.slice(0, 20)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load announcements:', err)
|
||||
appStore.showError(err?.message || t('common.unknownError'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
isModalOpen.value = true
|
||||
if (announcements.value.length === 0) {
|
||||
loadAnnouncements()
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
isModalOpen.value = false
|
||||
}
|
||||
|
||||
function openDetail(announcement: UserAnnouncement) {
|
||||
selectedAnnouncement.value = announcement
|
||||
detailModalOpen.value = true
|
||||
if (!announcement.read_at) {
|
||||
markAsRead(announcement.id)
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
detailModalOpen.value = false
|
||||
selectedAnnouncement.value = null
|
||||
}
|
||||
|
||||
async function markAsRead(id: number) {
|
||||
try {
|
||||
await announcementsAPI.markRead(id)
|
||||
const announcement = announcements.value.find((a) => a.id === id)
|
||||
if (announcement) {
|
||||
announcement.read_at = new Date().toISOString()
|
||||
}
|
||||
if (selectedAnnouncement.value?.id === id) {
|
||||
selectedAnnouncement.value.read_at = new Date().toISOString()
|
||||
}
|
||||
} catch (err: any) {
|
||||
appStore.showError(err?.message || t('common.unknownError'))
|
||||
}
|
||||
}
|
||||
|
||||
async function markAsReadAndClose(id: number) {
|
||||
await markAsRead(id)
|
||||
appStore.showSuccess(t('announcements.markedAsRead'))
|
||||
closeDetail()
|
||||
}
|
||||
|
||||
async function markAllAsRead() {
|
||||
try {
|
||||
loading.value = true
|
||||
const unreadAnnouncements = announcements.value.filter((a) => !a.read_at)
|
||||
await Promise.all(unreadAnnouncements.map((a) => announcementsAPI.markRead(a.id)))
|
||||
announcements.value.forEach((a) => {
|
||||
if (!a.read_at) {
|
||||
a.read_at = new Date().toISOString()
|
||||
}
|
||||
})
|
||||
appStore.showSuccess(t('announcements.allMarkedAsRead'))
|
||||
} catch (err: any) {
|
||||
appStore.showError(err?.message || t('common.unknownError'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (detailModalOpen.value) {
|
||||
closeDetail()
|
||||
} else if (isModalOpen.value) {
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
loadAnnouncements()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
// Restore body overflow in case component is unmounted while modals are open
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
|
||||
watch([isModalOpen, detailModalOpen], ([modal, detail]) => {
|
||||
if (modal || detail) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Modal Animations */
|
||||
.modal-fade-enter-active {
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.modal-fade-leave-active {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 1, 1);
|
||||
}
|
||||
|
||||
.modal-fade-enter-from,
|
||||
.modal-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-fade-enter-from > div {
|
||||
transform: scale(0.94) translateY(-12px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-fade-leave-to > div {
|
||||
transform: scale(0.96) translateY(-8px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(to bottom, #cbd5e1, #94a3b8);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dark .overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(to bottom, #4b5563, #374151);
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(to bottom, #94a3b8, #64748b);
|
||||
}
|
||||
|
||||
.dark .overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(to bottom, #6b7280, #4b5563);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Enhanced Markdown Styles */
|
||||
.markdown-body {
|
||||
@apply text-[15px] leading-[1.75];
|
||||
@apply text-gray-700 dark:text-gray-300;
|
||||
}
|
||||
|
||||
.markdown-body h1 {
|
||||
@apply mb-6 mt-8 border-b border-gray-200 pb-3 text-3xl font-bold text-gray-900 dark:border-dark-600 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body h2 {
|
||||
@apply mb-4 mt-7 border-b border-gray-100 pb-2 text-2xl font-bold text-gray-900 dark:border-dark-700 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body h3 {
|
||||
@apply mb-3 mt-6 text-xl font-semibold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body h4 {
|
||||
@apply mb-2 mt-5 text-lg font-semibold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body p {
|
||||
@apply mb-4 leading-relaxed;
|
||||
}
|
||||
|
||||
.markdown-body a {
|
||||
@apply font-medium text-blue-600 underline decoration-blue-600/30 decoration-2 underline-offset-2 transition-all hover:decoration-blue-600 dark:text-blue-400 dark:decoration-blue-400/30 dark:hover:decoration-blue-400;
|
||||
}
|
||||
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
@apply mb-4 ml-6 space-y-2;
|
||||
}
|
||||
|
||||
.markdown-body ul {
|
||||
@apply list-disc;
|
||||
}
|
||||
|
||||
.markdown-body ol {
|
||||
@apply list-decimal;
|
||||
}
|
||||
|
||||
.markdown-body li {
|
||||
@apply leading-relaxed;
|
||||
@apply pl-2;
|
||||
}
|
||||
|
||||
.markdown-body li::marker {
|
||||
@apply text-blue-600 dark:text-blue-400;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
@apply relative my-5 border-l-4 border-blue-500 bg-blue-50/50 py-3 pl-5 pr-4 italic text-gray-700 dark:border-blue-400 dark:bg-blue-900/10 dark:text-gray-300;
|
||||
}
|
||||
|
||||
.markdown-body blockquote::before {
|
||||
content: '"';
|
||||
@apply absolute -left-1 top-0 text-5xl font-serif text-blue-500/20 dark:text-blue-400/20;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
@apply rounded-lg bg-gray-100 px-2 py-1 text-[13px] font-mono text-pink-600 dark:bg-dark-700 dark:text-pink-400;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
@apply my-5 overflow-x-auto rounded-xl border border-gray-200 bg-gray-50 p-5 dark:border-dark-600 dark:bg-dark-900/50;
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
@apply bg-transparent p-0 text-[13px] text-gray-800 dark:text-gray-200;
|
||||
}
|
||||
|
||||
.markdown-body hr {
|
||||
@apply my-8 border-0 border-t-2 border-gray-200 dark:border-dark-700;
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
@apply mb-5 w-full overflow-hidden rounded-lg border border-gray-200 dark:border-dark-600;
|
||||
}
|
||||
|
||||
.markdown-body th,
|
||||
.markdown-body td {
|
||||
@apply border-r border-b border-gray-200 px-4 py-3 text-left dark:border-dark-600;
|
||||
}
|
||||
|
||||
.markdown-body th:last-child,
|
||||
.markdown-body td:last-child {
|
||||
@apply border-r-0;
|
||||
}
|
||||
|
||||
.markdown-body tr:last-child td {
|
||||
@apply border-b-0;
|
||||
}
|
||||
|
||||
.markdown-body th {
|
||||
@apply bg-gradient-to-br from-blue-50 to-indigo-50 font-semibold text-gray-900 dark:from-blue-900/20 dark:to-indigo-900/10 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body tbody tr {
|
||||
@apply transition-colors hover:bg-gray-50 dark:hover:bg-dark-700/30;
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
@apply my-5 max-w-full rounded-xl border border-gray-200 shadow-md dark:border-dark-600;
|
||||
}
|
||||
|
||||
.markdown-body strong {
|
||||
@apply font-semibold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body em {
|
||||
@apply italic text-gray-600 dark:text-gray-400;
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@
|
||||
<div
|
||||
v-if="show"
|
||||
class="modal-overlay"
|
||||
:style="zIndexStyle"
|
||||
:aria-labelledby="dialogId"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -60,6 +61,7 @@ interface Props {
|
||||
width?: DialogWidth
|
||||
closeOnEscape?: boolean
|
||||
closeOnClickOutside?: boolean
|
||||
zIndex?: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -69,11 +71,17 @@ interface Emits {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
width: 'normal',
|
||||
closeOnEscape: true,
|
||||
closeOnClickOutside: false
|
||||
closeOnClickOutside: false,
|
||||
zIndex: 50
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// Custom z-index style (overrides the default z-50 from CSS)
|
||||
const zIndexStyle = computed(() => {
|
||||
return props.zIndex !== 50 ? { zIndex: props.zIndex } : undefined
|
||||
})
|
||||
|
||||
const widthClasses = computed(() => {
|
||||
// Width guidance: narrow=confirm/short prompts, normal=standard forms,
|
||||
// wide=multi-section forms or rich content, extra-wide=analytics/tables,
|
||||
|
||||
@@ -181,6 +181,10 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
sort: [key: string, order: 'asc' | 'desc']
|
||||
}>()
|
||||
|
||||
// 表格容器引用
|
||||
const tableWrapperRef = ref<HTMLElement | null>(null)
|
||||
const isScrollable = ref(false)
|
||||
@@ -289,6 +293,11 @@ interface Props {
|
||||
* If provided, DataTable will load the stored sort state on mount.
|
||||
*/
|
||||
sortStorageKey?: string
|
||||
/**
|
||||
* Enable server-side sorting mode. When true, clicking sort headers
|
||||
* will emit 'sort' events instead of performing client-side sorting.
|
||||
*/
|
||||
serverSideSort?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -296,7 +305,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
stickyFirstColumn: true,
|
||||
stickyActionsColumn: true,
|
||||
expandableActions: true,
|
||||
defaultSortOrder: 'asc'
|
||||
defaultSortOrder: 'asc',
|
||||
serverSideSort: false
|
||||
})
|
||||
|
||||
const sortKey = ref<string>('')
|
||||
@@ -448,16 +458,26 @@ watch(actionsExpanded, async () => {
|
||||
})
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
let newOrder: 'asc' | 'desc' = 'asc'
|
||||
if (sortKey.value === key) {
|
||||
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
newOrder = sortOrder.value === 'asc' ? 'desc' : 'asc'
|
||||
}
|
||||
|
||||
if (props.serverSideSort) {
|
||||
// Server-side sort mode: emit event and update internal state for UI feedback
|
||||
sortKey.value = key
|
||||
sortOrder.value = 'asc'
|
||||
sortOrder.value = newOrder
|
||||
emit('sort', key, newOrder)
|
||||
} else {
|
||||
// Client-side sort mode: just update internal state
|
||||
sortKey.value = key
|
||||
sortOrder.value = newOrder
|
||||
}
|
||||
}
|
||||
|
||||
const sortedData = computed(() => {
|
||||
if (!sortKey.value || !props.data) return props.data
|
||||
// Server-side sort mode: return data as-is (server handles sorting)
|
||||
if (props.serverSideSort || !sortKey.value || !props.data) return props.data
|
||||
|
||||
const key = sortKey.value
|
||||
const order = sortOrder.value
|
||||
|
||||
@@ -107,6 +107,9 @@ const icons = {
|
||||
database: 'M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125',
|
||||
cube: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4',
|
||||
|
||||
// Notification
|
||||
bell: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9',
|
||||
|
||||
// Misc
|
||||
bolt: 'M13 10V3L4 14h7v7l9-11h-7z',
|
||||
sparkles: 'M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z',
|
||||
|
||||
@@ -21,8 +21,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Docs + Language + Subscriptions + Balance + User Dropdown -->
|
||||
<!-- Right: Announcements + Docs + Language + Subscriptions + Balance + User Dropdown -->
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Announcement Bell -->
|
||||
<AnnouncementBell v-if="user" />
|
||||
|
||||
<!-- Docs Link -->
|
||||
<a
|
||||
v-if="docUrl"
|
||||
@@ -210,6 +213,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
|
||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
|
||||
import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMini.vue'
|
||||
import AnnouncementBell from '@/components/common/AnnouncementBell.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -319,6 +319,21 @@ const ServerIcon = {
|
||||
)
|
||||
}
|
||||
|
||||
const BellIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75V9a6 6 0 10-12 0v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const TicketIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
@@ -421,6 +436,16 @@ const userNavItems = computed(() => {
|
||||
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
|
||||
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
|
||||
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
|
||||
...(appStore.cachedPublicSettings?.purchase_subscription_enabled
|
||||
? [
|
||||
{
|
||||
path: '/purchase',
|
||||
label: t('nav.buySubscription'),
|
||||
icon: CreditCardIcon,
|
||||
hideInSimpleMode: true
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }
|
||||
]
|
||||
@@ -433,6 +458,16 @@ const personalNavItems = computed(() => {
|
||||
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
|
||||
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
|
||||
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
|
||||
...(appStore.cachedPublicSettings?.purchase_subscription_enabled
|
||||
? [
|
||||
{
|
||||
path: '/purchase',
|
||||
label: t('nav.buySubscription'),
|
||||
icon: CreditCardIcon,
|
||||
hideInSimpleMode: true
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }
|
||||
]
|
||||
@@ -450,6 +485,7 @@ const adminNavItems = computed(() => {
|
||||
{ path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true },
|
||||
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
|
||||
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
|
||||
{ path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon },
|
||||
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
|
||||
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
|
||||
{ path: '/admin/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true },
|
||||
|
||||
154
frontend/src/components/user/profile/ProfileTotpCard.vue
Normal file
154
frontend/src/components/user/profile/ProfileTotpCard.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ t('profile.totp.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.totp.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-6 py-6">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
|
||||
<!-- Feature disabled globally -->
|
||||
<div v-else-if="status && !status.feature_enabled" class="flex items-center gap-4 py-4">
|
||||
<div class="flex-shrink-0 rounded-full bg-gray-100 p-3 dark:bg-dark-700">
|
||||
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('profile.totp.featureDisabled') }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.totp.featureDisabledHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2FA Enabled -->
|
||||
<div v-else-if="status?.enabled" class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-shrink-0 rounded-full bg-green-100 p-3 dark:bg-green-900/30">
|
||||
<svg class="h-6 w-6 text-green-600 dark:text-green-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>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{{ t('profile.totp.enabled') }}
|
||||
</p>
|
||||
<p v-if="status.enabled_at" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.totp.enabledAt') }}: {{ formatDate(status.enabled_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-danger"
|
||||
@click="showDisableDialog = true"
|
||||
>
|
||||
{{ t('profile.totp.disable') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 2FA Not Enabled -->
|
||||
<div v-else class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-shrink-0 rounded-full bg-gray-100 p-3 dark:bg-dark-700">
|
||||
<svg class="h-6 w-6 text-gray-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>
|
||||
<div>
|
||||
<p class="font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('profile.totp.notEnabled') }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.totp.notEnabledHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="showSetupModal = true"
|
||||
>
|
||||
{{ t('profile.totp.enable') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Setup Modal -->
|
||||
<TotpSetupModal
|
||||
v-if="showSetupModal"
|
||||
@close="showSetupModal = false"
|
||||
@success="handleSetupSuccess"
|
||||
/>
|
||||
|
||||
<!-- Disable Dialog -->
|
||||
<TotpDisableDialog
|
||||
v-if="showDisableDialog"
|
||||
@close="showDisableDialog = false"
|
||||
@success="handleDisableSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { totpAPI } from '@/api'
|
||||
import type { TotpStatus } from '@/types'
|
||||
import TotpSetupModal from './TotpSetupModal.vue'
|
||||
import TotpDisableDialog from './TotpDisableDialog.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const loading = ref(true)
|
||||
const status = ref<TotpStatus | null>(null)
|
||||
const showSetupModal = ref(false)
|
||||
const showDisableDialog = ref(false)
|
||||
|
||||
const loadStatus = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
status.value = await totpAPI.getStatus()
|
||||
} catch (error) {
|
||||
console.error('Failed to load TOTP status:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetupSuccess = () => {
|
||||
showSetupModal.value = false
|
||||
loadStatus()
|
||||
}
|
||||
|
||||
const handleDisableSuccess = () => {
|
||||
showDisableDialog.value = false
|
||||
loadStatus()
|
||||
}
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
// Backend returns Unix timestamp in seconds, convert to milliseconds
|
||||
const date = new Date(timestamp * 1000)
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStatus()
|
||||
})
|
||||
</script>
|
||||
179
frontend/src/components/user/profile/TotpDisableDialog.vue
Normal file
179
frontend/src/components/user/profile/TotpDisableDialog.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto" @click.self="$emit('close')">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div class="fixed inset-0 bg-black/50 transition-opacity" @click="$emit('close')"></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">
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
|
||||
<svg class="h-6 w-6 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mt-4 text-center text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('profile.totp.disableTitle') }}
|
||||
</h3>
|
||||
<p class="mt-2 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.totp.disableWarning') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading verification method -->
|
||||
<div v-if="methodLoading" class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
|
||||
<form v-else @submit.prevent="handleDisable" class="space-y-4">
|
||||
<!-- Email verification -->
|
||||
<div v-if="verificationMethod === 'email'">
|
||||
<label class="input-label">{{ t('profile.totp.emailCode') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="form.emailCode"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
inputmode="numeric"
|
||||
class="input flex-1"
|
||||
:placeholder="t('profile.totp.enterEmailCode')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary whitespace-nowrap"
|
||||
:disabled="sendingCode || codeCooldown > 0"
|
||||
@click="handleSendCode"
|
||||
>
|
||||
{{ codeCooldown > 0 ? `${codeCooldown}s` : (sendingCode ? t('common.sending') : t('profile.totp.sendCode')) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password verification -->
|
||||
<div v-else>
|
||||
<label for="password" class="input-label">
|
||||
{{ t('profile.currentPassword') }}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="input"
|
||||
:placeholder="t('profile.totp.enterPassword')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="error" class="rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('close')">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-danger"
|
||||
:disabled="loading || !canSubmit"
|
||||
>
|
||||
{{ loading ? t('common.processing') : t('profile.totp.confirmDisable') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { totpAPI } from '@/api'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const methodLoading = ref(true)
|
||||
const verificationMethod = ref<'email' | 'password'>('password')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const sendingCode = ref(false)
|
||||
const codeCooldown = ref(0)
|
||||
const form = ref({
|
||||
emailCode: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (verificationMethod.value === 'email') {
|
||||
return form.value.emailCode.length === 6
|
||||
}
|
||||
return form.value.password.length > 0
|
||||
})
|
||||
|
||||
const loadVerificationMethod = async () => {
|
||||
methodLoading.value = true
|
||||
try {
|
||||
const method = await totpAPI.getVerificationMethod()
|
||||
verificationMethod.value = method.method
|
||||
} catch (err: any) {
|
||||
appStore.showError(err.response?.data?.message || t('common.error'))
|
||||
emit('close')
|
||||
} finally {
|
||||
methodLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendCode = async () => {
|
||||
sendingCode.value = true
|
||||
try {
|
||||
await totpAPI.sendVerifyCode()
|
||||
appStore.showSuccess(t('profile.totp.codeSent'))
|
||||
// Start cooldown
|
||||
codeCooldown.value = 60
|
||||
const timer = setInterval(() => {
|
||||
codeCooldown.value--
|
||||
if (codeCooldown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, 1000)
|
||||
} catch (err: any) {
|
||||
appStore.showError(err.response?.data?.message || t('profile.totp.sendCodeFailed'))
|
||||
} finally {
|
||||
sendingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisable = async () => {
|
||||
if (!canSubmit.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const request = verificationMethod.value === 'email'
|
||||
? { email_code: form.value.emailCode }
|
||||
: { password: form.value.password }
|
||||
|
||||
await totpAPI.disable(request)
|
||||
appStore.showSuccess(t('profile.totp.disableSuccess'))
|
||||
emit('success')
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.message || t('profile.totp.disableFailed')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadVerificationMethod()
|
||||
})
|
||||
</script>
|
||||
400
frontend/src/components/user/profile/TotpSetupModal.vue
Normal file
400
frontend/src/components/user/profile/TotpSetupModal.vue
Normal file
@@ -0,0 +1,400 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto" @click.self="$emit('close')">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div class="fixed inset-0 bg-black/50 transition-opacity" @click="$emit('close')"></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">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('profile.totp.setupTitle') }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ stepDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 0: Identity Verification -->
|
||||
<div v-if="step === 0" class="space-y-6">
|
||||
<!-- Loading verification method -->
|
||||
<div v-if="methodLoading" class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Email verification -->
|
||||
<div v-if="verificationMethod === 'email'" class="space-y-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('profile.totp.emailCode') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="verifyForm.emailCode"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
inputmode="numeric"
|
||||
class="input flex-1"
|
||||
:placeholder="t('profile.totp.enterEmailCode')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary whitespace-nowrap"
|
||||
:disabled="sendingCode || codeCooldown > 0"
|
||||
@click="handleSendCode"
|
||||
>
|
||||
{{ codeCooldown > 0 ? `${codeCooldown}s` : (sendingCode ? t('common.sending') : t('profile.totp.sendCode')) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password verification -->
|
||||
<div v-else class="space-y-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('profile.currentPassword') }}</label>
|
||||
<input
|
||||
v-model="verifyForm.password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="input"
|
||||
:placeholder="t('profile.totp.enterPassword')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="verifyError" class="rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||
{{ verifyError }}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('close')">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!canProceedFromVerify || setupLoading"
|
||||
@click="handleVerifyAndSetup"
|
||||
>
|
||||
{{ setupLoading ? t('common.loading') : t('common.next') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Show QR Code -->
|
||||
<div v-if="step === 1" class="space-y-6">
|
||||
<!-- QR Code and Secret -->
|
||||
<template v-if="setupData">
|
||||
<div class="flex justify-center">
|
||||
<div class="rounded-lg border border-gray-200 p-4 bg-white dark:border-dark-600 dark:bg-white">
|
||||
<img :src="qrCodeDataUrl" alt="QR Code" class="h-48 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
{{ t('profile.totp.manualEntry') }}
|
||||
</p>
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<code class="rounded bg-gray-100 px-3 py-2 font-mono text-sm dark:bg-dark-700">
|
||||
{{ setupData.secret }}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1.5 text-gray-500 hover:bg-gray-100 dark:hover:bg-dark-700"
|
||||
@click="copySecret"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('close')">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!setupData"
|
||||
@click="step = 2"
|
||||
>
|
||||
{{ t('common.next') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Verify Code -->
|
||||
<div v-if="step === 2" class="space-y-6">
|
||||
<form @submit.prevent="handleVerify">
|
||||
<div class="mb-6">
|
||||
<label class="input-label text-center block mb-3">
|
||||
{{ t('profile.totp.enterCode') }}
|
||||
</label>
|
||||
<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"
|
||||
@input="handleCodeInput($event, index)"
|
||||
@keydown="handleKeydown($event, index)"
|
||||
@paste="handlePaste"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" class="btn btn-secondary" @click="step = 1">
|
||||
{{ t('common.back') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="verifying || code.join('').length !== 6"
|
||||
>
|
||||
{{ verifying ? t('common.verifying') : t('profile.totp.verify') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { totpAPI } from '@/api'
|
||||
import type { TotpSetupResponse } from '@/types'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Step: 0 = verify identity, 1 = QR code, 2 = verify TOTP code
|
||||
const step = ref(0)
|
||||
const methodLoading = ref(true)
|
||||
const verificationMethod = ref<'email' | 'password'>('password')
|
||||
const verifyForm = ref({ emailCode: '', password: '' })
|
||||
const verifyError = ref('')
|
||||
const sendingCode = ref(false)
|
||||
const codeCooldown = ref(0)
|
||||
|
||||
const setupLoading = ref(false)
|
||||
const setupData = ref<TotpSetupResponse | null>(null)
|
||||
const verifying = ref(false)
|
||||
const error = ref('')
|
||||
const code = ref<string[]>(['', '', '', '', '', ''])
|
||||
const inputRefs = ref<(HTMLInputElement | null)[]>([])
|
||||
const qrCodeDataUrl = ref('')
|
||||
|
||||
const stepDescription = computed(() => {
|
||||
switch (step.value) {
|
||||
case 0:
|
||||
return verificationMethod.value === 'email'
|
||||
? t('profile.totp.verifyEmailFirst')
|
||||
: t('profile.totp.verifyPasswordFirst')
|
||||
case 1:
|
||||
return t('profile.totp.setupStep1')
|
||||
case 2:
|
||||
return t('profile.totp.setupStep2')
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const canProceedFromVerify = computed(() => {
|
||||
if (verificationMethod.value === 'email') {
|
||||
return verifyForm.value.emailCode.length === 6
|
||||
}
|
||||
return verifyForm.value.password.length > 0
|
||||
})
|
||||
|
||||
// Generate QR code as base64 when setupData changes
|
||||
watch(
|
||||
() => setupData.value?.qr_code_url,
|
||||
async (url) => {
|
||||
if (url) {
|
||||
try {
|
||||
qrCodeDataUrl.value = await QRCode.toDataURL(url, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff'
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to generate QR code:', err)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
const copySecret = async () => {
|
||||
if (setupData.value) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(setupData.value.secret)
|
||||
appStore.showSuccess(t('common.copied'))
|
||||
} catch {
|
||||
appStore.showError(t('common.copyFailed'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadVerificationMethod = async () => {
|
||||
methodLoading.value = true
|
||||
try {
|
||||
const method = await totpAPI.getVerificationMethod()
|
||||
verificationMethod.value = method.method
|
||||
} catch (err: any) {
|
||||
appStore.showError(err.response?.data?.message || t('common.error'))
|
||||
emit('close')
|
||||
} finally {
|
||||
methodLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendCode = async () => {
|
||||
sendingCode.value = true
|
||||
try {
|
||||
await totpAPI.sendVerifyCode()
|
||||
appStore.showSuccess(t('profile.totp.codeSent'))
|
||||
// Start cooldown
|
||||
codeCooldown.value = 60
|
||||
const timer = setInterval(() => {
|
||||
codeCooldown.value--
|
||||
if (codeCooldown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, 1000)
|
||||
} catch (err: any) {
|
||||
appStore.showError(err.response?.data?.message || t('profile.totp.sendCodeFailed'))
|
||||
} finally {
|
||||
sendingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerifyAndSetup = async () => {
|
||||
setupLoading.value = true
|
||||
verifyError.value = ''
|
||||
|
||||
try {
|
||||
const request = verificationMethod.value === 'email'
|
||||
? { email_code: verifyForm.value.emailCode }
|
||||
: { password: verifyForm.value.password }
|
||||
|
||||
setupData.value = await totpAPI.initiateSetup(request)
|
||||
step.value = 1
|
||||
} catch (err: any) {
|
||||
verifyError.value = err.response?.data?.message || t('profile.totp.setupFailed')
|
||||
} finally {
|
||||
setupLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerify = async () => {
|
||||
const totpCode = code.value.join('')
|
||||
if (totpCode.length !== 6 || !setupData.value) return
|
||||
|
||||
verifying.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await totpAPI.enable({
|
||||
totp_code: totpCode,
|
||||
setup_token: setupData.value.setup_token
|
||||
})
|
||||
appStore.showSuccess(t('profile.totp.enableSuccess'))
|
||||
emit('success')
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.message || t('profile.totp.verifyFailed')
|
||||
code.value = ['', '', '', '', '', '']
|
||||
nextTick(() => {
|
||||
inputRefs.value[0]?.focus()
|
||||
})
|
||||
} finally {
|
||||
verifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadVerificationMethod()
|
||||
})
|
||||
</script>
|
||||
@@ -69,7 +69,9 @@ export default {
|
||||
port: 'Port',
|
||||
password: 'Password (optional)',
|
||||
database: 'Database',
|
||||
passwordPlaceholder: 'Password'
|
||||
passwordPlaceholder: 'Password',
|
||||
enableTls: 'Enable TLS',
|
||||
enableTlsHint: 'Use TLS when connecting to Redis (public CA certs)'
|
||||
},
|
||||
admin: {
|
||||
title: 'Admin Account',
|
||||
@@ -146,7 +148,10 @@ export default {
|
||||
balance: 'Balance',
|
||||
available: 'Available',
|
||||
copiedToClipboard: 'Copied to clipboard',
|
||||
copied: 'Copied',
|
||||
copyFailed: 'Failed to copy',
|
||||
verifying: 'Verifying...',
|
||||
processing: 'Processing...',
|
||||
contactSupport: 'Contact Support',
|
||||
add: 'Add',
|
||||
invalidEmail: 'Please enter a valid email address',
|
||||
@@ -182,6 +187,7 @@ export default {
|
||||
// Navigation
|
||||
nav: {
|
||||
dashboard: 'Dashboard',
|
||||
announcements: 'Announcements',
|
||||
apiKeys: 'API Keys',
|
||||
usage: 'Usage',
|
||||
redeem: 'Redeem',
|
||||
@@ -203,6 +209,7 @@ export default {
|
||||
logout: 'Logout',
|
||||
github: 'GitHub',
|
||||
mySubscriptions: 'My Subscriptions',
|
||||
buySubscription: 'Purchase Subscription',
|
||||
docs: 'Docs'
|
||||
},
|
||||
|
||||
@@ -258,6 +265,13 @@ export default {
|
||||
promoCodeAlreadyUsed: 'You have already used this promo code',
|
||||
promoCodeValidating: 'Promo code is being validated, please wait',
|
||||
promoCodeInvalidCannotRegister: 'Invalid promo code. Please check and try again or clear the promo code field',
|
||||
invitationCodeLabel: 'Invitation Code',
|
||||
invitationCodePlaceholder: 'Enter invitation code',
|
||||
invitationCodeRequired: 'Invitation code is required',
|
||||
invitationCodeValid: 'Invitation code is valid',
|
||||
invitationCodeInvalid: 'Invalid or used invitation code',
|
||||
invitationCodeValidating: 'Validating invitation code...',
|
||||
invitationCodeInvalidCannotRegister: 'Invalid invitation code. Please check and try again',
|
||||
linuxdo: {
|
||||
signIn: 'Continue with Linux.do',
|
||||
orContinue: 'or continue with email',
|
||||
@@ -271,7 +285,36 @@ export default {
|
||||
code: 'Code',
|
||||
state: 'State',
|
||||
fullUrl: 'Full URL'
|
||||
}
|
||||
},
|
||||
// Forgot password
|
||||
forgotPassword: 'Forgot password?',
|
||||
forgotPasswordTitle: 'Reset Your Password',
|
||||
forgotPasswordHint: 'Enter your email address and we will send you a link to reset your password.',
|
||||
sendResetLink: 'Send Reset Link',
|
||||
sendingResetLink: 'Sending...',
|
||||
sendResetLinkFailed: 'Failed to send reset link. Please try again.',
|
||||
resetEmailSent: 'Reset Link Sent',
|
||||
resetEmailSentHint: 'If an account exists with this email, you will receive a password reset link shortly. Please check your inbox and spam folder.',
|
||||
backToLogin: 'Back to Login',
|
||||
rememberedPassword: 'Remembered your password?',
|
||||
// Reset password
|
||||
resetPasswordTitle: 'Set New Password',
|
||||
resetPasswordHint: 'Enter your new password below.',
|
||||
newPassword: 'New Password',
|
||||
newPasswordPlaceholder: 'Enter your new password',
|
||||
confirmPassword: 'Confirm Password',
|
||||
confirmPasswordPlaceholder: 'Confirm your new password',
|
||||
confirmPasswordRequired: 'Please confirm your password',
|
||||
passwordsDoNotMatch: 'Passwords do not match',
|
||||
resetPassword: 'Reset Password',
|
||||
resettingPassword: 'Resetting...',
|
||||
resetPasswordFailed: 'Failed to reset password. Please try again.',
|
||||
passwordResetSuccess: 'Password Reset Successful',
|
||||
passwordResetSuccessHint: 'Your password has been reset. You can now sign in with your new password.',
|
||||
invalidResetLink: 'Invalid Reset Link',
|
||||
invalidResetLinkHint: 'This password reset link is invalid or has expired. Please request a new one.',
|
||||
requestNewResetLink: 'Request New Reset Link',
|
||||
invalidOrExpiredToken: 'The password reset link is invalid or has expired. Please request a new one.'
|
||||
},
|
||||
|
||||
// Dashboard
|
||||
@@ -459,6 +502,7 @@ export default {
|
||||
exporting: 'Exporting...',
|
||||
preparingExport: 'Preparing export...',
|
||||
model: 'Model',
|
||||
reasoningEffort: 'Reasoning Effort',
|
||||
type: 'Type',
|
||||
tokens: 'Tokens',
|
||||
cost: 'Cost',
|
||||
@@ -554,7 +598,46 @@ export default {
|
||||
passwordsNotMatch: 'New passwords do not match',
|
||||
passwordTooShort: 'Password must be at least 8 characters long',
|
||||
passwordChangeSuccess: 'Password changed successfully',
|
||||
passwordChangeFailed: 'Failed to change password'
|
||||
passwordChangeFailed: 'Failed to change password',
|
||||
// TOTP 2FA
|
||||
totp: {
|
||||
title: 'Two-Factor Authentication (2FA)',
|
||||
description: 'Enhance account security with Google Authenticator or similar apps',
|
||||
enabled: 'Enabled',
|
||||
enabledAt: 'Enabled at',
|
||||
notEnabled: 'Not Enabled',
|
||||
notEnabledHint: 'Enable two-factor authentication to enhance account security',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
featureDisabled: 'Feature Unavailable',
|
||||
featureDisabledHint: 'Two-factor authentication has not been enabled by the administrator',
|
||||
setupTitle: 'Set Up Two-Factor Authentication',
|
||||
setupStep1: 'Scan the QR code below with your authenticator app',
|
||||
setupStep2: 'Enter the 6-digit code from your app',
|
||||
manualEntry: "Can't scan? Enter the key manually:",
|
||||
enterCode: 'Enter 6-digit code',
|
||||
verify: 'Verify',
|
||||
setupFailed: 'Failed to get setup information',
|
||||
verifyFailed: 'Invalid code, please try again',
|
||||
enableSuccess: 'Two-factor authentication enabled',
|
||||
disableTitle: 'Disable Two-Factor Authentication',
|
||||
disableWarning: 'After disabling, you will no longer need a verification code to log in. This may reduce your account security.',
|
||||
enterPassword: 'Enter your current password to confirm',
|
||||
confirmDisable: 'Confirm Disable',
|
||||
disableSuccess: 'Two-factor authentication disabled',
|
||||
disableFailed: 'Failed to disable, please check your password',
|
||||
loginTitle: 'Two-Factor Authentication',
|
||||
loginHint: 'Enter the 6-digit code from your authenticator app',
|
||||
loginFailed: 'Verification failed, please try again',
|
||||
// New translations for email verification
|
||||
verifyEmailFirst: 'Please verify your email first',
|
||||
verifyPasswordFirst: 'Please verify your identity first',
|
||||
emailCode: 'Email Verification Code',
|
||||
enterEmailCode: 'Enter 6-digit code',
|
||||
sendCode: 'Send Code',
|
||||
codeSent: 'Verification code sent to your email',
|
||||
sendCodeFailed: 'Failed to send verification code'
|
||||
}
|
||||
},
|
||||
|
||||
// Empty States
|
||||
@@ -760,6 +843,20 @@ export default {
|
||||
failedToDeposit: 'Failed to deposit',
|
||||
failedToWithdraw: 'Failed to withdraw',
|
||||
useDepositWithdrawButtons: 'Please use deposit/withdraw buttons to adjust balance',
|
||||
// Balance History
|
||||
balanceHistory: 'Recharge History',
|
||||
balanceHistoryTip: 'Click to open recharge history',
|
||||
balanceHistoryTitle: 'User Recharge & Concurrency History',
|
||||
noBalanceHistory: 'No records found for this user',
|
||||
allTypes: 'All Types',
|
||||
typeBalance: 'Balance (Redeem)',
|
||||
typeAdminBalance: 'Balance (Admin)',
|
||||
typeConcurrency: 'Concurrency (Redeem)',
|
||||
typeAdminConcurrency: 'Concurrency (Admin)',
|
||||
typeSubscription: 'Subscription',
|
||||
failedToLoadBalanceHistory: 'Failed to load balance history',
|
||||
createdAt: 'Created',
|
||||
totalRecharged: 'Total Recharged',
|
||||
roles: {
|
||||
admin: 'Admin',
|
||||
user: 'User'
|
||||
@@ -938,6 +1035,14 @@ export default {
|
||||
fallbackHint: 'Non-Claude Code requests will use this group. Leave empty to reject directly.',
|
||||
noFallback: 'No Fallback (Reject)'
|
||||
},
|
||||
copyAccounts: {
|
||||
title: 'Copy Accounts from Groups',
|
||||
tooltip: 'Select one or more groups of the same platform. After creation, all accounts from these groups will be automatically bound to the new group (deduplicated).',
|
||||
tooltipEdit: 'Select one or more groups of the same platform. After saving, current group accounts will be replaced with accounts from these groups (deduplicated).',
|
||||
selectPlaceholder: 'Select groups to copy accounts from...',
|
||||
hint: 'Multiple groups can be selected, accounts will be deduplicated',
|
||||
hintEdit: '⚠️ Warning: This will replace all existing account bindings'
|
||||
},
|
||||
modelRouting: {
|
||||
title: 'Model Routing',
|
||||
tooltip: 'Configure specific model requests to be routed to designated accounts. Supports wildcard matching, e.g., claude-opus-* matches all opus models.',
|
||||
@@ -1110,6 +1215,7 @@ export default {
|
||||
overloaded: 'Overloaded',
|
||||
tempUnschedulable: 'Temp Unschedulable',
|
||||
rateLimitedUntil: 'Rate limited until {time}',
|
||||
scopeRateLimitedUntil: '{scope} rate limited until {time}',
|
||||
overloadedUntil: 'Overloaded until {time}',
|
||||
viewTempUnschedDetails: 'View temp unschedulable details'
|
||||
},
|
||||
@@ -1357,6 +1463,8 @@ export default {
|
||||
accountUpdated: 'Account updated successfully',
|
||||
failedToCreate: 'Failed to create account',
|
||||
failedToUpdate: 'Failed to update account',
|
||||
mixedChannelWarningTitle: 'Mixed Channel Warning',
|
||||
mixedChannelWarning: 'Warning: Group "{groupName}" contains both {currentPlatform} and {otherPlatform} accounts. Mixing different channels may cause thinking block signature validation issues, which will fallback to non-thinking mode. Are you sure you want to continue?',
|
||||
pleaseEnterAccountName: 'Please enter account name',
|
||||
pleaseEnterApiKey: 'Please enter API Key',
|
||||
apiKeyIsRequired: 'API Key is required',
|
||||
@@ -1833,6 +1941,8 @@ export default {
|
||||
balance: 'Balance',
|
||||
concurrency: 'Concurrency',
|
||||
subscription: 'Subscription',
|
||||
invitation: 'Invitation',
|
||||
invitationHint: 'Invitation codes are used to restrict user registration. They are automatically marked as used after use.',
|
||||
unused: 'Unused',
|
||||
used: 'Used',
|
||||
columns: {
|
||||
@@ -1879,6 +1989,7 @@ export default {
|
||||
balance: 'Balance',
|
||||
concurrency: 'Concurrency',
|
||||
subscription: 'Subscription',
|
||||
invitation: 'Invitation',
|
||||
// Admin adjustment types (created when admin modifies user balance/concurrency)
|
||||
admin_balance: 'Balance (Admin)',
|
||||
admin_concurrency: 'Concurrency (Admin)'
|
||||
@@ -1896,6 +2007,73 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// Announcements
|
||||
announcements: {
|
||||
title: 'Announcements',
|
||||
description: 'Create announcements and target by conditions',
|
||||
createAnnouncement: 'Create Announcement',
|
||||
editAnnouncement: 'Edit Announcement',
|
||||
deleteAnnouncement: 'Delete Announcement',
|
||||
searchAnnouncements: 'Search announcements...',
|
||||
status: 'Status',
|
||||
allStatus: 'All Status',
|
||||
columns: {
|
||||
title: 'Title',
|
||||
status: 'Status',
|
||||
targeting: 'Targeting',
|
||||
timeRange: 'Schedule',
|
||||
createdAt: 'Created At',
|
||||
actions: 'Actions'
|
||||
},
|
||||
statusLabels: {
|
||||
draft: 'Draft',
|
||||
active: 'Active',
|
||||
archived: 'Archived'
|
||||
},
|
||||
form: {
|
||||
title: 'Title',
|
||||
content: 'Content (Markdown supported)',
|
||||
status: 'Status',
|
||||
startsAt: 'Starts At',
|
||||
endsAt: 'Ends At',
|
||||
startsAtHint: 'Leave empty to start immediately',
|
||||
endsAtHint: 'Leave empty to never expire',
|
||||
targetingMode: 'Targeting',
|
||||
targetingAll: 'All users',
|
||||
targetingCustom: 'Custom rules',
|
||||
addOrGroup: 'Add OR group',
|
||||
addAndCondition: 'Add AND condition',
|
||||
conditionType: 'Condition type',
|
||||
conditionSubscription: 'Subscription',
|
||||
conditionBalance: 'Balance',
|
||||
operator: 'Operator',
|
||||
balanceValue: 'Balance threshold',
|
||||
selectPackages: 'Select packages'
|
||||
},
|
||||
operators: {
|
||||
gt: '>',
|
||||
gte: '≥',
|
||||
lt: '<',
|
||||
lte: '≤',
|
||||
eq: '='
|
||||
},
|
||||
targetingSummaryAll: 'All users',
|
||||
targetingSummaryCustom: 'Custom ({groups} groups)',
|
||||
timeImmediate: 'Immediate',
|
||||
timeNever: 'Never',
|
||||
readStatus: 'Read Status',
|
||||
eligible: 'Eligible',
|
||||
readAt: 'Read at',
|
||||
unread: 'Unread',
|
||||
searchUsers: 'Search users...',
|
||||
failedToLoad: 'Failed to load announcements',
|
||||
failedToCreate: 'Failed to create announcement',
|
||||
failedToUpdate: 'Failed to update announcement',
|
||||
failedToDelete: 'Failed to delete announcement',
|
||||
failedToLoadReadStatus: 'Failed to load read status',
|
||||
deleteConfirm: 'Are you sure you want to delete this announcement? This action cannot be undone.'
|
||||
},
|
||||
|
||||
// Promo Codes
|
||||
promo: {
|
||||
title: 'Promo Code Management',
|
||||
@@ -2667,6 +2845,8 @@ export default {
|
||||
ignoreContextCanceledHint: 'When enabled, client disconnect (context canceled) errors will not be written to the error log.',
|
||||
ignoreNoAvailableAccounts: 'Ignore no available accounts errors',
|
||||
ignoreNoAvailableAccountsHint: 'When enabled, "No available accounts" errors will not be written to the error log (not recommended; usually a config issue).',
|
||||
ignoreInvalidApiKeyErrors: 'Ignore invalid API key errors',
|
||||
ignoreInvalidApiKeyErrorsHint: 'When enabled, invalid or missing API key errors (INVALID_API_KEY, API_KEY_REQUIRED) will not be written to the error log.',
|
||||
autoRefresh: 'Auto Refresh',
|
||||
enableAutoRefresh: 'Enable auto refresh',
|
||||
enableAutoRefreshHint: 'Automatically refresh dashboard data at a fixed interval.',
|
||||
@@ -2694,6 +2874,7 @@ export default {
|
||||
empty: 'No data',
|
||||
queued: 'Queue {count}',
|
||||
rateLimited: 'Rate-limited {count}',
|
||||
scopeRateLimitedTooltip: '{scope} rate-limited ({count} accounts)',
|
||||
errorAccounts: 'Errors {count}',
|
||||
loadFailed: 'Failed to load concurrency data'
|
||||
},
|
||||
@@ -2760,7 +2941,15 @@ export default {
|
||||
emailVerification: 'Email Verification',
|
||||
emailVerificationHint: 'Require email verification for new registrations',
|
||||
promoCode: 'Promo Code',
|
||||
promoCodeHint: 'Allow users to use promo codes during registration'
|
||||
promoCodeHint: 'Allow users to use promo codes during registration',
|
||||
invitationCode: 'Invitation Code Registration',
|
||||
invitationCodeHint: 'When enabled, users must enter a valid invitation code to register',
|
||||
passwordReset: 'Password Reset',
|
||||
passwordResetHint: 'Allow users to reset their password via email',
|
||||
totp: 'Two-Factor Authentication (2FA)',
|
||||
totpHint: 'Allow users to use authenticator apps like Google Authenticator',
|
||||
totpKeyNotConfigured:
|
||||
'Please configure TOTP_ENCRYPTION_KEY in environment variables first. Generate a key with: openssl rand -hex 32'
|
||||
},
|
||||
turnstile: {
|
||||
title: 'Cloudflare Turnstile',
|
||||
@@ -2834,6 +3023,17 @@ export default {
|
||||
hideCcsImportButton: 'Hide CCS Import Button',
|
||||
hideCcsImportButtonHint: 'When enabled, the "Import to CCS" button will be hidden on the API Keys page'
|
||||
},
|
||||
purchase: {
|
||||
title: 'Purchase Page',
|
||||
description: 'Show a "Purchase Subscription" entry in the sidebar and open the configured URL in an iframe',
|
||||
enabled: 'Show Purchase Entry',
|
||||
enabledHint: 'Only shown in standard mode (not simple mode)',
|
||||
url: 'Purchase URL',
|
||||
urlPlaceholder: 'https://example.com/purchase',
|
||||
urlHint: 'Must be an absolute http(s) URL',
|
||||
iframeWarning:
|
||||
'⚠️ iframe note: Some websites block embedding via X-Frame-Options or CSP (frame-ancestors). If the page is blank, provide an "Open in new tab" alternative.'
|
||||
},
|
||||
smtp: {
|
||||
title: 'SMTP Settings',
|
||||
description: 'Configure email sending for verification codes',
|
||||
@@ -2979,6 +3179,42 @@ export default {
|
||||
retry: 'Retry'
|
||||
},
|
||||
|
||||
// Purchase Subscription Page
|
||||
purchase: {
|
||||
title: 'Purchase Subscription',
|
||||
description: 'Purchase a subscription via the embedded page',
|
||||
openInNewTab: 'Open in new tab',
|
||||
notEnabledTitle: 'Feature not enabled',
|
||||
notEnabledDesc: 'The administrator has not enabled the purchase page. Please contact admin.',
|
||||
notConfiguredTitle: 'Purchase URL not configured',
|
||||
notConfiguredDesc:
|
||||
'The administrator enabled the entry but has not configured a purchase URL. Please contact admin.'
|
||||
},
|
||||
|
||||
// Announcements Page
|
||||
announcements: {
|
||||
title: 'Announcements',
|
||||
description: 'View system announcements',
|
||||
unreadOnly: 'Show unread only',
|
||||
markRead: 'Mark as read',
|
||||
markAllRead: 'Mark all as read',
|
||||
viewAll: 'View all announcements',
|
||||
markedAsRead: 'Marked as read',
|
||||
allMarkedAsRead: 'All announcements marked as read',
|
||||
newCount: '{count} new announcement | {count} new announcements',
|
||||
readAt: 'Read at',
|
||||
read: 'Read',
|
||||
unread: 'Unread',
|
||||
startsAt: 'Starts at',
|
||||
endsAt: 'Ends at',
|
||||
empty: 'No announcements',
|
||||
emptyUnread: 'No unread announcements',
|
||||
total: 'announcements',
|
||||
emptyDescription: 'There are no system announcements at this time',
|
||||
readStatus: 'You have read this announcement',
|
||||
markReadHint: 'Click "Mark as read" to mark this announcement'
|
||||
},
|
||||
|
||||
// User Subscriptions Page
|
||||
userSubscriptions: {
|
||||
title: 'My Subscriptions',
|
||||
|
||||
@@ -66,7 +66,9 @@ export default {
|
||||
port: '端口',
|
||||
password: '密码(可选)',
|
||||
database: '数据库',
|
||||
passwordPlaceholder: '密码'
|
||||
passwordPlaceholder: '密码',
|
||||
enableTls: '启用 TLS',
|
||||
enableTlsHint: '连接 Redis 时使用 TLS(公共 CA 证书)'
|
||||
},
|
||||
admin: {
|
||||
title: '管理员账户',
|
||||
@@ -143,7 +145,10 @@ export default {
|
||||
balance: '余额',
|
||||
available: '可用',
|
||||
copiedToClipboard: '已复制到剪贴板',
|
||||
copied: '已复制',
|
||||
copyFailed: '复制失败',
|
||||
verifying: '验证中...',
|
||||
processing: '处理中...',
|
||||
contactSupport: '联系客服',
|
||||
add: '添加',
|
||||
invalidEmail: '请输入有效的邮箱地址',
|
||||
@@ -179,6 +184,7 @@ export default {
|
||||
// Navigation
|
||||
nav: {
|
||||
dashboard: '仪表盘',
|
||||
announcements: '公告',
|
||||
apiKeys: 'API 密钥',
|
||||
usage: '使用记录',
|
||||
redeem: '兑换',
|
||||
@@ -200,6 +206,7 @@ export default {
|
||||
logout: '退出登录',
|
||||
github: 'GitHub',
|
||||
mySubscriptions: '我的订阅',
|
||||
buySubscription: '购买订阅',
|
||||
docs: '文档'
|
||||
},
|
||||
|
||||
@@ -255,6 +262,13 @@ export default {
|
||||
promoCodeAlreadyUsed: '您已使用过此优惠码',
|
||||
promoCodeValidating: '优惠码正在验证中,请稍候',
|
||||
promoCodeInvalidCannotRegister: '优惠码无效,请检查后重试或清空优惠码',
|
||||
invitationCodeLabel: '邀请码',
|
||||
invitationCodePlaceholder: '请输入邀请码',
|
||||
invitationCodeRequired: '请输入邀请码',
|
||||
invitationCodeValid: '邀请码有效',
|
||||
invitationCodeInvalid: '邀请码无效或已被使用',
|
||||
invitationCodeValidating: '正在验证邀请码...',
|
||||
invitationCodeInvalidCannotRegister: '邀请码无效,请检查后重试',
|
||||
linuxdo: {
|
||||
signIn: '使用 Linux.do 登录',
|
||||
orContinue: '或使用邮箱密码继续',
|
||||
@@ -268,7 +282,36 @@ export default {
|
||||
code: '授权码',
|
||||
state: '状态',
|
||||
fullUrl: '完整URL'
|
||||
}
|
||||
},
|
||||
// 忘记密码
|
||||
forgotPassword: '忘记密码?',
|
||||
forgotPasswordTitle: '重置密码',
|
||||
forgotPasswordHint: '输入您的邮箱地址,我们将向您发送密码重置链接。',
|
||||
sendResetLink: '发送重置链接',
|
||||
sendingResetLink: '发送中...',
|
||||
sendResetLinkFailed: '发送重置链接失败,请重试。',
|
||||
resetEmailSent: '重置链接已发送',
|
||||
resetEmailSentHint: '如果该邮箱已注册,您将很快收到密码重置链接。请检查您的收件箱和垃圾邮件文件夹。',
|
||||
backToLogin: '返回登录',
|
||||
rememberedPassword: '想起密码了?',
|
||||
// 重置密码
|
||||
resetPasswordTitle: '设置新密码',
|
||||
resetPasswordHint: '请在下方输入您的新密码。',
|
||||
newPassword: '新密码',
|
||||
newPasswordPlaceholder: '输入新密码',
|
||||
confirmPassword: '确认密码',
|
||||
confirmPasswordPlaceholder: '再次输入新密码',
|
||||
confirmPasswordRequired: '请确认您的密码',
|
||||
passwordsDoNotMatch: '两次输入的密码不一致',
|
||||
resetPassword: '重置密码',
|
||||
resettingPassword: '重置中...',
|
||||
resetPasswordFailed: '重置密码失败,请重试。',
|
||||
passwordResetSuccess: '密码重置成功',
|
||||
passwordResetSuccessHint: '您的密码已重置。现在可以使用新密码登录。',
|
||||
invalidResetLink: '无效的重置链接',
|
||||
invalidResetLinkHint: '此密码重置链接无效或已过期。请重新请求一个新链接。',
|
||||
requestNewResetLink: '请求新的重置链接',
|
||||
invalidOrExpiredToken: '密码重置链接无效或已过期。请重新请求一个新链接。'
|
||||
},
|
||||
|
||||
// Dashboard
|
||||
@@ -455,6 +498,7 @@ export default {
|
||||
exporting: '导出中...',
|
||||
preparingExport: '正在准备导出...',
|
||||
model: '模型',
|
||||
reasoningEffort: '推理强度',
|
||||
type: '类型',
|
||||
tokens: 'Token',
|
||||
cost: '费用',
|
||||
@@ -550,7 +594,46 @@ export default {
|
||||
passwordsNotMatch: '两次输入的密码不一致',
|
||||
passwordTooShort: '密码至少需要 8 个字符',
|
||||
passwordChangeSuccess: '密码修改成功',
|
||||
passwordChangeFailed: '密码修改失败'
|
||||
passwordChangeFailed: '密码修改失败',
|
||||
// TOTP 2FA
|
||||
totp: {
|
||||
title: '双因素认证 (2FA)',
|
||||
description: '使用 Google Authenticator 等应用增强账户安全',
|
||||
enabled: '已启用',
|
||||
enabledAt: '启用时间',
|
||||
notEnabled: '未启用',
|
||||
notEnabledHint: '启用双因素认证可以增强账户安全性',
|
||||
enable: '启用',
|
||||
disable: '禁用',
|
||||
featureDisabled: '功能未开放',
|
||||
featureDisabledHint: '管理员尚未开放双因素认证功能',
|
||||
setupTitle: '设置双因素认证',
|
||||
setupStep1: '使用认证器应用扫描下方二维码',
|
||||
setupStep2: '输入应用显示的 6 位验证码',
|
||||
manualEntry: '无法扫码?手动输入密钥:',
|
||||
enterCode: '输入 6 位验证码',
|
||||
verify: '验证',
|
||||
setupFailed: '获取设置信息失败',
|
||||
verifyFailed: '验证码错误,请重试',
|
||||
enableSuccess: '双因素认证已启用',
|
||||
disableTitle: '禁用双因素认证',
|
||||
disableWarning: '禁用后,登录时将不再需要验证码。这可能会降低您的账户安全性。',
|
||||
enterPassword: '请输入当前密码确认',
|
||||
confirmDisable: '确认禁用',
|
||||
disableSuccess: '双因素认证已禁用',
|
||||
disableFailed: '禁用失败,请检查密码是否正确',
|
||||
loginTitle: '双因素认证',
|
||||
loginHint: '请输入您认证器应用显示的 6 位验证码',
|
||||
loginFailed: '验证失败,请重试',
|
||||
// New translations for email verification
|
||||
verifyEmailFirst: '请先验证您的邮箱',
|
||||
verifyPasswordFirst: '请先验证您的身份',
|
||||
emailCode: '邮箱验证码',
|
||||
enterEmailCode: '请输入 6 位验证码',
|
||||
sendCode: '发送验证码',
|
||||
codeSent: '验证码已发送到您的邮箱',
|
||||
sendCodeFailed: '发送验证码失败'
|
||||
}
|
||||
},
|
||||
|
||||
// Empty States
|
||||
@@ -811,6 +894,20 @@ export default {
|
||||
failedToDeposit: '充值失败',
|
||||
failedToWithdraw: '退款失败',
|
||||
useDepositWithdrawButtons: '请使用充值/退款按钮调整余额',
|
||||
// 余额变动记录
|
||||
balanceHistory: '充值记录',
|
||||
balanceHistoryTip: '点击查看充值记录',
|
||||
balanceHistoryTitle: '用户充值和并发变动记录',
|
||||
noBalanceHistory: '暂无变动记录',
|
||||
allTypes: '全部类型',
|
||||
typeBalance: '余额(兑换码)',
|
||||
typeAdminBalance: '余额(管理员调整)',
|
||||
typeConcurrency: '并发(兑换码)',
|
||||
typeAdminConcurrency: '并发(管理员调整)',
|
||||
typeSubscription: '订阅',
|
||||
failedToLoadBalanceHistory: '加载余额记录失败',
|
||||
createdAt: '创建时间',
|
||||
totalRecharged: '总充值',
|
||||
// Settings Dropdowns
|
||||
filterSettings: '筛选设置',
|
||||
columnSettings: '列设置',
|
||||
@@ -1013,6 +1110,14 @@ export default {
|
||||
fallbackHint: '非 Claude Code 请求将使用此分组,留空则直接拒绝',
|
||||
noFallback: '不降级(直接拒绝)'
|
||||
},
|
||||
copyAccounts: {
|
||||
title: '从分组复制账号',
|
||||
tooltip: '选择一个或多个相同平台的分组,创建后会自动将这些分组的所有账号绑定到新分组(去重)。',
|
||||
tooltipEdit: '选择一个或多个相同平台的分组,保存后当前分组的账号会被替换为这些分组的账号(去重)。',
|
||||
selectPlaceholder: '选择分组以复制其账号...',
|
||||
hint: '可选多个分组,账号会自动去重',
|
||||
hintEdit: '⚠️ 注意:这会替换当前分组的所有账号绑定'
|
||||
},
|
||||
modelRouting: {
|
||||
title: '模型路由配置',
|
||||
tooltip: '配置特定模型请求优先路由到指定账号。支持通配符匹配,如 claude-opus-* 匹配所有 opus 模型。',
|
||||
@@ -1232,6 +1337,7 @@ export default {
|
||||
overloaded: '过载中',
|
||||
tempUnschedulable: '临时不可调度',
|
||||
rateLimitedUntil: '限流中,重置时间:{time}',
|
||||
scopeRateLimitedUntil: '{scope} 限流中,重置时间:{time}',
|
||||
overloadedUntil: '负载过重,重置时间:{time}',
|
||||
viewTempUnschedDetails: '查看临时不可调度详情'
|
||||
},
|
||||
@@ -1489,6 +1595,8 @@ export default {
|
||||
accountUpdated: '账号更新成功',
|
||||
failedToCreate: '创建账号失败',
|
||||
failedToUpdate: '更新账号失败',
|
||||
mixedChannelWarningTitle: '混合渠道警告',
|
||||
mixedChannelWarning: '警告:分组 "{groupName}" 中同时包含 {currentPlatform} 和 {otherPlatform} 账号。混合使用不同渠道可能导致 thinking block 签名验证问题,会自动回退到非 thinking 模式。确定要继续吗?',
|
||||
pleaseEnterAccountName: '请输入账号名称',
|
||||
pleaseEnterApiKey: '请输入 API Key',
|
||||
apiKeyIsRequired: 'API Key 是必需的',
|
||||
@@ -1956,6 +2064,7 @@ export default {
|
||||
balance: '余额',
|
||||
concurrency: '并发数',
|
||||
subscription: '订阅',
|
||||
invitation: '邀请码',
|
||||
// 管理员在用户管理页面调整余额/并发时产生的记录
|
||||
admin_balance: '余额(管理员)',
|
||||
admin_concurrency: '并发数(管理员)'
|
||||
@@ -1964,6 +2073,8 @@ export default {
|
||||
balance: '余额',
|
||||
concurrency: '并发数',
|
||||
subscription: '订阅',
|
||||
invitation: '邀请码',
|
||||
invitationHint: '邀请码用于限制用户注册,使用后自动标记为已使用。',
|
||||
allTypes: '全部类型',
|
||||
allStatus: '全部状态',
|
||||
unused: '未使用',
|
||||
@@ -2043,6 +2154,73 @@ export default {
|
||||
failedToDelete: '删除兑换码失败'
|
||||
},
|
||||
|
||||
// Announcements
|
||||
announcements: {
|
||||
title: '公告管理',
|
||||
description: '创建公告并按条件投放',
|
||||
createAnnouncement: '创建公告',
|
||||
editAnnouncement: '编辑公告',
|
||||
deleteAnnouncement: '删除公告',
|
||||
searchAnnouncements: '搜索公告...',
|
||||
status: '状态',
|
||||
allStatus: '全部状态',
|
||||
columns: {
|
||||
title: '标题',
|
||||
status: '状态',
|
||||
targeting: '展示条件',
|
||||
timeRange: '有效期',
|
||||
createdAt: '创建时间',
|
||||
actions: '操作'
|
||||
},
|
||||
statusLabels: {
|
||||
draft: '草稿',
|
||||
active: '展示中',
|
||||
archived: '已归档'
|
||||
},
|
||||
form: {
|
||||
title: '标题',
|
||||
content: '内容(支持 Markdown)',
|
||||
status: '状态',
|
||||
startsAt: '开始时间',
|
||||
endsAt: '结束时间',
|
||||
startsAtHint: '留空表示立即生效',
|
||||
endsAtHint: '留空表示永久生效',
|
||||
targetingMode: '展示条件',
|
||||
targetingAll: '所有用户',
|
||||
targetingCustom: '按条件',
|
||||
addOrGroup: '添加 OR 条件组',
|
||||
addAndCondition: '添加 AND 条件',
|
||||
conditionType: '条件类型',
|
||||
conditionSubscription: '订阅套餐',
|
||||
conditionBalance: '余额',
|
||||
operator: '运算符',
|
||||
balanceValue: '余额阈值',
|
||||
selectPackages: '选择套餐'
|
||||
},
|
||||
operators: {
|
||||
gt: '>',
|
||||
gte: '≥',
|
||||
lt: '<',
|
||||
lte: '≤',
|
||||
eq: '='
|
||||
},
|
||||
targetingSummaryAll: '全部用户',
|
||||
targetingSummaryCustom: '自定义({groups} 组)',
|
||||
timeImmediate: '立即',
|
||||
timeNever: '永久',
|
||||
readStatus: '已读情况',
|
||||
eligible: '符合条件',
|
||||
readAt: '已读时间',
|
||||
unread: '未读',
|
||||
searchUsers: '搜索用户...',
|
||||
failedToLoad: '加载公告失败',
|
||||
failedToCreate: '创建公告失败',
|
||||
failedToUpdate: '更新公告失败',
|
||||
failedToDelete: '删除公告失败',
|
||||
failedToLoadReadStatus: '加载已读情况失败',
|
||||
deleteConfirm: '确定要删除该公告吗?此操作无法撤销。'
|
||||
},
|
||||
|
||||
// Promo Codes
|
||||
promo: {
|
||||
title: '优惠码管理',
|
||||
@@ -2819,7 +2997,9 @@ export default {
|
||||
ignoreContextCanceled: '忽略客户端断连错误',
|
||||
ignoreContextCanceledHint: '启用后,客户端主动断开连接(context canceled)的错误将不会写入错误日志。',
|
||||
ignoreNoAvailableAccounts: '忽略无可用账号错误',
|
||||
ignoreNoAvailableAccountsHint: '启用后,“No available accounts” 错误将不会写入错误日志(不推荐,这通常是配置问题)。',
|
||||
ignoreNoAvailableAccountsHint: '启用后,"No available accounts" 错误将不会写入错误日志(不推荐,这通常是配置问题)。',
|
||||
ignoreInvalidApiKeyErrors: '忽略无效 API Key 错误',
|
||||
ignoreInvalidApiKeyErrorsHint: '启用后,无效或缺失 API Key 的错误(INVALID_API_KEY、API_KEY_REQUIRED)将不会写入错误日志。',
|
||||
autoRefresh: '自动刷新',
|
||||
enableAutoRefresh: '启用自动刷新',
|
||||
enableAutoRefreshHint: '自动刷新仪表板数据,启用后会定期拉取最新数据。',
|
||||
@@ -2847,6 +3027,7 @@ export default {
|
||||
empty: '暂无数据',
|
||||
queued: '队列 {count}',
|
||||
rateLimited: '限流 {count}',
|
||||
scopeRateLimitedTooltip: '{scope} 限流中 ({count} 个账号)',
|
||||
errorAccounts: '异常 {count}',
|
||||
loadFailed: '加载并发数据失败'
|
||||
},
|
||||
@@ -2913,7 +3094,15 @@ export default {
|
||||
emailVerification: '邮箱验证',
|
||||
emailVerificationHint: '新用户注册时需要验证邮箱',
|
||||
promoCode: '优惠码',
|
||||
promoCodeHint: '允许用户在注册时使用优惠码'
|
||||
promoCodeHint: '允许用户在注册时使用优惠码',
|
||||
invitationCode: '邀请码注册',
|
||||
invitationCodeHint: '开启后,用户注册时需要填写有效的邀请码',
|
||||
passwordReset: '忘记密码',
|
||||
passwordResetHint: '允许用户通过邮箱重置密码',
|
||||
totp: '双因素认证 (2FA)',
|
||||
totpHint: '允许用户使用 Google Authenticator 等应用进行二次验证',
|
||||
totpKeyNotConfigured:
|
||||
'请先在环境变量中配置 TOTP_ENCRYPTION_KEY。使用命令 openssl rand -hex 32 生成密钥。'
|
||||
},
|
||||
turnstile: {
|
||||
title: 'Cloudflare Turnstile',
|
||||
@@ -2985,6 +3174,17 @@ export default {
|
||||
hideCcsImportButton: '隐藏 CCS 导入按钮',
|
||||
hideCcsImportButtonHint: '启用后将在 API Keys 页面隐藏"导入 CCS"按钮'
|
||||
},
|
||||
purchase: {
|
||||
title: '购买订阅页面',
|
||||
description: '在侧边栏展示“购买订阅”入口,并在页面内通过 iframe 打开指定链接',
|
||||
enabled: '显示购买订阅入口',
|
||||
enabledHint: '仅在标准模式(非简单模式)下展示',
|
||||
url: '购买页面 URL',
|
||||
urlPlaceholder: 'https://example.com/purchase',
|
||||
urlHint: '必须是完整的 http(s) 链接',
|
||||
iframeWarning:
|
||||
'⚠️ iframe 提示:部分网站会通过 X-Frame-Options 或 CSP(frame-ancestors)禁止被 iframe 嵌入,出现空白时可引导用户使用“新窗口打开”。'
|
||||
},
|
||||
smtp: {
|
||||
title: 'SMTP 设置',
|
||||
description: '配置用于发送验证码的邮件服务',
|
||||
@@ -3129,6 +3329,41 @@ export default {
|
||||
retry: '重试'
|
||||
},
|
||||
|
||||
// Purchase Subscription Page
|
||||
purchase: {
|
||||
title: '购买订阅',
|
||||
description: '通过内嵌页面完成订阅购买',
|
||||
openInNewTab: '新窗口打开',
|
||||
notEnabledTitle: '该功能未开启',
|
||||
notEnabledDesc: '管理员暂未开启购买订阅入口,请联系管理员。',
|
||||
notConfiguredTitle: '购买链接未配置',
|
||||
notConfiguredDesc: '管理员已开启入口,但尚未配置购买订阅链接,请联系管理员。'
|
||||
},
|
||||
|
||||
// Announcements Page
|
||||
announcements: {
|
||||
title: '公告',
|
||||
description: '查看系统公告',
|
||||
unreadOnly: '仅显示未读',
|
||||
markRead: '标记已读',
|
||||
markAllRead: '全部已读',
|
||||
viewAll: '查看全部公告',
|
||||
markedAsRead: '已标记为已读',
|
||||
allMarkedAsRead: '所有公告已标记为已读',
|
||||
newCount: '有 {count} 条新公告',
|
||||
readAt: '已读时间',
|
||||
read: '已读',
|
||||
unread: '未读',
|
||||
startsAt: '开始时间',
|
||||
endsAt: '结束时间',
|
||||
empty: '暂无公告',
|
||||
emptyUnread: '暂无未读公告',
|
||||
total: '条公告',
|
||||
emptyDescription: '暂时没有任何系统公告',
|
||||
readStatus: '您已阅读此公告',
|
||||
markReadHint: '点击"已读"标记此公告'
|
||||
},
|
||||
|
||||
// User Subscriptions Page
|
||||
userSubscriptions: {
|
||||
title: '我的订阅',
|
||||
|
||||
@@ -79,6 +79,24 @@ const routes: RouteRecordRaw[] = [
|
||||
title: 'LinuxDo OAuth Callback'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'ForgotPassword',
|
||||
component: () => import('@/views/auth/ForgotPasswordView.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: 'Forgot Password'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/reset-password',
|
||||
name: 'ResetPassword',
|
||||
component: () => import('@/views/auth/ResetPasswordView.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: 'Reset Password'
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== User Routes ====================
|
||||
{
|
||||
@@ -157,6 +175,18 @@ const routes: RouteRecordRaw[] = [
|
||||
descriptionKey: 'userSubscriptions.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/purchase',
|
||||
name: 'PurchaseSubscription',
|
||||
component: () => import('@/views/user/PurchaseSubscriptionView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: false,
|
||||
title: 'Purchase Subscription',
|
||||
titleKey: 'purchase.title',
|
||||
descriptionKey: 'purchase.description'
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== Admin Routes ====================
|
||||
{
|
||||
@@ -235,6 +265,18 @@ const routes: RouteRecordRaw[] = [
|
||||
descriptionKey: 'admin.accounts.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/announcements',
|
||||
name: 'AdminAnnouncements',
|
||||
component: () => import('@/views/admin/AnnouncementsView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'Announcements',
|
||||
titleKey: 'admin.announcements.title',
|
||||
descriptionKey: 'admin.announcements.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/proxies',
|
||||
name: 'AdminProxies',
|
||||
|
||||
@@ -313,6 +313,8 @@ export const useAppStore = defineStore('app', () => {
|
||||
registration_enabled: false,
|
||||
email_verify_enabled: false,
|
||||
promo_code_enabled: true,
|
||||
password_reset_enabled: false,
|
||||
invitation_code_enabled: false,
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: '',
|
||||
site_name: siteName.value,
|
||||
@@ -323,6 +325,8 @@ export const useAppStore = defineStore('app', () => {
|
||||
doc_url: docUrl.value,
|
||||
home_content: '',
|
||||
hide_ccs_import_button: false,
|
||||
purchase_subscription_enabled: false,
|
||||
purchase_subscription_url: '',
|
||||
linuxdo_oauth_enabled: false,
|
||||
version: siteVersion.value
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
import { authAPI } from '@/api'
|
||||
import type { User, LoginRequest, RegisterRequest } from '@/types'
|
||||
import { authAPI, isTotp2FARequired, type LoginResponse } from '@/api'
|
||||
import type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types'
|
||||
|
||||
const AUTH_TOKEN_KEY = 'auth_token'
|
||||
const AUTH_USER_KEY = 'auth_user'
|
||||
@@ -91,32 +91,23 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
/**
|
||||
* User login
|
||||
* @param credentials - Login credentials (username and password)
|
||||
* @returns Promise resolving to the authenticated user
|
||||
* @param credentials - Login credentials (email and password)
|
||||
* @returns Promise resolving to the login response (may require 2FA)
|
||||
* @throws Error if login fails
|
||||
*/
|
||||
async function login(credentials: LoginRequest): Promise<User> {
|
||||
async function login(credentials: LoginRequest): Promise<LoginResponse> {
|
||||
try {
|
||||
const response = await authAPI.login(credentials)
|
||||
|
||||
// Store token and user
|
||||
token.value = response.access_token
|
||||
|
||||
// Extract run_mode if present
|
||||
if (response.user.run_mode) {
|
||||
runMode.value = response.user.run_mode
|
||||
// If 2FA is required, return the response without setting auth state
|
||||
if (isTotp2FARequired(response)) {
|
||||
return response
|
||||
}
|
||||
const { run_mode: _run_mode, ...userData } = response.user
|
||||
user.value = userData
|
||||
|
||||
// Persist to localStorage
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, response.access_token)
|
||||
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(userData))
|
||||
// Set auth state from the response
|
||||
setAuthFromResponse(response)
|
||||
|
||||
// Start auto-refresh interval
|
||||
startAutoRefresh()
|
||||
|
||||
return userData
|
||||
return response
|
||||
} catch (error) {
|
||||
// Clear any partial state on error
|
||||
clearAuth()
|
||||
@@ -124,6 +115,47 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete login with 2FA code
|
||||
* @param tempToken - Temporary token from initial login
|
||||
* @param totpCode - 6-digit TOTP code
|
||||
* @returns Promise resolving to the authenticated user
|
||||
* @throws Error if 2FA verification fails
|
||||
*/
|
||||
async function login2FA(tempToken: string, totpCode: string): Promise<User> {
|
||||
try {
|
||||
const response = await authAPI.login2FA({ temp_token: tempToken, totp_code: totpCode })
|
||||
setAuthFromResponse(response)
|
||||
return user.value!
|
||||
} catch (error) {
|
||||
clearAuth()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set auth state from an AuthResponse
|
||||
* Internal helper function
|
||||
*/
|
||||
function setAuthFromResponse(response: AuthResponse): void {
|
||||
// Store token and user
|
||||
token.value = response.access_token
|
||||
|
||||
// Extract run_mode if present
|
||||
if (response.user.run_mode) {
|
||||
runMode.value = response.user.run_mode
|
||||
}
|
||||
const { run_mode: _run_mode, ...userData } = response.user
|
||||
user.value = userData
|
||||
|
||||
// Persist to localStorage
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, response.access_token)
|
||||
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(userData))
|
||||
|
||||
// Start auto-refresh interval
|
||||
startAutoRefresh()
|
||||
}
|
||||
|
||||
/**
|
||||
* User registration
|
||||
* @param userData - Registration data (username, email, password)
|
||||
@@ -253,6 +285,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
// Actions
|
||||
login,
|
||||
login2FA,
|
||||
register,
|
||||
setToken,
|
||||
logout,
|
||||
|
||||
@@ -55,6 +55,7 @@ export interface RegisterRequest {
|
||||
verify_code?: string
|
||||
turnstile_token?: string
|
||||
promo_code?: string
|
||||
invitation_code?: string
|
||||
}
|
||||
|
||||
export interface SendVerifyCodeRequest {
|
||||
@@ -71,6 +72,8 @@ export interface PublicSettings {
|
||||
registration_enabled: boolean
|
||||
email_verify_enabled: boolean
|
||||
promo_code_enabled: boolean
|
||||
password_reset_enabled: boolean
|
||||
invitation_code_enabled: boolean
|
||||
turnstile_enabled: boolean
|
||||
turnstile_site_key: string
|
||||
site_name: string
|
||||
@@ -81,6 +84,8 @@ export interface PublicSettings {
|
||||
doc_url: string
|
||||
home_content: string
|
||||
hide_ccs_import_button: boolean
|
||||
purchase_subscription_enabled: boolean
|
||||
purchase_subscription_url: string
|
||||
linuxdo_oauth_enabled: boolean
|
||||
version: string
|
||||
}
|
||||
@@ -126,6 +131,81 @@ export interface UpdateSubscriptionRequest {
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
// ==================== Announcement Types ====================
|
||||
|
||||
export type AnnouncementStatus = 'draft' | 'active' | 'archived'
|
||||
|
||||
export type AnnouncementConditionType = 'subscription' | 'balance'
|
||||
|
||||
export type AnnouncementOperator = 'in' | 'gt' | 'gte' | 'lt' | 'lte' | 'eq'
|
||||
|
||||
export interface AnnouncementCondition {
|
||||
type: AnnouncementConditionType
|
||||
operator: AnnouncementOperator
|
||||
group_ids?: number[]
|
||||
value?: number
|
||||
}
|
||||
|
||||
export interface AnnouncementConditionGroup {
|
||||
all_of?: AnnouncementCondition[]
|
||||
}
|
||||
|
||||
export interface AnnouncementTargeting {
|
||||
any_of?: AnnouncementConditionGroup[]
|
||||
}
|
||||
|
||||
export interface Announcement {
|
||||
id: number
|
||||
title: string
|
||||
content: string
|
||||
status: AnnouncementStatus
|
||||
targeting: AnnouncementTargeting
|
||||
starts_at?: string
|
||||
ends_at?: string
|
||||
created_by?: number
|
||||
updated_by?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface UserAnnouncement {
|
||||
id: number
|
||||
title: string
|
||||
content: string
|
||||
starts_at?: string
|
||||
ends_at?: string
|
||||
read_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CreateAnnouncementRequest {
|
||||
title: string
|
||||
content: string
|
||||
status?: AnnouncementStatus
|
||||
targeting: AnnouncementTargeting
|
||||
starts_at?: number
|
||||
ends_at?: number
|
||||
}
|
||||
|
||||
export interface UpdateAnnouncementRequest {
|
||||
title?: string
|
||||
content?: string
|
||||
status?: AnnouncementStatus
|
||||
targeting?: AnnouncementTargeting
|
||||
starts_at?: number
|
||||
ends_at?: number
|
||||
}
|
||||
|
||||
export interface AnnouncementUserReadStatus {
|
||||
user_id: number
|
||||
email: string
|
||||
username: string
|
||||
balance: number
|
||||
eligible: boolean
|
||||
read_at?: string
|
||||
}
|
||||
|
||||
// ==================== Proxy Node Types ====================
|
||||
|
||||
export interface ProxyNode {
|
||||
@@ -342,6 +422,8 @@ export interface CreateGroupRequest {
|
||||
sora_video_price_per_request_hd?: number | null
|
||||
claude_code_only?: boolean
|
||||
fallback_group_id?: number | null
|
||||
// 从指定分组复制账号
|
||||
copy_accounts_from_group_ids?: number[]
|
||||
}
|
||||
|
||||
export interface UpdateGroupRequest {
|
||||
@@ -364,6 +446,7 @@ export interface UpdateGroupRequest {
|
||||
sora_video_price_per_request_hd?: number | null
|
||||
claude_code_only?: boolean
|
||||
fallback_group_id?: number | null
|
||||
copy_accounts_from_group_ids?: number[]
|
||||
}
|
||||
|
||||
// ==================== Account & Proxy Types ====================
|
||||
@@ -491,6 +574,9 @@ export interface Account {
|
||||
temp_unschedulable_until: string | null
|
||||
temp_unschedulable_reason: string | null
|
||||
|
||||
// Antigravity scope 级限流状态
|
||||
scope_rate_limits?: Record<string, { reset_at: string; remaining_sec: number }>
|
||||
|
||||
// Session window fields (5-hour window)
|
||||
session_window_start: string | null
|
||||
session_window_end: string | null
|
||||
@@ -633,7 +719,7 @@ export interface UpdateProxyRequest {
|
||||
|
||||
// ==================== Usage & Redeem Types ====================
|
||||
|
||||
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription'
|
||||
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' | 'invitation'
|
||||
|
||||
export interface UsageLog {
|
||||
id: number
|
||||
@@ -642,6 +728,7 @@ export interface UsageLog {
|
||||
account_id: number | null
|
||||
request_id: string
|
||||
model: string
|
||||
reasoning_effort?: string | null
|
||||
|
||||
group_id: number | null
|
||||
subscription_id: number | null
|
||||
@@ -1120,3 +1207,52 @@ export interface UpdatePromoCodeRequest {
|
||||
expires_at?: number | null
|
||||
notes?: string
|
||||
}
|
||||
|
||||
// ==================== TOTP (2FA) Types ====================
|
||||
|
||||
export interface TotpStatus {
|
||||
enabled: boolean
|
||||
enabled_at: number | null // Unix timestamp in seconds
|
||||
feature_enabled: boolean
|
||||
}
|
||||
|
||||
export interface TotpSetupRequest {
|
||||
email_code?: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
export interface TotpSetupResponse {
|
||||
secret: string
|
||||
qr_code_url: string
|
||||
setup_token: string
|
||||
countdown: number
|
||||
}
|
||||
|
||||
export interface TotpEnableRequest {
|
||||
totp_code: string
|
||||
setup_token: string
|
||||
}
|
||||
|
||||
export interface TotpEnableResponse {
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export interface TotpDisableRequest {
|
||||
email_code?: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
export interface TotpVerificationMethod {
|
||||
method: 'email' | 'password'
|
||||
}
|
||||
|
||||
export interface TotpLoginResponse {
|
||||
requires_2fa: boolean
|
||||
temp_token?: string
|
||||
user_email_masked?: string
|
||||
}
|
||||
|
||||
export interface TotpLogin2FARequest {
|
||||
temp_token: string
|
||||
totp_code: string
|
||||
}
|
||||
|
||||
@@ -174,6 +174,35 @@ export function parseDateTimeLocalInput(value: string): number | null {
|
||||
return Math.floor(date.getTime() / 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 OpenAI reasoning effort(用于使用记录展示)
|
||||
* @param effort 原始 effort(如 "low" / "medium" / "high" / "xhigh")
|
||||
* @returns 格式化后的字符串(Low / Medium / High / Xhigh),无值返回 "-"
|
||||
*/
|
||||
export function formatReasoningEffort(effort: string | null | undefined): string {
|
||||
const raw = (effort ?? '').toString().trim()
|
||||
if (!raw) return '-'
|
||||
|
||||
const normalized = raw.toLowerCase().replace(/[-_\s]/g, '')
|
||||
switch (normalized) {
|
||||
case 'low':
|
||||
return 'Low'
|
||||
case 'medium':
|
||||
return 'Medium'
|
||||
case 'high':
|
||||
return 'High'
|
||||
case 'xhigh':
|
||||
case 'extrahigh':
|
||||
return 'Xhigh'
|
||||
case 'none':
|
||||
case 'minimal':
|
||||
return '-'
|
||||
default:
|
||||
// best-effort: Title-case first letter
|
||||
return raw.length > 1 ? raw[0].toUpperCase() + raw.slice(1) : raw.toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间(只显示时分)
|
||||
* @param date 日期字符串或 Date 对象
|
||||
@@ -261,3 +290,22 @@ export function formatCountdownWithSuffix(targetDate: string | Date | null | und
|
||||
if (!countdown) return null
|
||||
return i18n.global.t('common.time.countdown.withSuffix', { time: countdown })
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化为相对时间 + 具体时间组合
|
||||
* @param date 日期字符串或 Date 对象
|
||||
* @returns 组合时间字符串,如 "5 天前 · 2026-01-27 15:25"
|
||||
*/
|
||||
export function formatRelativeWithDateTime(date: string | Date | null | undefined): string {
|
||||
if (!date) return ''
|
||||
|
||||
const relativeTime = formatRelativeTime(date)
|
||||
const dateTime = formatDateTime(date)
|
||||
|
||||
// 如果是 "从未" 或空字符串,只返回相对时间
|
||||
if (!dateTime || relativeTime === i18n.global.t('common.time.never')) {
|
||||
return relativeTime
|
||||
}
|
||||
|
||||
return `${relativeTime} · ${dateTime}`
|
||||
}
|
||||
|
||||
538
frontend/src/views/admin/AnnouncementsView.vue
Normal file
538
frontend/src/views/admin/AnnouncementsView.vue
Normal file
@@ -0,0 +1,538 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<TablePageLayout>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="loadAnnouncements"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<button @click="openCreateDialog" class="btn btn-primary">
|
||||
<Icon name="plus" size="md" class="mr-1" />
|
||||
{{ t('admin.announcements.createAnnouncement') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #filters>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="max-w-md flex-1">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('admin.announcements.searchAnnouncements')"
|
||||
class="input"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="statusFilterOptions"
|
||||
class="w-40"
|
||||
@change="handleStatusChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="announcements" :loading="loading">
|
||||
<template #cell-title="{ value, row }">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500 dark:text-dark-400">
|
||||
<span>#{{ row.id }}</span>
|
||||
<span class="text-gray-300 dark:text-dark-700">·</span>
|
||||
<span>{{ formatDateTime(row.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
value === 'active'
|
||||
? 'badge-success'
|
||||
: value === 'draft'
|
||||
? 'badge-gray'
|
||||
: 'badge-warning'
|
||||
]"
|
||||
>
|
||||
{{ statusLabel(value) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-targeting="{ row }">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ targetingSummary(row.targeting) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-timeRange="{ row }">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
<div>
|
||||
<span class="font-medium">{{ t('admin.announcements.form.startsAt') }}:</span>
|
||||
<span class="ml-1">{{ row.starts_at ? formatDateTime(row.starts_at) : t('admin.announcements.timeImmediate') }}</span>
|
||||
</div>
|
||||
<div class="mt-0.5">
|
||||
<span class="font-medium">{{ t('admin.announcements.form.endsAt') }}:</span>
|
||||
<span class="ml-1">{{ row.ends_at ? formatDateTime(row.ends_at) : t('admin.announcements.timeNever') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-createdAt="{ value }">
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center space-x-1">
|
||||
<button
|
||||
@click="openReadStatus(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||
:title="t('admin.announcements.readStatus')"
|
||||
>
|
||||
<Icon name="eye" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
@click="openEditDialog(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-dark-600 dark:hover:text-gray-300"
|
||||
:title="t('common.edit')"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
@click="handleDelete(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
:title="t('common.delete')"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<EmptyState
|
||||
:title="t('empty.noData')"
|
||||
:description="t('admin.announcements.failedToLoad')"
|
||||
:action-text="t('admin.announcements.createAnnouncement')"
|
||||
@action="openCreateDialog"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
|
||||
<template #pagination>
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
@update:pageSize="handlePageSizeChange"
|
||||
/>
|
||||
</template>
|
||||
</TablePageLayout>
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
<BaseDialog
|
||||
:show="showEditDialog"
|
||||
:title="isEditing ? t('admin.announcements.editAnnouncement') : t('admin.announcements.createAnnouncement')"
|
||||
width="wide"
|
||||
@close="closeEdit"
|
||||
>
|
||||
<form id="announcement-form" @submit.prevent="handleSave" class="space-y-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.announcements.form.title') }}</label>
|
||||
<input v-model="form.title" type="text" class="input" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.announcements.form.content') }}</label>
|
||||
<textarea v-model="form.content" rows="6" class="input" required></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.announcements.form.status') }}</label>
|
||||
<Select v-model="form.status" :options="statusOptions" />
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.announcements.form.startsAt') }}</label>
|
||||
<input v-model="form.starts_at_str" type="datetime-local" class="input" />
|
||||
<p class="input-hint">{{ t('admin.announcements.form.startsAtHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.announcements.form.endsAt') }}</label>
|
||||
<input v-model="form.ends_at_str" type="datetime-local" class="input" />
|
||||
<p class="input-hint">{{ t('admin.announcements.form.endsAtHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnnouncementTargetingEditor
|
||||
v-model="form.targeting"
|
||||
:groups="subscriptionGroups"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" @click="closeEdit" class="btn btn-secondary">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" form="announcement-form" :disabled="saving" class="btn btn-primary">
|
||||
{{ saving ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Delete Confirmation -->
|
||||
<ConfirmDialog
|
||||
:show="showDeleteDialog"
|
||||
:title="t('admin.announcements.deleteAnnouncement')"
|
||||
:message="t('admin.announcements.deleteConfirm')"
|
||||
:confirm-text="t('common.delete')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
danger
|
||||
@confirm="confirmDelete"
|
||||
@cancel="showDeleteDialog = false"
|
||||
/>
|
||||
|
||||
<!-- Read Status Dialog -->
|
||||
<AnnouncementReadStatusDialog
|
||||
:show="showReadStatusDialog"
|
||||
:announcement-id="readStatusAnnouncementId"
|
||||
@close="showReadStatusDialog = false"
|
||||
/>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { formatDateTime, formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
|
||||
import type { AdminGroup, Announcement, AnnouncementTargeting } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
import AnnouncementTargetingEditor from '@/components/admin/announcements/AnnouncementTargetingEditor.vue'
|
||||
import AnnouncementReadStatusDialog from '@/components/admin/announcements/AnnouncementReadStatusDialog.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const announcements = ref<Announcement[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const filters = reactive({
|
||||
status: '',
|
||||
})
|
||||
const searchQuery = ref('')
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
|
||||
const statusFilterOptions = computed(() => [
|
||||
{ value: '', label: t('admin.announcements.allStatus') },
|
||||
{ value: 'draft', label: t('admin.announcements.statusLabels.draft') },
|
||||
{ value: 'active', label: t('admin.announcements.statusLabels.active') },
|
||||
{ value: 'archived', label: t('admin.announcements.statusLabels.archived') }
|
||||
])
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ value: 'draft', label: t('admin.announcements.statusLabels.draft') },
|
||||
{ value: 'active', label: t('admin.announcements.statusLabels.active') },
|
||||
{ value: 'archived', label: t('admin.announcements.statusLabels.archived') }
|
||||
])
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'title', label: t('admin.announcements.columns.title') },
|
||||
{ key: 'status', label: t('admin.announcements.columns.status') },
|
||||
{ key: 'targeting', label: t('admin.announcements.columns.targeting') },
|
||||
{ key: 'timeRange', label: t('admin.announcements.columns.timeRange') },
|
||||
{ key: 'createdAt', label: t('admin.announcements.columns.createdAt') },
|
||||
{ key: 'actions', label: t('admin.announcements.columns.actions') }
|
||||
])
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
if (status === 'draft') return t('admin.announcements.statusLabels.draft')
|
||||
if (status === 'active') return t('admin.announcements.statusLabels.active')
|
||||
if (status === 'archived') return t('admin.announcements.statusLabels.archived')
|
||||
return status
|
||||
}
|
||||
|
||||
const targetingSummary = (targeting: AnnouncementTargeting) => {
|
||||
const anyOf = targeting?.any_of ?? []
|
||||
if (!anyOf || anyOf.length === 0) return t('admin.announcements.targetingSummaryAll')
|
||||
return t('admin.announcements.targetingSummaryCustom', { groups: anyOf.length })
|
||||
}
|
||||
|
||||
// ===== CRUD / list =====
|
||||
let currentController: AbortController | null = null
|
||||
|
||||
async function loadAnnouncements() {
|
||||
if (currentController) currentController.abort()
|
||||
currentController = new AbortController()
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await adminAPI.announcements.list(pagination.page, pagination.page_size, {
|
||||
status: filters.status || undefined,
|
||||
search: searchQuery.value || undefined
|
||||
})
|
||||
|
||||
announcements.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('Error loading announcements:', error)
|
||||
appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoad'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
pagination.page = page
|
||||
loadAnnouncements()
|
||||
}
|
||||
|
||||
function handlePageSizeChange(pageSize: number) {
|
||||
pagination.page_size = pageSize
|
||||
pagination.page = 1
|
||||
loadAnnouncements()
|
||||
}
|
||||
|
||||
function handleStatusChange() {
|
||||
pagination.page = 1
|
||||
loadAnnouncements()
|
||||
}
|
||||
|
||||
let searchDebounceTimer: number | null = null
|
||||
function handleSearch() {
|
||||
if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = window.setTimeout(() => {
|
||||
pagination.page = 1
|
||||
loadAnnouncements()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// ===== Create/Edit dialog =====
|
||||
const showEditDialog = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingAnnouncement = ref<Announcement | null>(null)
|
||||
|
||||
const isEditing = computed(() => !!editingAnnouncement.value)
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
content: '',
|
||||
status: 'draft',
|
||||
starts_at_str: '',
|
||||
ends_at_str: '',
|
||||
targeting: { any_of: [] } as AnnouncementTargeting
|
||||
})
|
||||
|
||||
const subscriptionGroups = ref<AdminGroup[]>([])
|
||||
|
||||
async function loadSubscriptionGroups() {
|
||||
try {
|
||||
const all = await adminAPI.groups.getAll()
|
||||
subscriptionGroups.value = (all || []).filter((g) => g.subscription_type === 'subscription')
|
||||
} catch (error: any) {
|
||||
console.error('Error loading groups:', error)
|
||||
// not fatal
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.title = ''
|
||||
form.content = ''
|
||||
form.status = 'draft'
|
||||
form.starts_at_str = ''
|
||||
form.ends_at_str = ''
|
||||
form.targeting = { any_of: [] }
|
||||
}
|
||||
|
||||
function fillFormFromAnnouncement(a: Announcement) {
|
||||
form.title = a.title
|
||||
form.content = a.content
|
||||
form.status = a.status
|
||||
|
||||
// Backend returns RFC3339 strings
|
||||
form.starts_at_str = a.starts_at ? formatDateTimeLocalInput(Math.floor(new Date(a.starts_at).getTime() / 1000)) : ''
|
||||
form.ends_at_str = a.ends_at ? formatDateTimeLocalInput(Math.floor(new Date(a.ends_at).getTime() / 1000)) : ''
|
||||
|
||||
form.targeting = a.targeting ?? { any_of: [] }
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
editingAnnouncement.value = null
|
||||
resetForm()
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(row: Announcement) {
|
||||
editingAnnouncement.value = row
|
||||
fillFormFromAnnouncement(row)
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
function closeEdit() {
|
||||
showEditDialog.value = false
|
||||
editingAnnouncement.value = null
|
||||
}
|
||||
|
||||
function buildCreatePayload() {
|
||||
const startsAt = parseDateTimeLocalInput(form.starts_at_str)
|
||||
const endsAt = parseDateTimeLocalInput(form.ends_at_str)
|
||||
|
||||
return {
|
||||
title: form.title,
|
||||
content: form.content,
|
||||
status: form.status as any,
|
||||
targeting: form.targeting,
|
||||
starts_at: startsAt ?? undefined,
|
||||
ends_at: endsAt ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
function buildUpdatePayload(original: Announcement) {
|
||||
const payload: any = {}
|
||||
|
||||
if (form.title !== original.title) payload.title = form.title
|
||||
if (form.content !== original.content) payload.content = form.content
|
||||
if (form.status !== original.status) payload.status = form.status
|
||||
|
||||
// starts_at / ends_at: distinguish unchanged vs clear(0) vs set
|
||||
const originalStarts = original.starts_at ? Math.floor(new Date(original.starts_at).getTime() / 1000) : null
|
||||
const originalEnds = original.ends_at ? Math.floor(new Date(original.ends_at).getTime() / 1000) : null
|
||||
|
||||
const newStarts = parseDateTimeLocalInput(form.starts_at_str)
|
||||
const newEnds = parseDateTimeLocalInput(form.ends_at_str)
|
||||
|
||||
if (newStarts !== originalStarts) {
|
||||
payload.starts_at = newStarts === null ? 0 : newStarts
|
||||
}
|
||||
if (newEnds !== originalEnds) {
|
||||
payload.ends_at = newEnds === null ? 0 : newEnds
|
||||
}
|
||||
|
||||
// targeting: do shallow compare by JSON
|
||||
if (JSON.stringify(form.targeting ?? {}) !== JSON.stringify(original.targeting ?? {})) {
|
||||
payload.targeting = form.targeting
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
// Frontend validation for targeting (to avoid ANNOUNCEMENT_INVALID_TARGET)
|
||||
const anyOf = form.targeting?.any_of ?? []
|
||||
if (anyOf.length > 50) {
|
||||
appStore.showError(t('admin.announcements.failedToCreate'))
|
||||
return
|
||||
}
|
||||
for (const g of anyOf) {
|
||||
const allOf = g?.all_of ?? []
|
||||
if (allOf.length > 50) {
|
||||
appStore.showError(t('admin.announcements.failedToCreate'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (!editingAnnouncement.value) {
|
||||
const payload = buildCreatePayload()
|
||||
await adminAPI.announcements.create(payload)
|
||||
appStore.showSuccess(t('common.success'))
|
||||
showEditDialog.value = false
|
||||
await loadAnnouncements()
|
||||
return
|
||||
}
|
||||
|
||||
const original = editingAnnouncement.value
|
||||
const payload = buildUpdatePayload(original)
|
||||
await adminAPI.announcements.update(original.id, payload)
|
||||
appStore.showSuccess(t('common.success'))
|
||||
showEditDialog.value = false
|
||||
editingAnnouncement.value = null
|
||||
await loadAnnouncements()
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save announcement:', error)
|
||||
appStore.showError(error.response?.data?.detail || (editingAnnouncement.value ? t('admin.announcements.failedToUpdate') : t('admin.announcements.failedToCreate')))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Delete =====
|
||||
const showDeleteDialog = ref(false)
|
||||
const deletingAnnouncement = ref<Announcement | null>(null)
|
||||
|
||||
function handleDelete(row: Announcement) {
|
||||
deletingAnnouncement.value = row
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deletingAnnouncement.value) return
|
||||
|
||||
try {
|
||||
await adminAPI.announcements.delete(deletingAnnouncement.value.id)
|
||||
appStore.showSuccess(t('common.success'))
|
||||
showDeleteDialog.value = false
|
||||
deletingAnnouncement.value = null
|
||||
await loadAnnouncements()
|
||||
} catch (error: any) {
|
||||
console.error('Failed to delete announcement:', error)
|
||||
appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToDelete'))
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Read status =====
|
||||
const showReadStatusDialog = ref(false)
|
||||
const readStatusAnnouncementId = ref<number | null>(null)
|
||||
|
||||
function openReadStatus(row: Announcement) {
|
||||
readStatusAnnouncementId.value = row.id
|
||||
showReadStatusDialog.value = true
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSubscriptionGroups()
|
||||
await loadAnnouncements()
|
||||
})
|
||||
</script>
|
||||
@@ -240,9 +240,73 @@
|
||||
v-model="createForm.platform"
|
||||
:options="platformOptions"
|
||||
data-tour="group-form-platform"
|
||||
@change="createForm.copy_accounts_from_group_ids = []"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.groups.platformHint') }}</p>
|
||||
</div>
|
||||
<!-- 从分组复制账号 -->
|
||||
<div v-if="copyAccountsGroupOptions.length > 0">
|
||||
<div class="mb-1.5 flex items-center gap-1">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.groups.copyAccounts.title') }}
|
||||
</label>
|
||||
<div class="group relative inline-flex">
|
||||
<Icon
|
||||
name="questionCircle"
|
||||
size="sm"
|
||||
:stroke-width="2"
|
||||
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
|
||||
/>
|
||||
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
|
||||
<p class="text-xs leading-relaxed text-gray-300">
|
||||
{{ t('admin.groups.copyAccounts.tooltip') }}
|
||||
</p>
|
||||
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 已选分组标签 -->
|
||||
<div v-if="createForm.copy_accounts_from_group_ids.length > 0" class="flex flex-wrap gap-1.5 mb-2">
|
||||
<span
|
||||
v-for="groupId in createForm.copy_accounts_from_group_ids"
|
||||
:key="groupId"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
|
||||
>
|
||||
{{ copyAccountsGroupOptions.find(o => o.value === groupId)?.label || `#${groupId}` }}
|
||||
<button
|
||||
type="button"
|
||||
@click="createForm.copy_accounts_from_group_ids = createForm.copy_accounts_from_group_ids.filter(id => id !== groupId)"
|
||||
class="ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200"
|
||||
>
|
||||
<Icon name="x" size="xs" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 分组选择下拉 -->
|
||||
<select
|
||||
class="input"
|
||||
@change="(e) => {
|
||||
const val = Number((e.target as HTMLSelectElement).value)
|
||||
if (val && !createForm.copy_accounts_from_group_ids.includes(val)) {
|
||||
createForm.copy_accounts_from_group_ids.push(val)
|
||||
}
|
||||
(e.target as HTMLSelectElement).value = ''
|
||||
}"
|
||||
>
|
||||
<option value="">{{ t('admin.groups.copyAccounts.selectPlaceholder') }}</option>
|
||||
<option
|
||||
v-for="opt in copyAccountsGroupOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
:disabled="createForm.copy_accounts_from_group_ids.includes(opt.value)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="input-hint">{{ t('admin.groups.copyAccounts.hint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label>
|
||||
<input
|
||||
@@ -738,6 +802,69 @@
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.groups.platformNotEditable') }}</p>
|
||||
</div>
|
||||
<!-- 从分组复制账号(编辑时) -->
|
||||
<div v-if="copyAccountsGroupOptionsForEdit.length > 0">
|
||||
<div class="mb-1.5 flex items-center gap-1">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.groups.copyAccounts.title') }}
|
||||
</label>
|
||||
<div class="group relative inline-flex">
|
||||
<Icon
|
||||
name="questionCircle"
|
||||
size="sm"
|
||||
:stroke-width="2"
|
||||
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
|
||||
/>
|
||||
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
|
||||
<p class="text-xs leading-relaxed text-gray-300">
|
||||
{{ t('admin.groups.copyAccounts.tooltipEdit') }}
|
||||
</p>
|
||||
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 已选分组标签 -->
|
||||
<div v-if="editForm.copy_accounts_from_group_ids.length > 0" class="flex flex-wrap gap-1.5 mb-2">
|
||||
<span
|
||||
v-for="groupId in editForm.copy_accounts_from_group_ids"
|
||||
:key="groupId"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
|
||||
>
|
||||
{{ copyAccountsGroupOptionsForEdit.find(o => o.value === groupId)?.label || `#${groupId}` }}
|
||||
<button
|
||||
type="button"
|
||||
@click="editForm.copy_accounts_from_group_ids = editForm.copy_accounts_from_group_ids.filter(id => id !== groupId)"
|
||||
class="ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200"
|
||||
>
|
||||
<Icon name="x" size="xs" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 分组选择下拉 -->
|
||||
<select
|
||||
class="input"
|
||||
@change="(e) => {
|
||||
const val = Number((e.target as HTMLSelectElement).value)
|
||||
if (val && !editForm.copy_accounts_from_group_ids.includes(val)) {
|
||||
editForm.copy_accounts_from_group_ids.push(val)
|
||||
}
|
||||
(e.target as HTMLSelectElement).value = ''
|
||||
}"
|
||||
>
|
||||
<option value="">{{ t('admin.groups.copyAccounts.selectPlaceholder') }}</option>
|
||||
<option
|
||||
v-for="opt in copyAccountsGroupOptionsForEdit"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
:disabled="editForm.copy_accounts_from_group_ids.includes(opt.value)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="input-hint">{{ t('admin.groups.copyAccounts.hintEdit') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label>
|
||||
<input
|
||||
@@ -1320,6 +1447,29 @@ const fallbackGroupOptionsForEdit = computed(() => {
|
||||
return options
|
||||
})
|
||||
|
||||
// 复制账号的源分组选项(创建时)- 仅包含相同平台且有账号的分组
|
||||
const copyAccountsGroupOptions = computed(() => {
|
||||
const eligibleGroups = groups.value.filter(
|
||||
(g) => g.platform === createForm.platform && (g.account_count || 0) > 0
|
||||
)
|
||||
return eligibleGroups.map((g) => ({
|
||||
value: g.id,
|
||||
label: `${g.name} (${g.account_count || 0} 个账号)`
|
||||
}))
|
||||
})
|
||||
|
||||
// 复制账号的源分组选项(编辑时)- 仅包含相同平台且有账号的分组,排除自身
|
||||
const copyAccountsGroupOptionsForEdit = computed(() => {
|
||||
const currentId = editingGroup.value?.id
|
||||
const eligibleGroups = groups.value.filter(
|
||||
(g) => g.platform === editForm.platform && (g.account_count || 0) > 0 && g.id !== currentId
|
||||
)
|
||||
return eligibleGroups.map((g) => ({
|
||||
value: g.id,
|
||||
label: `${g.name} (${g.account_count || 0} 个账号)`
|
||||
}))
|
||||
})
|
||||
|
||||
const groups = ref<AdminGroup[]>([])
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
@@ -1367,7 +1517,9 @@ const createForm = reactive({
|
||||
claude_code_only: false,
|
||||
fallback_group_id: null as number | null,
|
||||
// 模型路由开关
|
||||
model_routing_enabled: false
|
||||
model_routing_enabled: false,
|
||||
// 从分组复制账号
|
||||
copy_accounts_from_group_ids: [] as number[]
|
||||
})
|
||||
|
||||
// 简单账号类型(用于模型路由选择)
|
||||
@@ -1543,7 +1695,9 @@ const editForm = reactive({
|
||||
claude_code_only: false,
|
||||
fallback_group_id: null as number | null,
|
||||
// 模型路由开关
|
||||
model_routing_enabled: false
|
||||
model_routing_enabled: false,
|
||||
// 从分组复制账号
|
||||
copy_accounts_from_group_ids: [] as number[]
|
||||
})
|
||||
|
||||
// 根据分组类型返回不同的删除确认消息
|
||||
@@ -1629,6 +1783,7 @@ const closeCreateModal = () => {
|
||||
createForm.sora_video_price_per_request_hd = null
|
||||
createForm.claude_code_only = false
|
||||
createForm.fallback_group_id = null
|
||||
createForm.copy_accounts_from_group_ids = []
|
||||
createModelRoutingRules.value = []
|
||||
}
|
||||
|
||||
@@ -1683,6 +1838,7 @@ const handleEdit = async (group: AdminGroup) => {
|
||||
editForm.claude_code_only = group.claude_code_only || false
|
||||
editForm.fallback_group_id = group.fallback_group_id
|
||||
editForm.model_routing_enabled = group.model_routing_enabled || false
|
||||
editForm.copy_accounts_from_group_ids = [] // 复制账号字段每次编辑时重置为空
|
||||
// 加载模型路由规则(异步加载账号名称)
|
||||
editModelRoutingRules.value = await convertApiFormatToRoutingRules(group.model_routing)
|
||||
showEditModal.value = true
|
||||
@@ -1692,6 +1848,7 @@ const closeEditModal = () => {
|
||||
showEditModal.value = false
|
||||
editingGroup.value = null
|
||||
editModelRoutingRules.value = []
|
||||
editForm.copy_accounts_from_group_ids = []
|
||||
}
|
||||
|
||||
const handleUpdateGroup = async () => {
|
||||
|
||||
@@ -213,7 +213,7 @@
|
||||
<Select v-model="generateForm.type" :options="typeOptions" />
|
||||
</div>
|
||||
<!-- 余额/并发类型:显示数值输入 -->
|
||||
<div v-if="generateForm.type !== 'subscription'">
|
||||
<div v-if="generateForm.type !== 'subscription' && generateForm.type !== 'invitation'">
|
||||
<label class="input-label">
|
||||
{{
|
||||
generateForm.type === 'balance'
|
||||
@@ -230,6 +230,12 @@
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<!-- 邀请码类型:显示提示信息 -->
|
||||
<div v-if="generateForm.type === 'invitation'" class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300">
|
||||
{{ t('admin.redeem.invitationHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- 订阅类型:显示分组选择和有效天数 -->
|
||||
<template v-if="generateForm.type === 'subscription'">
|
||||
<div>
|
||||
@@ -387,7 +393,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
@@ -499,14 +505,16 @@ const columns = computed<Column[]>(() => [
|
||||
const typeOptions = computed(() => [
|
||||
{ value: 'balance', label: t('admin.redeem.balance') },
|
||||
{ value: 'concurrency', label: t('admin.redeem.concurrency') },
|
||||
{ value: 'subscription', label: t('admin.redeem.subscription') }
|
||||
{ value: 'subscription', label: t('admin.redeem.subscription') },
|
||||
{ value: 'invitation', label: t('admin.redeem.invitation') }
|
||||
])
|
||||
|
||||
const filterTypeOptions = computed(() => [
|
||||
{ value: '', label: t('admin.redeem.allTypes') },
|
||||
{ value: 'balance', label: t('admin.redeem.balance') },
|
||||
{ value: 'concurrency', label: t('admin.redeem.concurrency') },
|
||||
{ value: 'subscription', label: t('admin.redeem.subscription') }
|
||||
{ value: 'subscription', label: t('admin.redeem.subscription') },
|
||||
{ value: 'invitation', label: t('admin.redeem.invitation') }
|
||||
])
|
||||
|
||||
const filterStatusOptions = computed(() => [
|
||||
@@ -546,6 +554,18 @@ const generateForm = reactive({
|
||||
validity_days: 30
|
||||
})
|
||||
|
||||
// 监听类型变化,邀请码类型时自动设置 value 为 0
|
||||
watch(
|
||||
() => generateForm.type,
|
||||
(newType) => {
|
||||
if (newType === 'invitation') {
|
||||
generateForm.value = 0
|
||||
} else if (generateForm.value === 0) {
|
||||
generateForm.value = 10
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const loadCodes = async () => {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
|
||||
@@ -338,6 +338,62 @@
|
||||
</div>
|
||||
<Toggle v-model="form.promo_code_enabled" />
|
||||
</div>
|
||||
|
||||
<!-- Invitation Code -->
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
>
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t('admin.settings.registration.invitationCode')
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.registration.invitationCodeHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.invitation_code_enabled" />
|
||||
</div>
|
||||
|
||||
<!-- Password Reset - Only show when email verification is enabled -->
|
||||
<div
|
||||
v-if="form.email_verify_enabled"
|
||||
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
>
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t('admin.settings.registration.passwordReset')
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.registration.passwordResetHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.password_reset_enabled" />
|
||||
</div>
|
||||
|
||||
<!-- TOTP 2FA -->
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
>
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t('admin.settings.registration.totp')
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.registration.totpHint') }}
|
||||
</p>
|
||||
<!-- Warning when encryption key not configured -->
|
||||
<p
|
||||
v-if="!form.totp_encryption_key_configured"
|
||||
class="mt-2 text-sm text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
{{ t('admin.settings.registration.totpKeyNotConfigured') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
v-model="form.totp_enabled"
|
||||
:disabled="!form.totp_encryption_key_configured"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -894,6 +950,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Purchase Subscription Page -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.purchase.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.purchase.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-6 p-6">
|
||||
<!-- Enable Toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t('admin.settings.purchase.enabled')
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.purchase.enabledHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.purchase_subscription_enabled" />
|
||||
</div>
|
||||
|
||||
<!-- URL -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.purchase.url') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.purchase_subscription_url"
|
||||
type="url"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.purchase.urlPlaceholder')"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.purchase.urlHint') }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
{{ t('admin.settings.purchase.iframeWarning') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send Test Email - Only show when email verification is enabled -->
|
||||
<div v-if="form.email_verify_enabled" class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
@@ -1029,6 +1130,10 @@ const form = reactive<SettingsForm>({
|
||||
registration_enabled: true,
|
||||
email_verify_enabled: false,
|
||||
promo_code_enabled: true,
|
||||
invitation_code_enabled: false,
|
||||
password_reset_enabled: false,
|
||||
totp_enabled: false,
|
||||
totp_encryption_key_configured: false,
|
||||
default_balance: 0,
|
||||
default_concurrency: 1,
|
||||
site_name: 'Sub2API',
|
||||
@@ -1039,6 +1144,8 @@ const form = reactive<SettingsForm>({
|
||||
doc_url: '',
|
||||
home_content: '',
|
||||
hide_ccs_import_button: false,
|
||||
purchase_subscription_enabled: false,
|
||||
purchase_subscription_url: '',
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
smtp_username: '',
|
||||
@@ -1152,6 +1259,9 @@ async function saveSettings() {
|
||||
registration_enabled: form.registration_enabled,
|
||||
email_verify_enabled: form.email_verify_enabled,
|
||||
promo_code_enabled: form.promo_code_enabled,
|
||||
invitation_code_enabled: form.invitation_code_enabled,
|
||||
password_reset_enabled: form.password_reset_enabled,
|
||||
totp_enabled: form.totp_enabled,
|
||||
default_balance: form.default_balance,
|
||||
default_concurrency: form.default_concurrency,
|
||||
site_name: form.site_name,
|
||||
@@ -1162,6 +1272,8 @@ async function saveSettings() {
|
||||
doc_url: form.doc_url,
|
||||
home_content: form.home_content,
|
||||
hide_ccs_import_button: form.hide_ccs_import_button,
|
||||
purchase_subscription_enabled: form.purchase_subscription_enabled,
|
||||
purchase_subscription_url: form.purchase_subscription_url,
|
||||
smtp_host: form.smtp_host,
|
||||
smtp_port: form.smtp_port,
|
||||
smtp_username: form.smtp_username,
|
||||
|
||||
@@ -154,7 +154,13 @@
|
||||
|
||||
<!-- Subscriptions Table -->
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="subscriptions" :loading="loading">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="subscriptions"
|
||||
:loading="loading"
|
||||
:server-side-sort="true"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #cell-user="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
@@ -357,7 +363,7 @@
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-if="row.status === 'active'"
|
||||
v-if="row.status === 'active' || row.status === 'expired'"
|
||||
@click="handleExtend(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||
>
|
||||
@@ -683,9 +689,9 @@ const allColumns = computed<Column[]>(() => [
|
||||
label: userColumnMode.value === 'email'
|
||||
? t('admin.subscriptions.columns.user')
|
||||
: t('admin.users.columns.username'),
|
||||
sortable: true
|
||||
sortable: false
|
||||
},
|
||||
{ key: 'group', label: t('admin.subscriptions.columns.group'), sortable: true },
|
||||
{ key: 'group', label: t('admin.subscriptions.columns.group'), sortable: false },
|
||||
{ key: 'usage', label: t('admin.subscriptions.columns.usage'), sortable: false },
|
||||
{ key: 'expires_at', label: t('admin.subscriptions.columns.expires'), sortable: true },
|
||||
{ key: 'status', label: t('admin.subscriptions.columns.status'), sortable: true },
|
||||
@@ -785,10 +791,17 @@ const selectedUser = ref<SimpleUser | null>(null)
|
||||
let userSearchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const filters = reactive({
|
||||
status: '',
|
||||
status: 'active',
|
||||
group_id: '',
|
||||
user_id: null as number | null
|
||||
})
|
||||
|
||||
// Sorting state
|
||||
const sortState = reactive({
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc' as 'asc' | 'desc'
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
@@ -854,7 +867,9 @@ const loadSubscriptions = async () => {
|
||||
{
|
||||
status: (filters.status as any) || undefined,
|
||||
group_id: filters.group_id ? parseInt(filters.group_id) : undefined,
|
||||
user_id: filters.user_id || undefined
|
||||
user_id: filters.user_id || undefined,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
},
|
||||
{
|
||||
signal
|
||||
@@ -995,6 +1010,13 @@ const handlePageSizeChange = (pageSize: number) => {
|
||||
loadSubscriptions()
|
||||
}
|
||||
|
||||
const handleSort = (key: string, order: 'asc' | 'desc') => {
|
||||
sortState.sort_by = key
|
||||
sortState.sort_order = order
|
||||
pagination.page = 1
|
||||
loadSubscriptions()
|
||||
}
|
||||
|
||||
const closeAssignModal = () => {
|
||||
showAssignModal.value = false
|
||||
assignForm.user_id = null
|
||||
@@ -1053,11 +1075,11 @@ const closeExtendModal = () => {
|
||||
const handleExtendSubscription = async () => {
|
||||
if (!extendingSubscription.value) return
|
||||
|
||||
// 前端验证:调整后剩余天数必须 > 0
|
||||
// 前端验证:调整后的过期时间必须在未来
|
||||
if (extendingSubscription.value.expires_at) {
|
||||
const currentDaysRemaining = getDaysRemaining(extendingSubscription.value.expires_at) ?? 0
|
||||
const newDaysRemaining = currentDaysRemaining + extendForm.days
|
||||
if (newDaysRemaining <= 0) {
|
||||
const expiresAt = new Date(extendingSubscription.value.expires_at)
|
||||
const newExpiresAt = new Date(expiresAt.getTime() + extendForm.days * 24 * 60 * 60 * 1000)
|
||||
if (newExpiresAt <= new Date()) {
|
||||
appStore.showError(t('admin.subscriptions.adjustWouldExpire'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -35,12 +35,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { saveAs } from 'file-saver'
|
||||
import { useAppStore } from '@/stores/app'; import { adminAPI } from '@/api/admin'; import { adminUsageAPI } from '@/api/admin/usage'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'; import Pagination from '@/components/common/Pagination.vue'; import Select from '@/components/common/Select.vue'
|
||||
import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'; import UsageFilters from '@/components/admin/usage/UsageFilters.vue'
|
||||
import UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageExportProgress from '@/components/admin/usage/UsageExportProgress.vue'
|
||||
import UsageCleanupDialog from '@/components/admin/usage/UsageCleanupDialog.vue'
|
||||
import { saveAs } from 'file-saver'
|
||||
import { useAppStore } from '@/stores/app'; import { adminAPI } from '@/api/admin'; import { adminUsageAPI } from '@/api/admin/usage'
|
||||
import { formatReasoningEffort } from '@/utils/format'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'; import Pagination from '@/components/common/Pagination.vue'; import Select from '@/components/common/Select.vue'
|
||||
import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'; import UsageFilters from '@/components/admin/usage/UsageFilters.vue'
|
||||
import UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageExportProgress from '@/components/admin/usage/UsageExportProgress.vue'
|
||||
import UsageCleanupDialog from '@/components/admin/usage/UsageCleanupDialog.vue'
|
||||
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'; import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
|
||||
import type { AdminUsageLog, TrendDataPoint, ModelStat } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
|
||||
|
||||
@@ -104,7 +105,7 @@ const exportToExcel = async () => {
|
||||
const XLSX = await import('xlsx')
|
||||
const headers = [
|
||||
t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'),
|
||||
t('admin.usage.account'), t('usage.model'), t('admin.usage.group'),
|
||||
t('admin.usage.account'), t('usage.model'), t('usage.reasoningEffort'), t('admin.usage.group'),
|
||||
t('usage.type'),
|
||||
t('admin.usage.inputTokens'), t('admin.usage.outputTokens'),
|
||||
t('admin.usage.cacheReadTokens'), t('admin.usage.cacheCreationTokens'),
|
||||
@@ -120,6 +121,7 @@ const exportToExcel = async () => {
|
||||
log.api_key?.name || '',
|
||||
log.account?.name || '',
|
||||
log.model,
|
||||
formatReasoningEffort(log.reasoning_effort),
|
||||
log.group?.name || '',
|
||||
log.stream ? t('usage.stream') : t('usage.sync'),
|
||||
log.input_tokens,
|
||||
|
||||
@@ -300,8 +300,29 @@
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-balance="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span>
|
||||
<template #cell-balance="{ value, row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="group relative">
|
||||
<button
|
||||
class="font-medium text-gray-900 underline decoration-dashed decoration-gray-300 underline-offset-4 transition-colors hover:text-primary-600 dark:text-white dark:decoration-dark-500 dark:hover:text-primary-400"
|
||||
@click="handleBalanceHistory(row)"
|
||||
>
|
||||
${{ value.toFixed(2) }}
|
||||
</button>
|
||||
<!-- Instant tooltip -->
|
||||
<div class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-1.5 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-opacity duration-75 group-hover:opacity-100 dark:bg-dark-600">
|
||||
{{ t('admin.users.balanceHistoryTip') }}
|
||||
<div class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-dark-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click.stop="handleDeposit(row)"
|
||||
class="rounded px-2 py-0.5 text-xs font-medium text-emerald-600 transition-colors hover:bg-emerald-50 dark:text-emerald-400 dark:hover:bg-emerald-900/20"
|
||||
:title="t('admin.users.deposit')"
|
||||
>
|
||||
{{ t('admin.users.deposit') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-usage="{ row }">
|
||||
@@ -456,6 +477,15 @@
|
||||
{{ t('admin.users.withdraw') }}
|
||||
</button>
|
||||
|
||||
<!-- Balance History -->
|
||||
<button
|
||||
@click="handleBalanceHistory(user); closeActionMenu()"
|
||||
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
>
|
||||
<Icon name="dollar" size="sm" class="text-gray-400" :stroke-width="2" />
|
||||
{{ t('admin.users.balanceHistory') }}
|
||||
</button>
|
||||
|
||||
<div class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
|
||||
|
||||
<!-- Delete (not for admin) -->
|
||||
@@ -479,6 +509,7 @@
|
||||
<UserApiKeysModal :show="showApiKeysModal" :user="viewingUser" @close="closeApiKeysModal" />
|
||||
<UserAllowedGroupsModal :show="showAllowedGroupsModal" :user="allowedGroupsUser" @close="closeAllowedGroupsModal" @success="loadUsers" />
|
||||
<UserBalanceModal :show="showBalanceModal" :user="balanceUser" :operation="balanceOperation" @close="closeBalanceModal" @success="loadUsers" />
|
||||
<UserBalanceHistoryModal :show="showBalanceHistoryModal" :user="balanceHistoryUser" @close="closeBalanceHistoryModal" @deposit="handleDepositFromHistory" @withdraw="handleWithdrawFromHistory" />
|
||||
<UserAttributesConfigModal :show="showAttributesModal" @close="handleAttributesModalClose" />
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -509,6 +540,7 @@ import UserEditModal from '@/components/admin/user/UserEditModal.vue'
|
||||
import UserApiKeysModal from '@/components/admin/user/UserApiKeysModal.vue'
|
||||
import UserAllowedGroupsModal from '@/components/admin/user/UserAllowedGroupsModal.vue'
|
||||
import UserBalanceModal from '@/components/admin/user/UserBalanceModal.vue'
|
||||
import UserBalanceHistoryModal from '@/components/admin/user/UserBalanceHistoryModal.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
@@ -828,6 +860,10 @@ const showBalanceModal = ref(false)
|
||||
const balanceUser = ref<AdminUser | null>(null)
|
||||
const balanceOperation = ref<'add' | 'subtract'>('add')
|
||||
|
||||
// Balance History modal state
|
||||
const showBalanceHistoryModal = ref(false)
|
||||
const balanceHistoryUser = ref<AdminUser | null>(null)
|
||||
|
||||
// 计算剩余天数
|
||||
const getDaysRemaining = (expiresAt: string): number => {
|
||||
const now = new Date()
|
||||
@@ -1078,6 +1114,30 @@ const closeBalanceModal = () => {
|
||||
balanceUser.value = null
|
||||
}
|
||||
|
||||
const handleBalanceHistory = (user: AdminUser) => {
|
||||
balanceHistoryUser.value = user
|
||||
showBalanceHistoryModal.value = true
|
||||
}
|
||||
|
||||
const closeBalanceHistoryModal = () => {
|
||||
showBalanceHistoryModal.value = false
|
||||
balanceHistoryUser.value = null
|
||||
}
|
||||
|
||||
// Handle deposit from balance history modal
|
||||
const handleDepositFromHistory = () => {
|
||||
if (balanceHistoryUser.value) {
|
||||
handleDeposit(balanceHistoryUser.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle withdraw from balance history modal
|
||||
const handleWithdrawFromHistory = () => {
|
||||
if (balanceHistoryUser.value) {
|
||||
handleWithdraw(balanceHistoryUser.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动时关闭菜单
|
||||
const handleScroll = () => {
|
||||
closeActionMenu()
|
||||
|
||||
@@ -49,6 +49,7 @@ interface SummaryRow {
|
||||
total_accounts: number
|
||||
available_accounts: number
|
||||
rate_limited_accounts: number
|
||||
scope_rate_limit_count?: Record<string, number>
|
||||
error_accounts: number
|
||||
// 并发统计
|
||||
total_concurrency: number
|
||||
@@ -102,6 +103,7 @@ const platformRows = computed((): SummaryRow[] => {
|
||||
total_accounts: totalAccounts,
|
||||
available_accounts: availableAccounts,
|
||||
rate_limited_accounts: safeNumber(avail.rate_limit_count),
|
||||
scope_rate_limit_count: avail.scope_rate_limit_count,
|
||||
error_accounts: safeNumber(avail.error_count),
|
||||
total_concurrency: totalConcurrency,
|
||||
used_concurrency: usedConcurrency,
|
||||
@@ -141,6 +143,7 @@ const groupRows = computed((): SummaryRow[] => {
|
||||
total_accounts: totalAccounts,
|
||||
available_accounts: availableAccounts,
|
||||
rate_limited_accounts: safeNumber(avail.rate_limit_count),
|
||||
scope_rate_limit_count: avail.scope_rate_limit_count,
|
||||
error_accounts: safeNumber(avail.error_count),
|
||||
total_concurrency: totalConcurrency,
|
||||
used_concurrency: usedConcurrency,
|
||||
@@ -269,6 +272,15 @@ function formatDuration(seconds: number): string {
|
||||
return `${hours}h`
|
||||
}
|
||||
|
||||
function formatScopeName(scope: string): string {
|
||||
const names: Record<string, string> = {
|
||||
claude: 'Claude',
|
||||
gemini_text: 'Gemini',
|
||||
gemini_image: 'Image'
|
||||
}
|
||||
return names[scope] || scope
|
||||
}
|
||||
|
||||
watch(
|
||||
() => realtimeEnabled.value,
|
||||
async (enabled) => {
|
||||
@@ -387,6 +399,18 @@ watch(
|
||||
{{ t('admin.ops.concurrency.rateLimited', { count: row.rate_limited_accounts }) }}
|
||||
</span>
|
||||
|
||||
<!-- Scope 限流 (仅 Antigravity) -->
|
||||
<template v-if="row.scope_rate_limit_count && Object.keys(row.scope_rate_limit_count).length > 0">
|
||||
<span
|
||||
v-for="(count, scope) in row.scope_rate_limit_count"
|
||||
:key="scope"
|
||||
class="rounded-full bg-orange-100 px-1.5 py-0.5 font-semibold text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
|
||||
:title="t('admin.ops.concurrency.scopeRateLimitedTooltip', { scope, count })"
|
||||
>
|
||||
{{ formatScopeName(scope as string) }} {{ count }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- 异常账号 -->
|
||||
<span
|
||||
v-if="row.error_accounts > 0"
|
||||
|
||||
@@ -505,6 +505,16 @@ async function saveAllSettings() {
|
||||
</div>
|
||||
<Toggle v-model="advancedSettings.ignore_no_available_accounts" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.ignoreInvalidApiKeyErrors') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
{{ t('admin.ops.settings.ignoreInvalidApiKeyErrorsHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="advancedSettings.ignore_invalid_api_key_errors" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto Refresh -->
|
||||
|
||||
@@ -201,6 +201,7 @@ const email = ref<string>('')
|
||||
const password = ref<string>('')
|
||||
const initialTurnstileToken = ref<string>('')
|
||||
const promoCode = ref<string>('')
|
||||
const invitationCode = ref<string>('')
|
||||
const hasRegisterData = ref<boolean>(false)
|
||||
|
||||
// Public settings
|
||||
@@ -230,6 +231,7 @@ onMounted(async () => {
|
||||
password.value = registerData.password || ''
|
||||
initialTurnstileToken.value = registerData.turnstile_token || ''
|
||||
promoCode.value = registerData.promo_code || ''
|
||||
invitationCode.value = registerData.invitation_code || ''
|
||||
hasRegisterData.value = !!(email.value && password.value)
|
||||
} catch {
|
||||
hasRegisterData.value = false
|
||||
@@ -384,7 +386,8 @@ async function handleVerify(): Promise<void> {
|
||||
password: password.value,
|
||||
verify_code: verifyCode.value.trim(),
|
||||
turnstile_token: initialTurnstileToken.value || undefined,
|
||||
promo_code: promoCode.value || undefined
|
||||
promo_code: promoCode.value || undefined,
|
||||
invitation_code: invitationCode.value || undefined
|
||||
})
|
||||
|
||||
// Clear session data
|
||||
|
||||
297
frontend/src/views/auth/ForgotPasswordView.vue
Normal file
297
frontend/src/views/auth/ForgotPasswordView.vue
Normal file
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Title -->
|
||||
<div class="text-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ t('auth.forgotPasswordTitle') }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.forgotPasswordHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Success State -->
|
||||
<div v-if="isSubmitted" class="space-y-6">
|
||||
<div class="rounded-xl border border-green-200 bg-green-50 p-6 dark:border-green-800/50 dark:bg-green-900/20">
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-800/50">
|
||||
<Icon name="checkCircle" size="lg" class="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-green-800 dark:text-green-200">
|
||||
{{ t('auth.resetEmailSent') }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-green-700 dark:text-green-300">
|
||||
{{ t('auth.resetEmailSentHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<router-link
|
||||
to="/login"
|
||||
class="inline-flex items-center gap-2 font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
<Icon name="arrowLeft" size="sm" />
|
||||
{{ t('auth.backToLogin') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form State -->
|
||||
<form v-else @submit.prevent="handleSubmit" class="space-y-5">
|
||||
<!-- Email Input -->
|
||||
<div>
|
||||
<label for="email" class="input-label">
|
||||
{{ t('auth.emailLabel') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
|
||||
<Icon name="mail" size="md" class="text-gray-400 dark:text-dark-500" />
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
v-model="formData.email"
|
||||
type="email"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="email"
|
||||
:disabled="isLoading"
|
||||
class="input pl-11"
|
||||
:class="{ 'input-error': errors.email }"
|
||||
:placeholder="t('auth.emailPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="errors.email" class="input-error-text">
|
||||
{{ errors.email }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Widget -->
|
||||
<div v-if="turnstileEnabled && turnstileSiteKey">
|
||||
<TurnstileWidget
|
||||
ref="turnstileRef"
|
||||
:site-key="turnstileSiteKey"
|
||||
@verify="onTurnstileVerify"
|
||||
@expire="onTurnstileExpire"
|
||||
@error="onTurnstileError"
|
||||
/>
|
||||
<p v-if="errors.turnstile" class="input-error-text mt-2 text-center">
|
||||
{{ errors.turnstile }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<Icon name="exclamationCircle" size="md" class="text-red-500" />
|
||||
</div>
|
||||
<p class="text-sm text-red-700 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isLoading || (turnstileEnabled && !turnstileToken)"
|
||||
class="btn btn-primary w-full"
|
||||
>
|
||||
<svg
|
||||
v-if="isLoading"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<Icon v-else name="mail" size="md" class="mr-2" />
|
||||
{{ isLoading ? t('auth.sendingResetLink') : t('auth.sendResetLink') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<template #footer>
|
||||
<p class="text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.rememberedPassword') }}
|
||||
<router-link
|
||||
to="/login"
|
||||
class="font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
{{ t('auth.signIn') }}
|
||||
</router-link>
|
||||
</p>
|
||||
</template>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { getPublicSettings, forgotPassword } from '@/api/auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// ==================== Stores ====================
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
// ==================== State ====================
|
||||
|
||||
const isLoading = ref<boolean>(false)
|
||||
const isSubmitted = ref<boolean>(false)
|
||||
const errorMessage = ref<string>('')
|
||||
|
||||
// Public settings
|
||||
const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
|
||||
// Turnstile
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const turnstileToken = ref<string>('')
|
||||
|
||||
const formData = reactive({
|
||||
email: ''
|
||||
})
|
||||
|
||||
const errors = reactive({
|
||||
email: '',
|
||||
turnstile: ''
|
||||
})
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const settings = await getPublicSettings()
|
||||
turnstileEnabled.value = settings.turnstile_enabled
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== Turnstile Handlers ====================
|
||||
|
||||
function onTurnstileVerify(token: string): void {
|
||||
turnstileToken.value = token
|
||||
errors.turnstile = ''
|
||||
}
|
||||
|
||||
function onTurnstileExpire(): void {
|
||||
turnstileToken.value = ''
|
||||
errors.turnstile = t('auth.turnstileExpired')
|
||||
}
|
||||
|
||||
function onTurnstileError(): void {
|
||||
turnstileToken.value = ''
|
||||
errors.turnstile = t('auth.turnstileFailed')
|
||||
}
|
||||
|
||||
// ==================== Validation ====================
|
||||
|
||||
function validateForm(): boolean {
|
||||
errors.email = ''
|
||||
errors.turnstile = ''
|
||||
|
||||
let isValid = true
|
||||
|
||||
// Email validation
|
||||
if (!formData.email.trim()) {
|
||||
errors.email = t('auth.emailRequired')
|
||||
isValid = false
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
errors.email = t('auth.invalidEmail')
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Turnstile validation
|
||||
if (turnstileEnabled.value && !turnstileToken.value) {
|
||||
errors.turnstile = t('auth.completeVerification')
|
||||
isValid = false
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
// ==================== Form Handlers ====================
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
errorMessage.value = ''
|
||||
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
await forgotPassword({
|
||||
email: formData.email,
|
||||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined
|
||||
})
|
||||
|
||||
isSubmitted.value = true
|
||||
appStore.showSuccess(t('auth.resetEmailSent'))
|
||||
} catch (error: unknown) {
|
||||
// Reset Turnstile on error
|
||||
if (turnstileRef.value) {
|
||||
turnstileRef.value.reset()
|
||||
turnstileToken.value = ''
|
||||
}
|
||||
|
||||
const err = error as { message?: string; response?: { data?: { detail?: string } } }
|
||||
|
||||
if (err.response?.data?.detail) {
|
||||
errorMessage.value = err.response.data.detail
|
||||
} else if (err.message) {
|
||||
errorMessage.value = err.message
|
||||
} else {
|
||||
errorMessage.value = t('auth.sendResetLinkFailed')
|
||||
}
|
||||
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
@@ -72,9 +72,19 @@
|
||||
<Icon v-else name="eye" size="md" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="errors.password" class="input-error-text">
|
||||
{{ errors.password }}
|
||||
</p>
|
||||
<div class="mt-1 flex items-center justify-between">
|
||||
<p v-if="errors.password" class="input-error-text">
|
||||
{{ errors.password }}
|
||||
</p>
|
||||
<span v-else></span>
|
||||
<router-link
|
||||
v-if="passwordResetEnabled"
|
||||
to="/forgot-password"
|
||||
class="text-sm font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
{{ t('auth.forgotPassword') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Widget -->
|
||||
@@ -153,6 +163,16 @@
|
||||
</p>
|
||||
</template>
|
||||
</AuthLayout>
|
||||
|
||||
<!-- 2FA Modal -->
|
||||
<TotpLoginModal
|
||||
v-if="show2FAModal"
|
||||
ref="totpModalRef"
|
||||
:temp-token="totpTempToken"
|
||||
:user-email-masked="totpUserEmailMasked"
|
||||
@verify="handle2FAVerify"
|
||||
@cancel="handle2FACancel"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -161,10 +181,12 @@ import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
||||
import TotpLoginModal from '@/components/auth/TotpLoginModal.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import { getPublicSettings } from '@/api/auth'
|
||||
import { getPublicSettings, isTotp2FARequired } from '@/api/auth'
|
||||
import type { TotpLoginResponse } from '@/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -184,11 +206,18 @@ const showPassword = ref<boolean>(false)
|
||||
const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const linuxdoOAuthEnabled = ref<boolean>(false)
|
||||
const passwordResetEnabled = ref<boolean>(false)
|
||||
|
||||
// Turnstile
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const turnstileToken = ref<string>('')
|
||||
|
||||
// 2FA state
|
||||
const show2FAModal = ref<boolean>(false)
|
||||
const totpTempToken = ref<string>('')
|
||||
const totpUserEmailMasked = ref<string>('')
|
||||
const totpModalRef = ref<InstanceType<typeof TotpLoginModal> | null>(null)
|
||||
|
||||
const formData = reactive({
|
||||
email: '',
|
||||
password: ''
|
||||
@@ -216,6 +245,7 @@ onMounted(async () => {
|
||||
turnstileEnabled.value = settings.turnstile_enabled
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
|
||||
passwordResetEnabled.value = settings.password_reset_enabled
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error)
|
||||
}
|
||||
@@ -290,12 +320,22 @@ async function handleLogin(): Promise<void> {
|
||||
|
||||
try {
|
||||
// Call auth store login
|
||||
await authStore.login({
|
||||
const response = await authStore.login({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined
|
||||
})
|
||||
|
||||
// Check if 2FA is required
|
||||
if (isTotp2FARequired(response)) {
|
||||
const totpResponse = response as TotpLoginResponse
|
||||
totpTempToken.value = totpResponse.temp_token || ''
|
||||
totpUserEmailMasked.value = totpResponse.user_email_masked || ''
|
||||
show2FAModal.value = true
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Show success toast
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
|
||||
@@ -326,6 +366,40 @@ async function handleLogin(): Promise<void> {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 2FA Handlers ====================
|
||||
|
||||
async function handle2FAVerify(code: string): Promise<void> {
|
||||
if (totpModalRef.value) {
|
||||
totpModalRef.value.setVerifying(true)
|
||||
}
|
||||
|
||||
try {
|
||||
await authStore.login2FA(totpTempToken.value, code)
|
||||
|
||||
// Close modal and show success
|
||||
show2FAModal.value = false
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
|
||||
// Redirect to dashboard or intended route
|
||||
const redirectTo = (router.currentRoute.value.query.redirect as string) || '/dashboard'
|
||||
await router.push(redirectTo)
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string; response?: { data?: { message?: string } } }
|
||||
const message = err.response?.data?.message || err.message || t('profile.totp.loginFailed')
|
||||
|
||||
if (totpModalRef.value) {
|
||||
totpModalRef.value.setError(message)
|
||||
totpModalRef.value.setVerifying(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handle2FACancel(): void {
|
||||
show2FAModal.value = false
|
||||
totpTempToken.value = ''
|
||||
totpUserEmailMasked.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -95,6 +95,59 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Invitation Code Input (Required when enabled) -->
|
||||
<div v-if="invitationCodeEnabled">
|
||||
<label for="invitation_code" class="input-label">
|
||||
{{ t('auth.invitationCodeLabel') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
|
||||
<Icon name="key" size="md" :class="invitationValidation.valid ? 'text-green-500' : 'text-gray-400 dark:text-dark-500'" />
|
||||
</div>
|
||||
<input
|
||||
id="invitation_code"
|
||||
v-model="formData.invitation_code"
|
||||
type="text"
|
||||
:disabled="isLoading"
|
||||
class="input pl-11 pr-10"
|
||||
:class="{
|
||||
'border-green-500 focus:border-green-500 focus:ring-green-500': invitationValidation.valid,
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-500': invitationValidation.invalid || errors.invitation_code
|
||||
}"
|
||||
:placeholder="t('auth.invitationCodePlaceholder')"
|
||||
@input="handleInvitationCodeInput"
|
||||
/>
|
||||
<!-- Validation indicator -->
|
||||
<div v-if="invitationValidating" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
|
||||
<svg class="h-4 w-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div v-else-if="invitationValidation.valid" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
|
||||
<Icon name="checkCircle" size="md" class="text-green-500" />
|
||||
</div>
|
||||
<div v-else-if="invitationValidation.invalid || errors.invitation_code" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
|
||||
<Icon name="exclamationCircle" size="md" class="text-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Invitation code validation result -->
|
||||
<transition name="fade">
|
||||
<div v-if="invitationValidation.valid" class="mt-2 flex items-center gap-2 rounded-lg bg-green-50 px-3 py-2 dark:bg-green-900/20">
|
||||
<Icon name="checkCircle" size="sm" class="text-green-600 dark:text-green-400" />
|
||||
<span class="text-sm text-green-700 dark:text-green-400">
|
||||
{{ t('auth.invitationCodeValid') }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-else-if="invitationValidation.invalid" class="input-error-text">
|
||||
{{ invitationValidation.message }}
|
||||
</p>
|
||||
<p v-else-if="errors.invitation_code" class="input-error-text">
|
||||
{{ errors.invitation_code }}
|
||||
</p>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- Promo Code Input (Optional) -->
|
||||
<div v-if="promoCodeEnabled">
|
||||
<label for="promo_code" class="input-label">
|
||||
@@ -239,7 +292,7 @@ import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import { getPublicSettings, validatePromoCode } from '@/api/auth'
|
||||
import { getPublicSettings, validatePromoCode, validateInvitationCode } from '@/api/auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -261,6 +314,7 @@ const showPassword = ref<boolean>(false)
|
||||
const registrationEnabled = ref<boolean>(true)
|
||||
const emailVerifyEnabled = ref<boolean>(false)
|
||||
const promoCodeEnabled = ref<boolean>(true)
|
||||
const invitationCodeEnabled = ref<boolean>(false)
|
||||
const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const siteName = ref<string>('Sub2API')
|
||||
@@ -280,16 +334,27 @@ const promoValidation = reactive({
|
||||
})
|
||||
let promoValidateTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// Invitation code validation
|
||||
const invitationValidating = ref<boolean>(false)
|
||||
const invitationValidation = reactive({
|
||||
valid: false,
|
||||
invalid: false,
|
||||
message: ''
|
||||
})
|
||||
let invitationValidateTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const formData = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
promo_code: ''
|
||||
promo_code: '',
|
||||
invitation_code: ''
|
||||
})
|
||||
|
||||
const errors = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
turnstile: ''
|
||||
turnstile: '',
|
||||
invitation_code: ''
|
||||
})
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
@@ -300,6 +365,7 @@ onMounted(async () => {
|
||||
registrationEnabled.value = settings.registration_enabled
|
||||
emailVerifyEnabled.value = settings.email_verify_enabled
|
||||
promoCodeEnabled.value = settings.promo_code_enabled
|
||||
invitationCodeEnabled.value = settings.invitation_code_enabled
|
||||
turnstileEnabled.value = settings.turnstile_enabled
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
siteName.value = settings.site_name || 'Sub2API'
|
||||
@@ -325,6 +391,9 @@ onUnmounted(() => {
|
||||
if (promoValidateTimeout) {
|
||||
clearTimeout(promoValidateTimeout)
|
||||
}
|
||||
if (invitationValidateTimeout) {
|
||||
clearTimeout(invitationValidateTimeout)
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== Promo Code Validation ====================
|
||||
@@ -400,6 +469,70 @@ function getPromoErrorMessage(errorCode?: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Invitation Code Validation ====================
|
||||
|
||||
function handleInvitationCodeInput(): void {
|
||||
const code = formData.invitation_code.trim()
|
||||
|
||||
// Clear previous validation
|
||||
invitationValidation.valid = false
|
||||
invitationValidation.invalid = false
|
||||
invitationValidation.message = ''
|
||||
errors.invitation_code = ''
|
||||
|
||||
if (!code) {
|
||||
return
|
||||
}
|
||||
|
||||
// Debounce validation
|
||||
if (invitationValidateTimeout) {
|
||||
clearTimeout(invitationValidateTimeout)
|
||||
}
|
||||
|
||||
invitationValidateTimeout = setTimeout(() => {
|
||||
validateInvitationCodeDebounced(code)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
async function validateInvitationCodeDebounced(code: string): Promise<void> {
|
||||
invitationValidating.value = true
|
||||
|
||||
try {
|
||||
const result = await validateInvitationCode(code)
|
||||
|
||||
if (result.valid) {
|
||||
invitationValidation.valid = true
|
||||
invitationValidation.invalid = false
|
||||
invitationValidation.message = ''
|
||||
} else {
|
||||
invitationValidation.valid = false
|
||||
invitationValidation.invalid = true
|
||||
invitationValidation.message = getInvitationErrorMessage(result.error_code)
|
||||
}
|
||||
} catch {
|
||||
invitationValidation.valid = false
|
||||
invitationValidation.invalid = true
|
||||
invitationValidation.message = t('auth.invitationCodeInvalid')
|
||||
} finally {
|
||||
invitationValidating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getInvitationErrorMessage(errorCode?: string): string {
|
||||
switch (errorCode) {
|
||||
case 'INVITATION_CODE_NOT_FOUND':
|
||||
return t('auth.invitationCodeInvalid')
|
||||
case 'INVITATION_CODE_INVALID':
|
||||
return t('auth.invitationCodeInvalid')
|
||||
case 'INVITATION_CODE_USED':
|
||||
return t('auth.invitationCodeInvalid')
|
||||
case 'INVITATION_CODE_DISABLED':
|
||||
return t('auth.invitationCodeInvalid')
|
||||
default:
|
||||
return t('auth.invitationCodeInvalid')
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Turnstile Handlers ====================
|
||||
|
||||
function onTurnstileVerify(token: string): void {
|
||||
@@ -429,6 +562,7 @@ function validateForm(): boolean {
|
||||
errors.email = ''
|
||||
errors.password = ''
|
||||
errors.turnstile = ''
|
||||
errors.invitation_code = ''
|
||||
|
||||
let isValid = true
|
||||
|
||||
@@ -450,6 +584,14 @@ function validateForm(): boolean {
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Invitation code validation (required when enabled)
|
||||
if (invitationCodeEnabled.value) {
|
||||
if (!formData.invitation_code.trim()) {
|
||||
errors.invitation_code = t('auth.invitationCodeRequired')
|
||||
isValid = false
|
||||
}
|
||||
}
|
||||
|
||||
// Turnstile validation
|
||||
if (turnstileEnabled.value && !turnstileToken.value) {
|
||||
errors.turnstile = t('auth.completeVerification')
|
||||
@@ -484,6 +626,30 @@ async function handleRegister(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Check invitation code validation status (if enabled and code provided)
|
||||
if (invitationCodeEnabled.value) {
|
||||
// If still validating, wait
|
||||
if (invitationValidating.value) {
|
||||
errorMessage.value = t('auth.invitationCodeValidating')
|
||||
return
|
||||
}
|
||||
// If invitation code is invalid, block submission
|
||||
if (invitationValidation.invalid) {
|
||||
errorMessage.value = t('auth.invitationCodeInvalidCannotRegister')
|
||||
return
|
||||
}
|
||||
// If invitation code is required but not validated yet
|
||||
if (formData.invitation_code.trim() && !invitationValidation.valid) {
|
||||
errorMessage.value = t('auth.invitationCodeValidating')
|
||||
// Trigger validation
|
||||
await validateInvitationCodeDebounced(formData.invitation_code.trim())
|
||||
if (!invitationValidation.valid) {
|
||||
errorMessage.value = t('auth.invitationCodeInvalidCannotRegister')
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
@@ -496,7 +662,8 @@ async function handleRegister(): Promise<void> {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
turnstile_token: turnstileToken.value,
|
||||
promo_code: formData.promo_code || undefined
|
||||
promo_code: formData.promo_code || undefined,
|
||||
invitation_code: formData.invitation_code || undefined
|
||||
})
|
||||
)
|
||||
|
||||
@@ -510,7 +677,8 @@ async function handleRegister(): Promise<void> {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
|
||||
promo_code: formData.promo_code || undefined
|
||||
promo_code: formData.promo_code || undefined,
|
||||
invitation_code: formData.invitation_code || undefined
|
||||
})
|
||||
|
||||
// Show success toast
|
||||
|
||||
355
frontend/src/views/auth/ResetPasswordView.vue
Normal file
355
frontend/src/views/auth/ResetPasswordView.vue
Normal file
@@ -0,0 +1,355 @@
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Title -->
|
||||
<div class="text-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ t('auth.resetPasswordTitle') }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.resetPasswordHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Invalid Link State -->
|
||||
<div v-if="isInvalidLink" class="space-y-6">
|
||||
<div class="rounded-xl border border-red-200 bg-red-50 p-6 dark:border-red-800/50 dark:bg-red-900/20">
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-800/50">
|
||||
<Icon name="exclamationCircle" size="lg" class="text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-red-800 dark:text-red-200">
|
||||
{{ t('auth.invalidResetLink') }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-red-700 dark:text-red-300">
|
||||
{{ t('auth.invalidResetLinkHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<router-link
|
||||
to="/forgot-password"
|
||||
class="inline-flex items-center gap-2 font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
{{ t('auth.requestNewResetLink') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success State -->
|
||||
<div v-else-if="isSuccess" class="space-y-6">
|
||||
<div class="rounded-xl border border-green-200 bg-green-50 p-6 dark:border-green-800/50 dark:bg-green-900/20">
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-800/50">
|
||||
<Icon name="checkCircle" size="lg" class="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-green-800 dark:text-green-200">
|
||||
{{ t('auth.passwordResetSuccess') }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-green-700 dark:text-green-300">
|
||||
{{ t('auth.passwordResetSuccessHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<router-link
|
||||
to="/login"
|
||||
class="btn btn-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<Icon name="login" size="md" />
|
||||
{{ t('auth.signIn') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form State -->
|
||||
<form v-else @submit.prevent="handleSubmit" class="space-y-5">
|
||||
<!-- Email (readonly) -->
|
||||
<div>
|
||||
<label for="email" class="input-label">
|
||||
{{ t('auth.emailLabel') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
|
||||
<Icon name="mail" size="md" class="text-gray-400 dark:text-dark-500" />
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
:value="email"
|
||||
type="email"
|
||||
readonly
|
||||
disabled
|
||||
class="input pl-11 bg-gray-50 dark:bg-dark-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Password Input -->
|
||||
<div>
|
||||
<label for="password" class="input-label">
|
||||
{{ t('auth.newPassword') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
|
||||
<Icon name="lock" size="md" class="text-gray-400 dark:text-dark-500" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
v-model="formData.password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
:disabled="isLoading"
|
||||
class="input pl-11 pr-11"
|
||||
:class="{ 'input-error': errors.password }"
|
||||
:placeholder="t('auth.newPasswordPlaceholder')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3.5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-dark-300"
|
||||
>
|
||||
<Icon v-if="showPassword" name="eyeOff" size="md" />
|
||||
<Icon v-else name="eye" size="md" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="errors.password" class="input-error-text">
|
||||
{{ errors.password }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password Input -->
|
||||
<div>
|
||||
<label for="confirmPassword" class="input-label">
|
||||
{{ t('auth.confirmPassword') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
|
||||
<Icon name="lock" size="md" class="text-gray-400 dark:text-dark-500" />
|
||||
</div>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
v-model="formData.confirmPassword"
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
:disabled="isLoading"
|
||||
class="input pl-11 pr-11"
|
||||
:class="{ 'input-error': errors.confirmPassword }"
|
||||
:placeholder="t('auth.confirmPasswordPlaceholder')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showConfirmPassword = !showConfirmPassword"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3.5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-dark-300"
|
||||
>
|
||||
<Icon v-if="showConfirmPassword" name="eyeOff" size="md" />
|
||||
<Icon v-else name="eye" size="md" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="errors.confirmPassword" class="input-error-text">
|
||||
{{ errors.confirmPassword }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<Icon name="exclamationCircle" size="md" class="text-red-500" />
|
||||
</div>
|
||||
<p class="text-sm text-red-700 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isLoading"
|
||||
class="btn btn-primary w-full"
|
||||
>
|
||||
<svg
|
||||
v-if="isLoading"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<Icon v-else name="checkCircle" size="md" class="mr-2" />
|
||||
{{ isLoading ? t('auth.resettingPassword') : t('auth.resetPassword') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<template #footer>
|
||||
<p class="text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.rememberedPassword') }}
|
||||
<router-link
|
||||
to="/login"
|
||||
class="font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
{{ t('auth.signIn') }}
|
||||
</router-link>
|
||||
</p>
|
||||
</template>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { resetPassword } from '@/api/auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// ==================== Router & Stores ====================
|
||||
|
||||
const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// ==================== State ====================
|
||||
|
||||
const isLoading = ref<boolean>(false)
|
||||
const isSuccess = ref<boolean>(false)
|
||||
const errorMessage = ref<string>('')
|
||||
const showPassword = ref<boolean>(false)
|
||||
const showConfirmPassword = ref<boolean>(false)
|
||||
|
||||
// URL parameters
|
||||
const email = ref<string>('')
|
||||
const token = ref<string>('')
|
||||
|
||||
const formData = reactive({
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const errors = reactive({
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
// Check if the reset link is valid (has email and token)
|
||||
const isInvalidLink = computed(() => !email.value || !token.value)
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
onMounted(() => {
|
||||
// Get email and token from URL query parameters
|
||||
email.value = (route.query.email as string) || ''
|
||||
token.value = (route.query.token as string) || ''
|
||||
})
|
||||
|
||||
// ==================== Validation ====================
|
||||
|
||||
function validateForm(): boolean {
|
||||
errors.password = ''
|
||||
errors.confirmPassword = ''
|
||||
|
||||
let isValid = true
|
||||
|
||||
// Password validation
|
||||
if (!formData.password) {
|
||||
errors.password = t('auth.passwordRequired')
|
||||
isValid = false
|
||||
} else if (formData.password.length < 6) {
|
||||
errors.password = t('auth.passwordMinLength')
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Confirm password validation
|
||||
if (!formData.confirmPassword) {
|
||||
errors.confirmPassword = t('auth.confirmPasswordRequired')
|
||||
isValid = false
|
||||
} else if (formData.password !== formData.confirmPassword) {
|
||||
errors.confirmPassword = t('auth.passwordsDoNotMatch')
|
||||
isValid = false
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
// ==================== Form Handlers ====================
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
errorMessage.value = ''
|
||||
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
await resetPassword({
|
||||
email: email.value,
|
||||
token: token.value,
|
||||
new_password: formData.password
|
||||
})
|
||||
|
||||
isSuccess.value = true
|
||||
appStore.showSuccess(t('auth.passwordResetSuccess'))
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string; response?: { data?: { detail?: string; code?: string } } }
|
||||
|
||||
// Check for invalid/expired token error
|
||||
if (err.response?.data?.code === 'INVALID_RESET_TOKEN') {
|
||||
errorMessage.value = t('auth.invalidOrExpiredToken')
|
||||
} else if (err.response?.data?.detail) {
|
||||
errorMessage.value = err.response.data.detail
|
||||
} else if (err.message) {
|
||||
errorMessage.value = err.message
|
||||
} else {
|
||||
errorMessage.value = t('auth.resetPasswordFailed')
|
||||
}
|
||||
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
@@ -225,6 +225,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-xl border border-gray-200 p-3 dark:border-dark-700">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t("setup.redis.enableTls") }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ t("setup.redis.enableTlsHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="formData.redis.enable_tls" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="testRedisConnection"
|
||||
:disabled="testingRedis"
|
||||
@@ -470,6 +482,7 @@ import { ref, reactive, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { testDatabase, testRedis, install, type InstallRequest } from '@/api/setup'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Toggle from '@/components/common/Toggle.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -517,7 +530,8 @@ const formData = reactive<InstallRequest>({
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
password: '',
|
||||
db: 0
|
||||
db: 0,
|
||||
enable_tls: false
|
||||
},
|
||||
admin: {
|
||||
email: '',
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
</div>
|
||||
<ProfileEditForm :initial-username="user?.username || ''" />
|
||||
<ProfilePasswordForm />
|
||||
<ProfileTotpCard />
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -27,6 +28,7 @@ import StatCard from '@/components/common/StatCard.vue'
|
||||
import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue'
|
||||
import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue'
|
||||
import ProfilePasswordForm from '@/components/user/profile/ProfilePasswordForm.vue'
|
||||
import ProfileTotpCard from '@/components/user/profile/ProfileTotpCard.vue'
|
||||
import { Icon } from '@/components/icons'
|
||||
|
||||
const { t } = useI18n(); const authStore = useAuthStore(); const user = computed(() => authStore.user)
|
||||
|
||||
121
frontend/src/views/user/PurchaseSubscriptionView.vue
Normal file
121
frontend/src/views/user/PurchaseSubscriptionView.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="purchase-page-layout">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('purchase.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ t('purchase.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
v-if="isValidUrl"
|
||||
:href="purchaseUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
<Icon name="externalLink" size="sm" class="mr-1.5" :stroke-width="2" />
|
||||
{{ t('purchase.openInNewTab') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card flex-1 min-h-0 overflow-hidden">
|
||||
<div v-if="loading" class="flex h-full items-center justify-center py-12">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!purchaseEnabled"
|
||||
class="flex h-full items-center justify-center p-10 text-center"
|
||||
>
|
||||
<div class="max-w-md">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"
|
||||
>
|
||||
<Icon name="creditCard" size="lg" class="text-gray-400" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('purchase.notEnabledTitle') }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ t('purchase.notEnabledDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!isValidUrl"
|
||||
class="flex h-full items-center justify-center p-10 text-center"
|
||||
>
|
||||
<div class="max-w-md">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"
|
||||
>
|
||||
<Icon name="link" size="lg" class="text-gray-400" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('purchase.notConfiguredTitle') }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ t('purchase.notConfiguredDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<iframe v-else :src="purchaseUrl" class="h-full w-full border-0" allowfullscreen></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const purchaseEnabled = computed(() => {
|
||||
return appStore.cachedPublicSettings?.purchase_subscription_enabled ?? false
|
||||
})
|
||||
|
||||
const purchaseUrl = computed(() => {
|
||||
return (appStore.cachedPublicSettings?.purchase_subscription_url || '').trim()
|
||||
})
|
||||
|
||||
const isValidUrl = computed(() => {
|
||||
const url = purchaseUrl.value
|
||||
return url.startsWith('http://') || url.startsWith('https://')
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (appStore.publicSettingsLoaded) return
|
||||
loading.value = true
|
||||
try {
|
||||
await appStore.fetchPublicSettings()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.purchase-page-layout {
|
||||
@apply flex flex-col gap-6;
|
||||
height: calc(100vh - 64px - 4rem); /* 减去 header + lg:p-8 的上下padding */
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -312,6 +312,14 @@
|
||||
<p v-else class="text-xs text-gray-400 dark:text-dark-500">
|
||||
{{ t('redeem.adminAdjustment') }}
|
||||
</p>
|
||||
<!-- Display notes for admin adjustments -->
|
||||
<p
|
||||
v-if="item.notes"
|
||||
class="mt-1 text-xs text-gray-500 dark:text-dark-400 italic max-w-[200px] truncate"
|
||||
:title="item.notes"
|
||||
>
|
||||
{{ item.notes }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -157,6 +157,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-stream="{ row }">
|
||||
<span
|
||||
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
|
||||
@@ -438,12 +444,12 @@ import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -466,6 +472,7 @@ const usageStats = ref<UsageStatsResponse | null>(null)
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
|
||||
{ key: 'model', label: t('usage.model'), sortable: true },
|
||||
{ key: 'reasoning_effort', label: t('usage.reasoningEffort'), sortable: false },
|
||||
{ key: 'stream', label: t('usage.type'), sortable: false },
|
||||
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
|
||||
{ key: 'cost', label: t('usage.cost'), sortable: false },
|
||||
@@ -723,6 +730,7 @@ const exportToCSV = async () => {
|
||||
'Time',
|
||||
'API Key Name',
|
||||
'Model',
|
||||
'Reasoning Effort',
|
||||
'Type',
|
||||
'Input Tokens',
|
||||
'Output Tokens',
|
||||
@@ -739,6 +747,7 @@ const exportToCSV = async () => {
|
||||
log.created_at,
|
||||
log.api_key?.name || '',
|
||||
log.model,
|
||||
formatReasoningEffort(log.reasoning_effort),
|
||||
log.stream ? 'Stream' : 'Sync',
|
||||
log.input_tokens,
|
||||
log.output_tokens,
|
||||
|
||||
Reference in New Issue
Block a user