Merge branch 'main' into test

This commit is contained in:
yangjianbo
2026-02-03 22:48:04 +08:00
235 changed files with 25155 additions and 7955 deletions

View 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

View File

@@ -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'

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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'

View File

@@ -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?: {

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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 = () => {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 },

View 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>&nbsp;</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>

View 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>

View 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>

View File

@@ -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,

View File

@@ -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

View File

@@ -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',

View File

@@ -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()

View File

@@ -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 },

View 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>

View 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>

View 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>

View File

@@ -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',

View File

@@ -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 或 CSPframe-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: '我的订阅',

View File

@@ -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',

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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}`
}

View 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>

View File

@@ -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 () => {

View File

@@ -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()

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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()

View File

@@ -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"

View File

@@ -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 -->

View File

@@ -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

View 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>

View File

@@ -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>

View File

@@ -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

View 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>

View File

@@ -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: '',

View File

@@ -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)

View 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>

View File

@@ -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>

View File

@@ -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,