Commit a04ae28a authored by 陈曦's avatar 陈曦
Browse files

merge v0.1.111

parents 68f67198 ad64190b
const STORAGE_KEY = 'table-page-size' import { getConfiguredTableDefaultPageSize, normalizeTablePageSize } from '@/utils/tablePreferences'
const DEFAULT_PAGE_SIZE = 20
/** /**
* 从 localStorage 读取/写入 pageSize * 读取当前系统配置的表格默认每页条数。
* 全局共享一个 key,所有表格统一偏好 * 不再使用本地持久化缓存,所有页面统一以通用表格设置为准。
*/ */
export function getPersistedPageSize(fallback = DEFAULT_PAGE_SIZE): number { export function getPersistedPageSize(fallback = getConfiguredTableDefaultPageSize()): number {
try { return normalizeTablePageSize(getConfiguredTableDefaultPageSize() || fallback)
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const parsed = Number(stored)
if (Number.isFinite(parsed) && parsed > 0) return parsed
}
} catch {
// localStorage 不可用(隐私模式等)
}
return fallback
}
export function setPersistedPageSize(size: number): void {
try {
localStorage.setItem(STORAGE_KEY, String(size))
} catch {
// 静默失败
}
} }
import { ref, reactive, onUnmounted, toRaw } from 'vue' import { ref, reactive, onUnmounted, toRaw } from 'vue'
import { useDebounceFn } from '@vueuse/core' import { useDebounceFn } from '@vueuse/core'
import type { BasePaginationResponse, FetchOptions } from '@/types' import type { BasePaginationResponse, FetchOptions } from '@/types'
import { getPersistedPageSize, setPersistedPageSize } from './usePersistedPageSize' import { getPersistedPageSize } from './usePersistedPageSize'
interface PaginationState { interface PaginationState {
page: number page: number
...@@ -88,7 +88,6 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL ...@@ -88,7 +88,6 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
const handlePageSizeChange = (size: number) => { const handlePageSizeChange = (size: number) => {
pagination.page_size = size pagination.page_size = size
pagination.page = 1 pagination.page = 1
setPersistedPageSize(size)
load() load()
} }
......
...@@ -315,6 +315,8 @@ export default { ...@@ -315,6 +315,8 @@ export default {
chooseFile: 'Choose File', chooseFile: 'Choose File',
notAvailable: 'N/A', notAvailable: 'N/A',
now: 'Now', now: 'Now',
today: 'Today',
tomorrow: 'Tomorrow',
unknown: 'Unknown', unknown: 'Unknown',
minutes: 'min', minutes: 'min',
time: { time: {
...@@ -360,7 +362,11 @@ export default { ...@@ -360,7 +362,11 @@ export default {
mySubscriptions: 'My Subscriptions', mySubscriptions: 'My Subscriptions',
buySubscription: 'Recharge / Subscription', buySubscription: 'Recharge / Subscription',
docs: 'Docs', docs: 'Docs',
sora: 'Sora Studio' myOrders: 'My Orders',
orderManagement: 'Orders',
paymentDashboard: 'Payment Dashboard',
paymentConfig: 'Payment Config',
paymentPlans: 'Plans'
}, },
// Auth // Auth
...@@ -435,6 +441,7 @@ export default { ...@@ -435,6 +441,7 @@ export default {
invitationCodeInvalid: 'Invalid or used invitation code', invitationCodeInvalid: 'Invalid or used invitation code',
invitationCodeValidating: 'Validating invitation code...', invitationCodeValidating: 'Validating invitation code...',
invitationCodeInvalidCannotRegister: 'Invalid invitation code. Please check and try again', invitationCodeInvalidCannotRegister: 'Invalid invitation code. Please check and try again',
oauthOrContinue: 'or continue with email',
linuxdo: { linuxdo: {
signIn: 'Continue with Linux.do', signIn: 'Continue with Linux.do',
orContinue: 'or continue with email', orContinue: 'or continue with email',
...@@ -449,6 +456,20 @@ export default { ...@@ -449,6 +456,20 @@ export default {
completing: 'Completing registration…', completing: 'Completing registration…',
completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.' completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.'
}, },
oidc: {
signIn: 'Continue with {providerName}',
callbackTitle: 'Signing you in with {providerName}',
callbackProcessing: 'Completing login with {providerName}, please wait...',
callbackHint: 'If you are not redirected automatically, go back to the login page and try again.',
callbackMissingToken: 'Missing login token, please try again.',
backToLogin: 'Back to Login',
invitationRequired:
'This {providerName} account is not yet registered. The site requires an invitation code — please enter one to complete registration.',
invalidPendingToken: 'The registration token has expired. Please sign in again.',
completeRegistration: 'Complete Registration',
completing: 'Completing registration…',
completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.'
},
oauth: { oauth: {
code: 'Code', code: 'Code',
state: 'State', state: 'State',
...@@ -1618,7 +1639,6 @@ export default { ...@@ -1618,7 +1639,6 @@ export default {
openai: 'OpenAI', openai: 'OpenAI',
gemini: 'Gemini', gemini: 'Gemini',
antigravity: 'Antigravity', antigravity: 'Antigravity',
sora: 'Sora'
}, },
deleteConfirm: deleteConfirm:
"Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.", "Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.",
...@@ -1643,16 +1663,6 @@ export default { ...@@ -1643,16 +1663,6 @@ export default {
title: 'Image Generation Pricing', title: 'Image Generation Pricing',
description: 'Configure pricing for image generation models. Leave empty to use default prices.' description: 'Configure pricing for image generation models. Leave empty to use default prices.'
}, },
soraPricing: {
title: 'Sora Per-Request Pricing',
description: 'Configure per-request pricing for Sora image/video generation. Leave empty to disable billing.',
image360: 'Image 360px ($)',
image540: 'Image 540px ($)',
video: 'Video (standard) ($)',
videoHd: 'Video (Pro-HD) ($)',
storageQuota: 'Storage Quota',
storageQuotaHint: 'In GB, set the Sora storage quota for users in this group. 0 means use system default'
},
claudeCode: { claudeCode: {
title: 'Claude Code Client Restriction', title: 'Claude Code Client Restriction',
tooltip: 'When enabled, this group only allows official Claude Code clients. Non-Claude Code requests will be rejected or fallback to the specified group.', tooltip: 'When enabled, this group only allows official Claude Code clients. Non-Claude Code requests will be rejected or fallback to the specified group.',
...@@ -1666,9 +1676,23 @@ export default { ...@@ -1666,9 +1676,23 @@ export default {
title: 'OpenAI Messages Dispatch', title: 'OpenAI Messages Dispatch',
allowDispatch: 'Allow /v1/messages dispatch', allowDispatch: 'Allow /v1/messages dispatch',
allowDispatchHint: 'When enabled, API keys in this OpenAI group can dispatch requests through /v1/messages endpoint', allowDispatchHint: 'When enabled, API keys in this OpenAI group can dispatch requests through /v1/messages endpoint',
defaultModel: 'Default mapped model', familyMappingTitle: 'Family Default Mapping',
defaultModelPlaceholder: 'e.g., gpt-4.1', familyMappingHint: 'Requests that match the Opus, Sonnet, or Haiku families will prefer the target model configured here.',
defaultModelHint: 'When account has no model mapping configured, all request models will be mapped to this model' opusModel: 'Opus Target Model',
opusModelPlaceholder: 'e.g., gpt-5.4',
sonnetModel: 'Sonnet Target Model',
sonnetModelPlaceholder: 'e.g., gpt-5.3-codex',
haikuModel: 'Haiku Target Model',
haikuModelPlaceholder: 'e.g., gpt-5.4-mini',
exactMappingTitle: 'Exact Model Overrides',
exactMappingHint: 'Exact Claude model overrides take priority over the family defaults and can route a specific Claude model to a different target model.',
noExactMappings: 'No exact model overrides yet',
addExactMapping: 'Add Exact Mapping',
claudeModel: 'Claude Model',
claudeModelPlaceholder: 'e.g., claude-sonnet-4-5-20250929',
targetModel: 'Target Model',
targetModelPlaceholder: 'e.g., gpt-5.4',
removeExactMapping: 'Remove Exact Mapping'
}, },
invalidRequestFallback: { invalidRequestFallback: {
title: 'Invalid Request Fallback Group', title: 'Invalid Request Fallback Group',
...@@ -2032,7 +2056,6 @@ export default { ...@@ -2032,7 +2056,6 @@ export default {
openai: 'OpenAI', openai: 'OpenAI',
gemini: 'Gemini', gemini: 'Gemini',
antigravity: 'Antigravity', antigravity: 'Antigravity',
sora: 'Sora'
}, },
types: { types: {
oauth: 'OAuth', oauth: 'OAuth',
...@@ -2042,10 +2065,6 @@ export default { ...@@ -2042,10 +2065,6 @@ export default {
codeAssist: 'Code Assist', codeAssist: 'Code Assist',
antigravityOauth: 'Antigravity OAuth', antigravityOauth: 'Antigravity OAuth',
antigravityApikey: 'Connect via Base URL + API Key', antigravityApikey: 'Connect via Base URL + API Key',
soraApiKey: 'API Key / Upstream',
soraApiKeyHint: 'Connect to another TrafficAPI or compatible API',
soraBaseUrlRequired: 'Sora API Key account requires a Base URL',
soraBaseUrlInvalidScheme: 'Base URL must start with http:// or https://',
upstream: 'Upstream', upstream: 'Upstream',
upstreamDesc: 'Connect via Base URL + API Key' upstreamDesc: 'Connect via Base URL + API Key'
}, },
...@@ -2059,6 +2078,7 @@ export default { ...@@ -2059,6 +2078,7 @@ export default {
rateLimited: 'Rate Limited', rateLimited: 'Rate Limited',
overloaded: 'Overloaded', overloaded: 'Overloaded',
tempUnschedulable: 'Temp Unschedulable', tempUnschedulable: 'Temp Unschedulable',
unschedulable: 'Unschedulable',
rateLimitedUntil: 'Rate limited and removed from scheduling. Auto resumes at {time}', rateLimitedUntil: 'Rate limited and removed from scheduling. Auto resumes at {time}',
rateLimitedAutoResume: 'Auto resumes in {time}', rateLimitedAutoResume: 'Auto resumes in {time}',
modelRateLimitedUntil: '{model} rate limited until {time}', modelRateLimitedUntil: '{model} rate limited until {time}',
...@@ -2308,8 +2328,6 @@ export default { ...@@ -2308,8 +2328,6 @@ export default {
codexCLIOnlyDesc: codexCLIOnlyDesc:
'Only applies to OpenAI OAuth. When enabled, only Codex official client families are allowed; when disabled, the gateway bypasses this restriction and keeps existing behavior.', 'Only applies to OpenAI OAuth. When enabled, only Codex official client families are allowed; when disabled, the gateway bypasses this restriction and keeps existing behavior.',
modelRestrictionDisabledByPassthrough: 'Automatic passthrough is enabled: model whitelist/mapping will not take effect.', modelRestrictionDisabledByPassthrough: 'Automatic passthrough is enabled: model whitelist/mapping will not take effect.',
enableSora: 'Enable Sora simultaneously',
enableSoraHint: 'Sora uses the same OpenAI account. Enable to create Sora account simultaneously.'
}, },
anthropic: { anthropic: {
apiKeyPassthrough: 'Auto passthrough (auth only)', apiKeyPassthrough: 'Auto passthrough (auth only)',
...@@ -2324,9 +2342,6 @@ export default { ...@@ -2324,9 +2342,6 @@ export default {
'Map request models to actual models. Left is the requested model, right is the actual model sent to API.', 'Map request models to actual models. Left is the requested model, right is the actual model sent to API.',
selectedModels: 'Selected {count} model(s)', selectedModels: 'Selected {count} model(s)',
supportsAllModels: '(supports all models)', supportsAllModels: '(supports all models)',
soraModelsLoadFailed: 'Failed to load Sora models, fallback to default list',
soraModelsLoading: 'Loading Sora models...',
soraModelsRetry: 'Load failed, click to retry',
requestModel: 'Request model', requestModel: 'Request model',
actualModel: 'Actual model', actualModel: 'Actual model',
addMapping: 'Add Mapping', addMapping: 'Add Mapping',
...@@ -2476,8 +2491,6 @@ export default { ...@@ -2476,8 +2491,6 @@ export default {
creating: 'Creating...', creating: 'Creating...',
updating: 'Updating...', updating: 'Updating...',
accountCreated: 'Account created successfully', accountCreated: 'Account created successfully',
soraAccountCreated: 'Sora account created simultaneously',
soraAccountFailed: 'Failed to create Sora account, please add manually later',
accountUpdated: 'Account updated successfully', accountUpdated: 'Account updated successfully',
failedToCreate: 'Failed to create account', failedToCreate: 'Failed to create account',
failedToUpdate: 'Failed to update account', failedToUpdate: 'Failed to update account',
...@@ -2591,8 +2604,8 @@ export default { ...@@ -2591,8 +2604,8 @@ export default {
refreshTokenDesc: 'Enter your existing OpenAI Refresh Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.', refreshTokenDesc: 'Enter your existing OpenAI Refresh Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
refreshTokenPlaceholder: 'Paste your OpenAI Refresh Token...\nSupports multiple, one per line', refreshTokenPlaceholder: 'Paste your OpenAI Refresh Token...\nSupports multiple, one per line',
sessionTokenAuth: 'Manual ST Input', sessionTokenAuth: 'Manual ST Input',
sessionTokenDesc: 'Enter your existing Sora Session Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.', sessionTokenDesc: 'Enter your existing Session Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
sessionTokenPlaceholder: 'Paste your Sora Session Token...\nSupports multiple, one per line', sessionTokenPlaceholder: 'Paste your Session Token...\nSupports multiple, one per line',
sessionTokenRawLabel: 'Raw Input', sessionTokenRawLabel: 'Raw Input',
sessionTokenRawPlaceholder: 'Paste /api/auth/session raw payload or Session Token...', sessionTokenRawPlaceholder: 'Paste /api/auth/session raw payload or Session Token...',
sessionTokenRawHint: 'You can paste full JSON. The system will auto-parse ST and AT.', sessionTokenRawHint: 'You can paste full JSON. The system will auto-parse ST and AT.',
...@@ -2826,7 +2839,6 @@ export default { ...@@ -2826,7 +2839,6 @@ export default {
reAuthorizeAccount: 'Re-Authorize Account', reAuthorizeAccount: 'Re-Authorize Account',
claudeCodeAccount: 'Claude Code Account', claudeCodeAccount: 'Claude Code Account',
openaiAccount: 'OpenAI Account', openaiAccount: 'OpenAI Account',
soraAccount: 'Sora Account',
geminiAccount: 'Gemini Account', geminiAccount: 'Gemini Account',
antigravityAccount: 'Antigravity Account', antigravityAccount: 'Antigravity Account',
inputMethod: 'Input Method', inputMethod: 'Input Method',
...@@ -2860,11 +2872,6 @@ export default { ...@@ -2860,11 +2872,6 @@ export default {
geminiImageTestMode: 'Mode: Gemini image generation test', geminiImageTestMode: 'Mode: Gemini image generation test',
geminiImagePreview: 'Generated images:', geminiImagePreview: 'Generated images:',
geminiImageReceived: 'Received test image #{count}', geminiImageReceived: 'Received test image #{count}',
soraUpstreamBaseUrlHint: 'Upstream Sora service URL (another TrafficAPI instance or compatible API)',
soraTestHint: 'Sora test runs connectivity and capability checks (/backend/me, subscription, Sora2 invite and remaining quota).',
soraTestTarget: 'Target: Sora account capability',
soraTestMode: 'Mode: Connectivity + Capability checks',
soraTestingFlow: 'Running Sora connectivity and capability checks...',
// Stats Modal // Stats Modal
viewStats: 'View Stats', viewStats: 'View Stats',
usageStatistics: 'Usage Statistics', usageStatistics: 'Usage Statistics',
...@@ -4202,7 +4209,7 @@ export default { ...@@ -4202,7 +4209,7 @@ export default {
gateway: 'Gateway', gateway: 'Gateway',
email: 'Email', email: 'Email',
backup: 'Backup', backup: 'Backup',
data: 'Sora Storage', payment: 'Payment',
}, },
emailTabDisabledTitle: 'Email Verification Not Enabled', emailTabDisabledTitle: 'Email Verification Not Enabled',
emailTabDisabledHint: 'Enable email verification in the Security tab to configure SMTP settings.', emailTabDisabledHint: 'Enable email verification in the Security tab to configure SMTP settings.',
...@@ -4263,6 +4270,57 @@ export default { ...@@ -4263,6 +4270,57 @@ export default {
quickSetCopy: 'Generate & Copy (current site)', quickSetCopy: 'Generate & Copy (current site)',
redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard' redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard'
}, },
oidc: {
title: 'OIDC Login',
description: 'Configure a standard OIDC provider (for example Keycloak)',
enable: 'Enable OIDC Login',
enableHint: 'Show OIDC login on the login/register pages',
providerName: 'Provider Name',
providerNamePlaceholder: 'for example Keycloak',
clientId: 'Client ID',
clientIdPlaceholder: 'OIDC client id',
clientSecret: 'Client Secret',
clientSecretPlaceholder: '********',
clientSecretHint: 'Used by backend to exchange tokens (keep it secret)',
clientSecretConfiguredPlaceholder: '********',
clientSecretConfiguredHint: 'Secret configured. Leave empty to keep the current value.',
issuerUrl: 'Issuer URL',
issuerUrlPlaceholder: 'https://id.example.com/realms/main',
discoveryUrl: 'Discovery URL',
discoveryUrlPlaceholder: 'Optional, leave empty to auto-derive from issuer',
authorizeUrl: 'Authorize URL',
authorizeUrlPlaceholder: 'Optional, can be discovered automatically',
tokenUrl: 'Token URL',
tokenUrlPlaceholder: 'Optional, can be discovered automatically',
userinfoUrl: 'UserInfo URL',
userinfoUrlPlaceholder: 'Optional, can be discovered automatically',
jwksUrl: 'JWKS URL',
jwksUrlPlaceholder: 'Optional, required when strict ID token validation is enabled',
scopes: 'Scopes',
scopesPlaceholder: 'openid email profile',
scopesHint: 'Must include openid',
redirectUrl: 'Backend Redirect URL',
redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/oidc/callback',
redirectUrlHint: 'Must match the callback URL configured in the OIDC provider',
quickSetCopy: 'Generate & Copy (current site)',
redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard',
frontendRedirectUrl: 'Frontend Callback Path',
frontendRedirectUrlPlaceholder: '/auth/oidc/callback',
frontendRedirectUrlHint: 'Frontend route used after backend callback',
tokenAuthMethod: 'Token Auth Method',
clockSkewSeconds: 'Clock Skew (seconds)',
allowedSigningAlgs: 'Allowed Signing Algs',
allowedSigningAlgsPlaceholder: 'RS256,ES256,PS256',
usePkce: 'Use PKCE',
validateIdToken: 'Validate ID Token',
requireEmailVerified: 'Require Email Verified',
userinfoEmailPath: 'UserInfo Email Path',
userinfoEmailPathPlaceholder: 'for example data.email',
userinfoIdPath: 'UserInfo ID Path',
userinfoIdPathPlaceholder: 'for example data.id',
userinfoUsernamePath: 'UserInfo Username Path',
userinfoUsernamePathPlaceholder: 'for example data.username'
},
defaults: { defaults: {
title: 'Default User Settings', title: 'Default User Settings',
description: 'Default values for new users', description: 'Default values for new users',
...@@ -5044,99 +5102,263 @@ export default { ...@@ -5044,99 +5102,263 @@ export default {
} }
}, },
// Sora Studio // Payment System
sora: { payment: {
title: 'Sora Studio', title: 'Recharge / Subscription',
description: 'Generate videos and images with Sora AI', amountLabel: 'Amount',
notEnabled: 'Feature Not Available', quickAmounts: 'Quick Amounts',
notEnabledDesc: 'The Sora Studio feature has not been enabled by the administrator. Please contact your admin.', customAmount: 'Custom Amount',
tabGenerate: 'Generate', enterAmount: 'Enter amount',
tabLibrary: 'Library', paymentMethod: 'Payment Method',
noActiveGenerations: 'No active generations', fee: 'Fee',
startGenerating: 'Enter a prompt below to start creating', actualPay: 'Actual Payment',
storage: 'Storage', createOrder: 'Confirm Payment',
promptPlaceholder: 'Describe what you want to create...', methods: {
generate: 'Generate', easypay: 'EasyPay',
generating: 'Generating...', alipay: 'Alipay',
selectModel: 'Select Model', wxpay: 'WeChat Pay',
statusPending: 'Pending', stripe: 'Stripe',
statusGenerating: 'Generating', card: 'Card',
statusCompleted: 'Completed', link: 'Link',
statusFailed: 'Failed', alipay_direct: 'Alipay (Direct)',
statusCancelled: 'Cancelled', wxpay_direct: 'WeChat Pay (Direct)',
cancel: 'Cancel', },
delete: 'Delete', status: {
save: 'Save to Cloud', pending: 'Pending',
saved: 'Saved', paid: 'Paid',
retry: 'Retry', recharging: 'Recharging',
download: 'Download', completed: 'Completed',
justNow: 'Just now', expired: 'Expired',
minutesAgo: '{n} min ago', cancelled: 'Cancelled',
hoursAgo: '{n} hr ago', failed: 'Failed',
noSavedWorks: 'No saved works', refund_requested: 'Refund Requested',
saveWorksHint: 'Save your completed generations to the library', refunding: 'Refunding',
filterAll: 'All', refunded: 'Refunded',
filterVideo: 'Video', partially_refunded: 'Partially Refunded',
filterImage: 'Image', refund_failed: 'Refund Failed',
confirmDelete: 'Are you sure you want to delete this work?', },
loading: 'Loading...', qr: {
loadMore: 'Load More', scanToPay: 'Scan to Pay',
noStorageWarningTitle: 'No Storage Configured', scanAlipay: 'Alipay QR Payment',
noStorageWarningDesc: 'Generated content is only available via temporary upstream links that expire in ~15 minutes. Consider configuring S3 storage.', scanWxpay: 'WeChat QR Payment',
mediaTypeVideo: 'Video', scanAlipayHint: 'Open Alipay on your phone and scan the QR code to pay',
mediaTypeImage: 'Image', scanWxpayHint: 'Open WeChat on your phone and scan the QR code to pay',
notificationCompleted: 'Generation Complete', payInNewWindow: 'Complete Payment in New Window',
notificationFailed: 'Generation Failed', payInNewWindowHint: 'The payment page has opened in a new window. Please complete the payment there and return to this page.',
notificationCompletedBody: 'Your {model} task has completed', openPayWindow: 'Reopen Payment Page',
notificationFailedBody: 'Your {model} task has failed', expiresIn: 'Expires in',
upstreamExpiresSoon: 'Expiring soon', expired: 'Order Expired',
upstreamExpired: 'Link expired', expiredDesc: 'This order has expired. Please create a new one.',
upstreamCountdown: '{time} remaining', cancelled: 'Order Cancelled',
previewTitle: 'Preview', cancelledDesc: 'You have cancelled this payment.',
closePreview: 'Close', waitingPayment: 'Waiting for payment...',
beforeUnloadWarning: 'You have unsaved generated content. Are you sure you want to leave?', cancelOrder: 'Cancel Order',
downloadTitle: 'Download Generated Content', },
downloadExpirationWarning: 'This link expires in approximately 15 minutes. Please download and save promptly.', orders: {
downloadNow: 'Download Now', title: 'My Orders',
referenceImage: 'Reference Image', empty: 'No orders yet',
removeImage: 'Remove', orderId: 'Order ID',
imageTooLarge: 'Image size cannot exceed 20MB', orderNo: 'Order No.',
// Sora dark theme additions amount: 'Amount',
welcomeTitle: 'Turn your imagination into video', payAmount: 'Paid',
welcomeSubtitle: 'Enter a description and Sora will create realistic videos or images for you. Try the examples below to get started.', status: 'Status',
queueTasks: 'tasks', paymentMethod: 'Payment Method',
queueWaiting: 'Queued', createdAt: 'Created',
waiting: 'Waiting', cancel: 'Cancel Order',
waited: 'Waited', userId: 'User ID',
errorCategory: 'Content Policy Violation', orderType: 'Order Type',
savedToCloud: 'Saved to Cloud', actions: 'Actions',
downloadLocal: 'Download', requestRefund: 'Request Refund',
canDownload: 'to download', },
regenrate: 'Regenerate', result: {
regenerate: 'Regenerate', success: 'Payment Successful',
creatorPlaceholder: 'Describe the video or image you want to create...', subscriptionSuccess: 'Subscription Successful',
videoModels: 'Video Models', failed: 'Payment Failed',
imageModels: 'Image Models', backToRecharge: 'Back to Recharge',
noStorageConfigured: 'No Storage', viewOrders: 'View Orders',
selectCredential: 'Select Credential', },
apiKeys: 'API Keys', currentBalance: 'Current Balance',
subscriptions: 'Subscriptions', rechargeAccount: 'Recharge Account',
subscription: 'Subscription', activeSubscription: 'Active Subscription',
noCredentialHint: 'Please create an API Key or contact admin for subscription', noActiveSubscription: 'No active subscription',
uploadReference: 'Upload reference image', tabTopUp: 'Top Up',
generatingCount: 'Generating {current}/{max}', tabSubscribe: 'Subscribe',
noStorageToastMessage: 'Cloud storage is not configured. Please use "Download" to save files after generation, otherwise they will be lost.', noPlans: 'No subscription plans available',
galleryCount: '{count} works', notAvailable: 'Top-up is currently unavailable',
galleryEmptyTitle: 'No works yet', confirmSubscription: 'Confirm Subscription',
galleryEmptyDesc: 'Your creations will be displayed here. Go to the generate page to start your first creation.', confirmCancel: 'Are you sure you want to cancel this order?',
startCreating: 'Start Creating', amountTooLow: 'Minimum amount is {min}',
yesterday: 'Yesterday', amountTooHigh: 'Maximum amount is {max}',
landscape: 'Landscape', amountNoMethod: 'No payment method available for this amount',
portrait: 'Portrait', refundReason: 'Refund Reason',
square: 'Square', refundReasonPlaceholder: 'Please describe your refund reason',
examplePrompt1: 'A golden Shiba Inu walking through the streets of Shibuya, Tokyo, camera following, cinematic shot, 4K', stripeLoadFailed: 'Failed to load payment component. Please refresh and try again.',
examplePrompt2: 'Drone aerial view, green aurora reflecting on a glacial lake in Iceland, slow push-in', stripeMissingParams: 'Missing order ID or client secret',
examplePrompt3: 'Cyberpunk futuristic city, neon lights reflected in rain puddles, nightscape, cinematic colors', stripeNotConfigured: 'Stripe is not configured',
examplePrompt4: 'Chinese ink painting style, a small boat drifting among misty mountains and rivers, classical atmosphere' errors: {
} tooManyPending: 'Too many pending orders (max {max}). Please complete or cancel existing orders first.',
cancelRateLimited: 'Too many cancellations. Please try again later.',
PENDING_ORDERS: 'This provider has pending orders. Please wait for them to complete before making changes.',
},
stripePay: 'Pay Now',
stripeSuccessProcessing: 'Payment successful, processing your order...',
stripePopup: {
redirecting: 'Redirecting to payment page...',
loadingQr: 'Loading WeChat Pay QR code...',
timeout: 'Timed out waiting for payment credentials, please retry',
qrFailed: 'Failed to get WeChat Pay QR code',
},
subscribeNow: 'Subscribe Now',
renewNow: 'Renew',
selectPlan: 'Select Plan',
planFeatures: 'Features',
planCard: {
rate: 'Rate',
dailyLimit: 'Daily',
weeklyLimit: 'Weekly',
monthlyLimit: 'Monthly',
quota: 'Quota',
unlimited: 'Unlimited',
models: 'Models',
},
days: 'days',
months: 'months',
years: 'years',
oneMonth: '1 Month',
oneYear: '1 Year',
perMonth: 'month',
perYear: 'year',
admin: {
tabs: {
overview: 'Overview',
orders: 'Orders',
channels: 'Channels',
plans: 'Plans',
},
todayRevenue: 'Today Revenue',
totalRevenue: 'Total Revenue',
todayOrders: 'Today Orders',
orderCount: 'Order Count',
avgAmount: 'Average Amount',
revenue: 'Revenue',
dailyRevenue: 'Daily Revenue',
paymentDistribution: 'Payment Distribution',
colUser: 'User',
topUsers: 'Top Users',
noData: 'No data',
days: 'days',
weeks: 'weeks',
months: 'months',
searchOrders: 'Search orders...',
allStatuses: 'All Statuses',
allPaymentTypes: 'All Payment Types',
allOrderTypes: 'All Order Types',
orderDetail: 'Order Detail',
orderType: 'Order Type',
orders: 'Orders',
balanceOrder: 'Balance Top-Up',
subscriptionOrder: 'Subscription',
paidAt: 'Paid At',
completedAt: 'Completed At',
expiresAt: 'Expires At',
feeRate: 'Fee Rate',
refund: 'Refund',
refundOrder: 'Refund Order',
refundAmount: 'Refund Amount',
maxRefundable: 'Max Refundable',
refundReason: 'Refund Reason',
refundReasonPlaceholder: 'Please enter refund reason',
confirmRefund: 'Confirm Refund',
refundSuccess: 'Refund successful',
refundInfo: 'Refund Info',
refundEnabled: 'Refund Enabled',
alreadyRefunded: 'Already Refunded',
deductBalance: 'Deduct Balance',
deductBalanceHint: 'Subtract recharged amount from user balance',
userBalance: 'User Balance',
orderAmount: 'Order Amount',
insufficientBalance: 'Insufficient balance — will deduct to $0',
noDeduction: 'Will NOT deduct user balance',
forceRefund: 'Force refund (ignore balance check)',
orderCancelled: 'Order Cancelled',
retry: 'Retry',
retrySuccess: 'Retry successful',
approveRefund: 'Approve Refund',
retryRefund: 'Retry Refund',
refundRequestInfo: 'Refund Request Info',
refundRequestedAt: 'Requested At',
refundRequestedBy: 'Requested By',
refundRequestReason: 'Request Reason',
auditLogs: 'Audit Logs',
operator: 'Operator',
channelName: 'Channel Name',
channelDescription: 'Channel Description',
createChannel: 'Create Channel',
editChannel: 'Edit Channel',
deleteChannel: 'Delete Channel',
deleteChannelConfirm: 'Are you sure you want to delete this channel?',
planName: 'Plan Name',
planDescription: 'Plan Description',
createPlan: 'Create Plan',
editPlan: 'Edit Plan',
deletePlan: 'Delete Plan',
deletePlanConfirm: 'Are you sure you want to delete this plan?',
originalPrice: 'Original Price',
price: 'Price',
validityDays: 'Validity (days)',
validityUnit: 'Validity Unit',
sortOrder: 'Sort Order',
forSale: 'For Sale',
onSale: 'On Sale',
offSale: 'Off Sale',
group: 'Group',
groupId: 'Group ID',
features: 'Features',
featuresHint: 'One feature per line',
featuresPlaceholder: 'Enter plan features...',
providerManagement: 'Provider Management',
providerManagementDesc: 'Manage payment provider instances',
createProvider: 'Create Provider',
editProvider: 'Edit Provider',
deleteProvider: 'Delete Provider',
deleteProviderConfirm: 'Are you sure you want to delete this provider?',
providerName: 'Provider Name',
providerKey: 'Provider Key',
selectProviderKey: 'Select Provider Key',
providerConfig: 'Provider Config',
noProviders: 'No providers configured',
noProvidersHint: 'Create a provider instance to start accepting payments',
supportedTypes: 'Supported Payment Types',
supportedTypesHint: 'Select the payment types this provider supports',
rateMultiplier: 'Rate Multiplier',
dashboardTitle: 'Payment Dashboard',
dashboardDesc: 'Recharge order analytics and insights',
daySuffix: 'd',
paymentConfigTitle: 'Payment Config',
paymentConfigDesc: 'Configure payment providers and settings',
plansPageTitle: 'Subscription Plans',
plansPageDesc: 'Manage subscription plan configuration',
tabPlanConfig: 'Plan Configuration',
tabUserSubs: 'User Subscriptions',
selectGroup: 'Select a group',
groupMissing: 'Missing',
groupInfo: 'Group Info',
platform: 'Platform',
rateMultiplierLabel: 'Rate',
dailyLimit: 'Daily Limit',
weeklyLimit: 'Weekly Limit',
monthlyLimit: 'Monthly Limit',
unlimited: 'Unlimited',
searchUserSubs: 'Search user subscriptions...',
daily: 'D',
weekly: 'W',
monthly: 'M',
subsStatus: {
active: 'Active',
expired: 'Expired',
revoked: 'Revoked',
},
},
},
} }
...@@ -315,6 +315,8 @@ export default { ...@@ -315,6 +315,8 @@ export default {
chooseFile: '选择文件', chooseFile: '选择文件',
notAvailable: '不可用', notAvailable: '不可用',
now: '现在', now: '现在',
today: '今天',
tomorrow: '明天',
unknown: '未知', unknown: '未知',
minutes: '分钟', minutes: '分钟',
time: { time: {
...@@ -360,7 +362,11 @@ export default { ...@@ -360,7 +362,11 @@ export default {
mySubscriptions: '我的订阅', mySubscriptions: '我的订阅',
buySubscription: '充值/订阅', buySubscription: '充值/订阅',
docs: '文档', docs: '文档',
sora: 'Sora 创作' myOrders: '我的订单',
orderManagement: '订单管理',
paymentDashboard: '支付概览',
paymentConfig: '支付配置',
paymentPlans: '订阅套餐'
}, },
// Auth // Auth
...@@ -434,6 +440,7 @@ export default { ...@@ -434,6 +440,7 @@ export default {
invitationCodeInvalid: '邀请码无效或已被使用', invitationCodeInvalid: '邀请码无效或已被使用',
invitationCodeValidating: '正在验证邀请码...', invitationCodeValidating: '正在验证邀请码...',
invitationCodeInvalidCannotRegister: '邀请码无效,请检查后重试', invitationCodeInvalidCannotRegister: '邀请码无效,请检查后重试',
oauthOrContinue: '或使用邮箱密码继续',
linuxdo: { linuxdo: {
signIn: '使用 Linux.do 登录', signIn: '使用 Linux.do 登录',
orContinue: '或使用邮箱密码继续', orContinue: '或使用邮箱密码继续',
...@@ -448,6 +455,19 @@ export default { ...@@ -448,6 +455,19 @@ export default {
completing: '正在完成注册...', completing: '正在完成注册...',
completeRegistrationFailed: '注册失败,请检查邀请码后重试。' completeRegistrationFailed: '注册失败,请检查邀请码后重试。'
}, },
oidc: {
signIn: '使用 {providerName} 登录',
callbackTitle: '正在完成 {providerName} 登录',
callbackProcessing: '正在验证 {providerName} 登录信息,请稍候...',
callbackHint: '如果页面未自动跳转,请返回登录页重试。',
callbackMissingToken: '登录信息缺失,请返回重试。',
backToLogin: '返回登录',
invitationRequired: '该 {providerName} 账号尚未注册,站点已开启邀请码注册,请输入邀请码以完成注册。',
invalidPendingToken: '注册凭证已失效,请重新登录。',
completeRegistration: '完成注册',
completing: '正在完成注册...',
completeRegistrationFailed: '注册失败,请检查邀请码后重试。'
},
oauth: { oauth: {
code: '授权码', code: '授权码',
state: '状态', state: '状态',
...@@ -1655,7 +1675,6 @@ export default { ...@@ -1655,7 +1675,6 @@ export default {
openai: 'OpenAI', openai: 'OpenAI',
gemini: 'Gemini', gemini: 'Gemini',
antigravity: 'Antigravity', antigravity: 'Antigravity',
sora: 'Sora'
}, },
saving: '保存中...', saving: '保存中...',
noGroups: '暂无分组', noGroups: '暂无分组',
...@@ -1729,16 +1748,6 @@ export default { ...@@ -1729,16 +1748,6 @@ export default {
title: '图片生成计费', title: '图片生成计费',
description: '配置图片生成模型的图片生成价格,留空则使用默认价格' description: '配置图片生成模型的图片生成价格,留空则使用默认价格'
}, },
soraPricing: {
title: 'Sora 按次计费',
description: '配置 Sora 图片/视频按次收费价格,留空则默认不计费',
image360: '图片 360px ($)',
image540: '图片 540px ($)',
video: '视频(标准)($)',
videoHd: '视频(Pro-HD)($)',
storageQuota: '存储配额',
storageQuotaHint: '单位 GB,设置该分组用户的 Sora 存储配额上限,0 表示使用系统默认'
},
claudeCode: { claudeCode: {
title: 'Claude Code 客户端限制', title: 'Claude Code 客户端限制',
tooltip: tooltip:
...@@ -1753,9 +1762,23 @@ export default { ...@@ -1753,9 +1762,23 @@ export default {
title: 'OpenAI Messages 调度配置', title: 'OpenAI Messages 调度配置',
allowDispatch: '允许 /v1/messages 调度', allowDispatch: '允许 /v1/messages 调度',
allowDispatchHint: '启用后,此 OpenAI 分组的 API Key 可以通过 /v1/messages 端点调度请求', allowDispatchHint: '启用后,此 OpenAI 分组的 API Key 可以通过 /v1/messages 端点调度请求',
defaultModel: '默认映射模型', familyMappingTitle: '系列默认映射',
defaultModelPlaceholder: '例如: gpt-4.1', familyMappingHint: '当请求命中 Opus、Sonnet、Haiku 系列时,会优先使用这里配置的目标模型。',
defaultModelHint: '当账号未配置模型映射时,所有请求模型将映射到此模型' opusModel: 'Opus 映射模型',
opusModelPlaceholder: '例如: gpt-5.4',
sonnetModel: 'Sonnet 映射模型',
sonnetModelPlaceholder: '例如: gpt-5.3-codex',
haikuModel: 'Haiku 映射模型',
haikuModelPlaceholder: '例如: gpt-5.4-mini',
exactMappingTitle: '精确模型覆盖',
exactMappingHint: '精确 Claude 模型覆盖优先级高于系列默认映射,可将某个具体 Claude 模型单独映射到不同的目标模型。',
noExactMappings: '暂无精确模型覆盖规则',
addExactMapping: '添加精确映射',
claudeModel: 'Claude 模型',
claudeModelPlaceholder: '例如: claude-sonnet-4-5-20250929',
targetModel: '目标模型',
targetModelPlaceholder: '例如: gpt-5.4',
removeExactMapping: '删除精确映射'
}, },
invalidRequestFallback: { invalidRequestFallback: {
title: '无效请求兜底分组', title: '无效请求兜底分组',
...@@ -2214,7 +2237,6 @@ export default { ...@@ -2214,7 +2237,6 @@ export default {
anthropic: 'Anthropic', anthropic: 'Anthropic',
gemini: 'Gemini', gemini: 'Gemini',
antigravity: 'Antigravity', antigravity: 'Antigravity',
sora: 'Sora'
}, },
types: { types: {
oauth: 'OAuth', oauth: 'OAuth',
...@@ -2224,10 +2246,6 @@ export default { ...@@ -2224,10 +2246,6 @@ export default {
codeAssist: 'Code Assist', codeAssist: 'Code Assist',
antigravityOauth: 'Antigravity OAuth', antigravityOauth: 'Antigravity OAuth',
antigravityApikey: '通过 Base URL + API Key 连接', antigravityApikey: '通过 Base URL + API Key 连接',
soraApiKey: 'API Key / 上游透传',
soraApiKeyHint: '连接另一个 TrafficAPI 或兼容 API',
soraBaseUrlRequired: 'Sora apikey 账号必须设置上游地址(Base URL)',
soraBaseUrlInvalidScheme: 'Base URL 必须以 http:// 或 https:// 开头',
upstream: '对接上游', upstream: '对接上游',
upstreamDesc: '通过 Base URL + API Key 连接上游', upstreamDesc: '通过 Base URL + API Key 连接上游',
api_key: 'API Key', api_key: 'API Key',
...@@ -2243,6 +2261,7 @@ export default { ...@@ -2243,6 +2261,7 @@ export default {
rateLimited: '限流中', rateLimited: '限流中',
overloaded: '过载中', overloaded: '过载中',
tempUnschedulable: '临时不可调度', tempUnschedulable: '临时不可调度',
unschedulable: '不可调度',
rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复', rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复',
rateLimitedAutoResume: '{time} 自动恢复', rateLimitedAutoResume: '{time} 自动恢复',
modelRateLimitedUntil: '{model} 限流至 {time}', modelRateLimitedUntil: '{model} 限流至 {time}',
...@@ -2456,8 +2475,6 @@ export default { ...@@ -2456,8 +2475,6 @@ export default {
codexCLIOnly: '仅允许 Codex 官方客户端', codexCLIOnly: '仅允许 Codex 官方客户端',
codexCLIOnlyDesc: '仅对 OpenAI OAuth 生效。开启后仅允许 Codex 官方客户端家族访问;关闭后完全绕过并保持原逻辑。', codexCLIOnlyDesc: '仅对 OpenAI OAuth 生效。开启后仅允许 Codex 官方客户端家族访问;关闭后完全绕过并保持原逻辑。',
modelRestrictionDisabledByPassthrough: '已开启自动透传:模型白名单/映射不会生效。', modelRestrictionDisabledByPassthrough: '已开启自动透传:模型白名单/映射不会生效。',
enableSora: '同时启用 Sora',
enableSoraHint: 'Sora 使用相同的 OpenAI 账号,开启后将同时创建 Sora 平台账号'
}, },
anthropic: { anthropic: {
apiKeyPassthrough: '自动透传(仅替换认证)', apiKeyPassthrough: '自动透传(仅替换认证)',
...@@ -2471,9 +2488,6 @@ export default { ...@@ -2471,9 +2488,6 @@ export default {
mapRequestModels: '将请求模型映射到实际模型。左边是请求的模型,右边是发送到 API 的实际模型。', mapRequestModels: '将请求模型映射到实际模型。左边是请求的模型,右边是发送到 API 的实际模型。',
selectedModels: '已选择 {count} 个模型', selectedModels: '已选择 {count} 个模型',
supportsAllModels: '(支持所有模型)', supportsAllModels: '(支持所有模型)',
soraModelsLoadFailed: '加载 Sora 模型列表失败,已回退到默认列表',
soraModelsLoading: '正在加载 Sora 模型...',
soraModelsRetry: '加载失败,点击重试',
requestModel: '请求模型', requestModel: '请求模型',
actualModel: '实际模型', actualModel: '实际模型',
addMapping: '添加映射', addMapping: '添加映射',
...@@ -2620,8 +2634,6 @@ export default { ...@@ -2620,8 +2634,6 @@ export default {
creating: '创建中...', creating: '创建中...',
updating: '更新中...', updating: '更新中...',
accountCreated: '账号创建成功', accountCreated: '账号创建成功',
soraAccountCreated: 'Sora 账号已同时创建',
soraAccountFailed: 'Sora 账号创建失败,请稍后手动添加',
accountUpdated: '账号更新成功', accountUpdated: '账号更新成功',
failedToCreate: '创建账号失败', failedToCreate: '创建账号失败',
failedToUpdate: '更新账号失败', failedToUpdate: '更新账号失败',
...@@ -2729,8 +2741,8 @@ export default { ...@@ -2729,8 +2741,8 @@ export default {
refreshTokenDesc: '输入您已有的 OpenAI Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。', refreshTokenDesc: '输入您已有的 OpenAI Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
refreshTokenPlaceholder: '粘贴您的 OpenAI Refresh Token...\n支持多个,每行一个', refreshTokenPlaceholder: '粘贴您的 OpenAI Refresh Token...\n支持多个,每行一个',
sessionTokenAuth: '手动输入 ST', sessionTokenAuth: '手动输入 ST',
sessionTokenDesc: '输入您已有的 Sora Session Token,支持批量输入(每行一个),系统将自动验证并创建账号。', sessionTokenDesc: '输入您已有的 Session Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
sessionTokenPlaceholder: '粘贴您的 Sora Session Token...\n支持多个,每行一个', sessionTokenPlaceholder: '粘贴您的 Session Token...\n支持多个,每行一个',
sessionTokenRawLabel: '原始字符串', sessionTokenRawLabel: '原始字符串',
sessionTokenRawPlaceholder: '粘贴 /api/auth/session 原始数据或 Session Token...', sessionTokenRawPlaceholder: '粘贴 /api/auth/session 原始数据或 Session Token...',
sessionTokenRawHint: '支持粘贴完整 JSON,系统会自动解析 ST 和 AT。', sessionTokenRawHint: '支持粘贴完整 JSON,系统会自动解析 ST 和 AT。',
...@@ -2959,7 +2971,6 @@ export default { ...@@ -2959,7 +2971,6 @@ export default {
reAuthorizeAccount: '重新授权账号', reAuthorizeAccount: '重新授权账号',
claudeCodeAccount: 'Claude Code 账号', claudeCodeAccount: 'Claude Code 账号',
openaiAccount: 'OpenAI 账号', openaiAccount: 'OpenAI 账号',
soraAccount: 'Sora 账号',
geminiAccount: 'Gemini 账号', geminiAccount: 'Gemini 账号',
antigravityAccount: 'Antigravity 账号', antigravityAccount: 'Antigravity 账号',
inputMethod: '输入方式', inputMethod: '输入方式',
...@@ -2991,11 +3002,6 @@ export default { ...@@ -2991,11 +3002,6 @@ export default {
geminiImageTestMode: '模式:Gemini 生图测试', geminiImageTestMode: '模式:Gemini 生图测试',
geminiImagePreview: '生成结果:', geminiImagePreview: '生成结果:',
geminiImageReceived: '已收到第 {count} 张测试图片', geminiImageReceived: '已收到第 {count} 张测试图片',
soraUpstreamBaseUrlHint: '上游 Sora 服务地址(另一个 TrafficAPI 实例或兼容 API)',
soraTestHint: 'Sora 测试将执行连通性与能力检测(/backend/me、订阅信息、Sora2 邀请码与剩余额度)。',
soraTestTarget: '检测目标:Sora 账号能力',
soraTestMode: '模式:连通性 + 能力探测',
soraTestingFlow: '执行 Sora 连通性与能力检测...',
// Stats Modal // Stats Modal
viewStats: '查看统计', viewStats: '查看统计',
usageStatistics: '使用统计', usageStatistics: '使用统计',
...@@ -4368,7 +4374,7 @@ export default { ...@@ -4368,7 +4374,7 @@ export default {
gateway: '网关服务', gateway: '网关服务',
email: '邮件设置', email: '邮件设置',
backup: '数据备份', backup: '数据备份',
data: 'Sora 存储', payment: '支付设置',
}, },
emailTabDisabledTitle: '邮箱验证未启用', emailTabDisabledTitle: '邮箱验证未启用',
emailTabDisabledHint: '请在「安全与认证」选项卡中启用邮箱验证后,再配置 SMTP 设置。', emailTabDisabledHint: '请在「安全与认证」选项卡中启用邮箱验证后,再配置 SMTP 设置。',
...@@ -4429,6 +4435,57 @@ export default { ...@@ -4429,6 +4435,57 @@ export default {
quickSetCopy: '使用当前站点生成并复制', quickSetCopy: '使用当前站点生成并复制',
redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板' redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板'
}, },
oidc: {
title: 'OIDC 登录',
description: '配置标准 OIDC Provider(例如 Keycloak)',
enable: '启用 OIDC 登录',
enableHint: '在登录/注册页面显示 OIDC 登录入口',
providerName: 'Provider 名称',
providerNamePlaceholder: '例如 Keycloak',
clientId: 'Client ID',
clientIdPlaceholder: 'OIDC client id',
clientSecret: 'Client Secret',
clientSecretPlaceholder: '********',
clientSecretHint: '用于后端交换 token(请保密)',
clientSecretConfiguredPlaceholder: '********',
clientSecretConfiguredHint: '密钥已配置,留空以保留当前值。',
issuerUrl: 'Issuer URL',
issuerUrlPlaceholder: 'https://id.example.com/realms/main',
discoveryUrl: 'Discovery URL',
discoveryUrlPlaceholder: '可选,留空将基于 issuer 自动推导',
authorizeUrl: 'Authorize URL',
authorizeUrlPlaceholder: '可选,可通过 discovery 自动获取',
tokenUrl: 'Token URL',
tokenUrlPlaceholder: '可选,可通过 discovery 自动获取',
userinfoUrl: 'UserInfo URL',
userinfoUrlPlaceholder: '可选,可通过 discovery 自动获取',
jwksUrl: 'JWKS URL',
jwksUrlPlaceholder: '可选;启用严格 ID Token 校验时必填',
scopes: 'Scopes',
scopesPlaceholder: 'openid email profile',
scopesHint: '必须包含 openid',
redirectUrl: '后端回调地址(Redirect URL)',
redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/oidc/callback',
redirectUrlHint: '必须与 OIDC Provider 中配置的回调地址一致',
quickSetCopy: '使用当前站点生成并复制',
redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板',
frontendRedirectUrl: '前端回调路径',
frontendRedirectUrlPlaceholder: '/auth/oidc/callback',
frontendRedirectUrlHint: '后端回调完成后重定向到此前端路径',
tokenAuthMethod: 'Token 鉴权方式',
clockSkewSeconds: '时钟偏移(秒)',
allowedSigningAlgs: '允许的签名算法',
allowedSigningAlgsPlaceholder: 'RS256,ES256,PS256',
usePkce: '启用 PKCE',
validateIdToken: '校验 ID Token',
requireEmailVerified: '要求邮箱已验证',
userinfoEmailPath: 'UserInfo 邮箱字段路径',
userinfoEmailPathPlaceholder: '例如 data.email',
userinfoIdPath: 'UserInfo ID 字段路径',
userinfoIdPathPlaceholder: '例如 data.id',
userinfoUsernamePath: 'UserInfo 用户名字段路径',
userinfoUsernamePathPlaceholder: '例如 data.username'
},
defaults: { defaults: {
title: '用户默认设置', title: '用户默认设置',
description: '新用户的默认值', description: '新用户的默认值',
...@@ -4485,6 +4542,15 @@ export default { ...@@ -4485,6 +4542,15 @@ export default {
apiBaseUrl: 'API 端点地址', apiBaseUrl: 'API 端点地址',
apiBaseUrlHint: '用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址', apiBaseUrlHint: '用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址',
apiBaseUrlPlaceholder: 'https://api.example.com', apiBaseUrlPlaceholder: 'https://api.example.com',
tablePreferencesTitle: '通用表格设置',
tablePreferencesDescription: '设置后台与用户侧表格组件的默认分页行为',
tableDefaultPageSize: '默认每页条数',
tableDefaultPageSizeHint: '必须为 5-1000 之间的整数',
tablePageSizeOptions: '可选每页条数列表',
tablePageSizeOptionsPlaceholder: '10, 20, 50, 100',
tablePageSizeOptionsHint: '使用英文逗号分隔,取值范围 5-1000,保存时会自动去重并排序',
tableDefaultPageSizeRangeError: '默认每页条数必须在 {min}-{max} 之间',
tablePageSizeOptionsFormatError: '可选每页条数格式无效,请输入 {min}-{max} 之间的整数并用英文逗号分隔',
customEndpoints: { customEndpoints: {
title: '自定义端点', title: '自定义端点',
description: '添加额外的 API 端点地址,用户可在「API Keys」页面快速复制', description: '添加额外的 API 端点地址,用户可在「API Keys」页面快速复制',
...@@ -4560,6 +4626,102 @@ export default { ...@@ -4560,6 +4626,102 @@ export default {
moveUp: '上移', moveUp: '上移',
moveDown: '下移', moveDown: '下移',
}, },
payment: {
title: '支付设置',
description: '配置支付系统选项',
configGuide: '支付配置指南',
enabled: '启用支付',
enabledHint: '启用或禁用支付系统',
enabledPaymentTypes: '启用的服务商',
enabledPaymentTypesHint: '禁用服务商将同时禁用对应的实例。',
findProvider: '正在寻找合适的 EasyPay 服务商?',
minAmount: '最低金额',
maxAmount: '最高金额',
dailyLimit: '每日限额',
orderTimeout: '订单超时时间',
orderTimeoutHint: '单位:分钟,至少 1 分钟',
maxPendingOrders: '最大待支付订单数',
cancelRateLimit: '限制取消频率',
cancelRateLimitHint: '启用后,用户在时间窗口内取消订单次数超限将无法创建新订单',
cancelRateLimitEvery: '',
cancelRateLimitAllowMax: '最多',
cancelRateLimitTimes: '',
cancelRateLimitWindow: '时间窗口',
cancelRateLimitUnit: '周期',
cancelRateLimitMax: '最大取消次数',
cancelRateLimitUnitMinute: '分钟',
cancelRateLimitUnitHour: '小时',
cancelRateLimitUnitDay: '',
cancelRateLimitWindowMode: '窗口模式',
cancelRateLimitWindowModeRolling: '滚动',
cancelRateLimitWindowModeFixed: '固定',
helpText: '帮助文本',
helpImageUrl: '帮助图片链接',
manageProviders: '管理服务商',
balancePaymentDisabled: '禁用余额充值',
noLimit: '留空表示不限制',
helpImage: '帮助图片',
helpImagePlaceholder: '上传或输入图片链接',
helpTextPlaceholder: '输入帮助说明文本...',
providerEasypay: '易支付',
providerAlipay: '支付宝官方',
providerWxpay: '微信官方',
providerStripe: 'Stripe',
typeDisabled: '类型已禁用',
enableTypesFirst: '请先在上方启用至少一种服务商',
easypayRedirect: '跳转',
paymentMode: '支付模式',
modeRedirect: '跳转',
modeQRCode: '二维码',
modePopup: '弹窗',
validationNameRequired: '服务商名称不能为空',
validationTypesRequired: '请至少选择一种支持的支付方式',
validationFieldRequired: '{field} 不能为空',
field_apiBase: 'API 基础地址',
field_notifyUrl: '异步通知地址',
field_returnUrl: '同步跳转地址',
callbackBaseUrl: '回调基础地址',
field_privateKey: '私钥',
field_publicKey: '公钥',
field_mchId: '商户号',
field_apiV3Key: 'API v3 密钥',
field_publicKeyId: '公钥 ID',
field_certSerial: '证书序列号',
field_secretKey: '密钥',
field_publishableKey: '公开密钥',
field_webhookSecret: 'Webhook 密钥',
field_cid: '支付渠道 ID',
field_cidAlipay: '支付宝渠道 ID',
field_cidWxpay: '微信渠道 ID',
stripeWebhookHint: '请在 Stripe Dashboard 中将以下地址配置为 Webhook 端点:',
limitsTitle: '限额配置',
limitSingleMin: '单笔最低',
limitSingleMax: '单笔最高',
limitDaily: '每日限额',
limitsHint: '全部留空使用全局配置,部分填写时留空项表示不限制',
limitsUseGlobal: '使用全局配置',
limitsNoLimit: '不限制',
productNamePrefix: '商品名前缀',
productNameSuffix: '商品名后缀',
preview: '预览',
loadBalanceStrategy: '负载均衡策略',
strategyRoundRobin: '轮询',
strategyLeastAmount: '最少金额',
providerManagement: '服务商管理',
providerManagementDesc: '管理支付服务商实例',
createProvider: '添加服务商',
editProvider: '编辑服务商',
deleteProvider: '删除服务商',
deleteProviderConfirm: '确定要删除此服务商吗?',
providerName: '服务商名称',
providerKey: '服务商类型',
selectProviderKey: '选择服务商类型',
providerConfig: '凭证配置',
noProviders: '暂无服务商实例',
supportedTypes: '支持的支付方式',
supportedTypesHint: '逗号分隔,如 alipay,wxpay',
refundEnabled: '允许退款',
},
smtp: { smtp: {
title: 'SMTP 设置', title: 'SMTP 设置',
description: '配置用于发送验证码的邮件服务', description: '配置用于发送验证码的邮件服务',
...@@ -5233,99 +5395,263 @@ export default { ...@@ -5233,99 +5395,263 @@ export default {
} }
}, },
// Sora 创作 // Payment System
sora: { payment: {
title: 'Sora 创作', title: '充值/订阅',
description: '使用 Sora AI 生成视频与图片', amountLabel: '充值金额',
notEnabled: '功能未开放', quickAmounts: '快捷金额',
notEnabledDesc: '管理员尚未启用 Sora 创作功能,请联系管理员开通。', customAmount: '自定义金额',
tabGenerate: '生成', enterAmount: '输入金额',
tabLibrary: '作品库', paymentMethod: '支付方式',
noActiveGenerations: '暂无生成任务', fee: '手续费',
startGenerating: '在下方输入提示词,开始创作', actualPay: '实付金额',
storage: '存储', createOrder: '确认支付',
promptPlaceholder: '描述你想创作的内容...', methods: {
generate: '生成', easypay: '易支付',
generating: '生成中...', alipay: '支付宝',
selectModel: '选择模型', wxpay: '微信支付',
statusPending: '等待中', stripe: 'Stripe',
statusGenerating: '生成中', card: '银行卡',
statusCompleted: '已完成', link: 'Link',
statusFailed: '失败', alipay_direct: '支付宝(直连)',
statusCancelled: '已取消', wxpay_direct: '微信支付(直连)',
cancel: '取消', },
delete: '删除', status: {
save: '保存到云端', pending: '待支付',
saved: '已保存', paid: '已支付',
retry: '重试', recharging: '充值中',
download: '下载', completed: '已完成',
justNow: '刚刚', expired: '已过期',
minutesAgo: '{n} 分钟前', cancelled: '已取消',
hoursAgo: '{n} 小时前', failed: '失败',
noSavedWorks: '暂无保存的作品', refund_requested: '退款申请中',
saveWorksHint: '生成完成后,将作品保存到作品库', refunding: '退款中',
filterAll: '全部', refunded: '已退款',
filterVideo: '视频', partially_refunded: '部分退款',
filterImage: '图片', refund_failed: '退款失败',
confirmDelete: '确定删除此作品?', },
loading: '加载中...', qr: {
loadMore: '加载更多', scanToPay: '请扫码支付',
noStorageWarningTitle: '未配置存储', scanAlipay: '支付宝扫码支付',
noStorageWarningDesc: '生成的内容仅通过上游临时链接提供,约 15 分钟后过期。建议管理员配置 S3 存储。', scanWxpay: '微信扫码支付',
mediaTypeVideo: '视频', scanAlipayHint: '请使用手机打开支付宝,扫描二维码完成支付',
mediaTypeImage: '图片', scanWxpayHint: '请使用手机打开微信,扫描二维码完成支付',
notificationCompleted: '生成完成', payInNewWindow: '请在新窗口中完成支付',
notificationFailed: '生成失败', payInNewWindowHint: '支付页面已在新窗口打开,请在新窗口中完成支付后返回此页面',
notificationCompletedBody: '您的 {model} 任务已完成', openPayWindow: '重新打开支付页面',
notificationFailedBody: '您的 {model} 任务失败了', expiresIn: '剩余支付时间',
upstreamExpiresSoon: '即将过期', expired: '订单已过期',
upstreamExpired: '链接已过期', expiredDesc: '订单已超时,请重新创建订单',
upstreamCountdown: '剩余 {time}', cancelled: '订单已取消',
previewTitle: '作品预览', cancelledDesc: '您已取消本次支付',
closePreview: '关闭', waitingPayment: '等待支付...',
beforeUnloadWarning: '您有未保存的生成内容,确定要离开吗?', cancelOrder: '取消订单',
downloadTitle: '下载生成内容', },
downloadExpirationWarning: '此链接约 15 分钟后过期,请尽快下载保存。', orders: {
downloadNow: '立即下载', title: '我的订单',
referenceImage: '参考图', empty: '暂无订单',
removeImage: '移除', orderId: '订单 ID',
imageTooLarge: '图片大小不能超过 20MB', orderNo: '订单编号',
// Sora 暗色主题新增 amount: '金额',
welcomeTitle: '将你的想象力变成视频', payAmount: '实付',
welcomeSubtitle: '输入一段描述,Sora 将为你创作逼真的视频或图片。尝试以下示例开始创作。', status: '状态',
queueTasks: '个任务', paymentMethod: '支付方式',
queueWaiting: '队列中等待', createdAt: '创建时间',
waiting: '等待中', cancel: '取消订单',
waited: '已等待', userId: '用户 ID',
errorCategory: '内容策略限制', orderType: '订单类型',
savedToCloud: '已保存到云端', actions: '操作',
downloadLocal: '本地下载', requestRefund: '申请退款',
canDownload: '可下载', },
regenrate: '重新生成', result: {
regenerate: '重新生成', success: '支付成功',
creatorPlaceholder: '描述你想要生成的视频或图片...', subscriptionSuccess: '订阅成功',
videoModels: '视频模型', failed: '支付失败',
imageModels: '图片模型', backToRecharge: '返回充值',
noStorageConfigured: '存储未配置', viewOrders: '查看订单',
selectCredential: '选择凭证', },
apiKeys: 'API 密钥', currentBalance: '当前余额',
subscriptions: '订阅', rechargeAccount: '充值账户',
subscription: '订阅', activeSubscription: '当前订阅',
noCredentialHint: '请先创建 API Key 或联系管理员分配订阅', noActiveSubscription: '暂无有效订阅',
uploadReference: '上传参考图片', tabTopUp: '充值',
generatingCount: '正在生成 {current}/{max}', tabSubscribe: '订阅',
noStorageToastMessage: '管理员未开通云存储,生成完成后请使用"本地下载"保存文件,否则将会丢失。', noPlans: '暂无可用订阅套餐',
galleryCount: '共 {count} 个作品', notAvailable: '充值功能暂未开放',
galleryEmptyTitle: '还没有任何作品', confirmSubscription: '确认订阅',
galleryEmptyDesc: '你的创作成果将会展示在这里。前往生成页,开始你的第一次创作吧。', confirmCancel: '确定要取消此订单吗?',
startCreating: '开始创作', amountTooLow: '最低金额为 {min}',
yesterday: '昨天', amountTooHigh: '最高金额为 {max}',
landscape: '横屏', amountNoMethod: '该金额没有可用的支付方式',
portrait: '竖屏', refundReason: '退款原因',
square: '方形', refundReasonPlaceholder: '请描述您的退款原因',
examplePrompt1: '一只金色的柴犬在东京涩谷街头散步,镜头跟随,电影感画面,4K 高清', stripeLoadFailed: '支付组件加载失败,请刷新页面重试',
examplePrompt2: '无人机航拍视角,冰岛极光下的冰川湖面反射绿色光芒,慢速推进', stripeMissingParams: '缺少订单ID或支付密钥',
examplePrompt3: '赛博朋克风格的未来城市,霓虹灯倒映在雨后积水中,夜景,电影级色彩', stripeNotConfigured: 'Stripe 未配置',
examplePrompt4: '水墨画风格,一叶扁舟在山水间漂泊,薄雾缭绕,中国古典意境' errors: {
} tooManyPending: '待支付订单过多(最多 {max} 个),请先完成或取消现有订单',
cancelRateLimited: '取消订单过于频繁,请稍后再试',
PENDING_ORDERS: '该服务商有未完成的订单,请等待订单完成后再操作',
},
stripePay: '立即支付',
stripeSuccessProcessing: '支付成功,正在处理订单...',
stripePopup: {
redirecting: '正在跳转到支付页面...',
loadingQr: '正在获取微信支付二维码...',
timeout: '等待支付凭证超时,请重试',
qrFailed: '未能获取微信支付二维码',
},
subscribeNow: '立即开通',
renewNow: '续费',
selectPlan: '选择套餐',
planFeatures: '功能特性',
planCard: {
rate: '倍率',
dailyLimit: '日限额',
weeklyLimit: '周限额',
monthlyLimit: '月限额',
quota: '配额',
unlimited: '无限制',
models: '模型',
},
days: '',
months: '个月',
years: '',
oneMonth: '1 个月',
oneYear: '1 年',
perMonth: '',
perYear: '',
admin: {
tabs: {
overview: '概览',
orders: '订单管理',
channels: '支付渠道',
plans: '订阅套餐',
},
todayRevenue: '今日收入',
totalRevenue: '总收入',
todayOrders: '今日订单',
orderCount: '订单数',
avgAmount: '平均金额',
revenue: '收入',
dailyRevenue: '每日收入',
paymentDistribution: '支付方式分布',
colUser: '用户',
topUsers: '消费排行',
noData: '暂无数据',
days: '',
weeks: '',
months: '',
searchOrders: '搜索订单...',
allStatuses: '全部状态',
allPaymentTypes: '全部支付方式',
allOrderTypes: '全部订单类型',
orderDetail: '订单详情',
orderType: '订单类型',
orders: '订单',
balanceOrder: '余额充值',
subscriptionOrder: '订阅',
paidAt: '支付时间',
completedAt: '完成时间',
expiresAt: '过期时间',
feeRate: '手续费率',
refund: '退款',
refundOrder: '退款订单',
refundAmount: '退款金额',
maxRefundable: '最大可退金额',
refundReason: '退款原因',
refundReasonPlaceholder: '请输入退款原因',
confirmRefund: '确认退款',
refundSuccess: '退款成功',
refundInfo: '退款信息',
refundEnabled: '允许退款',
alreadyRefunded: '已退款',
deductBalance: '扣除余额',
deductBalanceHint: '从用户余额中扣回充值金额',
userBalance: '用户余额',
orderAmount: '订单金额',
insufficientBalance: '余额不足,将扣至 $0',
noDeduction: '将不扣除用户余额',
forceRefund: '强制退款(忽略余额检查)',
orderCancelled: '订单已取消',
retry: '重试',
retrySuccess: '重试成功',
approveRefund: '批准退款',
retryRefund: '重试退款',
refundRequestInfo: '退款申请信息',
refundRequestedAt: '申请时间',
refundRequestedBy: '申请人',
refundRequestReason: '申请原因',
auditLogs: '操作日志',
operator: '操作人',
channelName: '渠道名称',
channelDescription: '渠道描述',
createChannel: '创建渠道',
editChannel: '编辑渠道',
deleteChannel: '删除渠道',
deleteChannelConfirm: '确定要删除此渠道吗?',
planName: '套餐名称',
planDescription: '套餐描述',
createPlan: '创建套餐',
editPlan: '编辑套餐',
deletePlan: '删除套餐',
deletePlanConfirm: '确定要删除此套餐吗?',
originalPrice: '原价',
price: '价格',
validityDays: '有效期(天)',
validityUnit: '有效期单位',
sortOrder: '排序',
forSale: '上架状态',
onSale: '上架',
offSale: '下架',
group: '分组',
groupId: '分组 ID',
features: '功能特性',
featuresHint: '每行一个特性',
featuresPlaceholder: '输入套餐特性...',
providerManagement: '服务商管理',
providerManagementDesc: '管理支付服务商实例',
createProvider: '创建服务商',
editProvider: '编辑服务商',
deleteProvider: '删除服务商',
deleteProviderConfirm: '确定要删除此服务商吗?',
providerName: '服务商名称',
providerKey: '服务商标识',
selectProviderKey: '选择服务商标识',
providerConfig: '服务商配置',
noProviders: '暂无服务商',
noProvidersHint: '创建一个服务商实例以开始接受支付',
supportedTypes: '支持的支付方式',
supportedTypesHint: '选择此服务商支持的支付方式',
rateMultiplier: '费率倍数',
dashboardTitle: '支付概览',
dashboardDesc: '充值订单统计与分析',
daySuffix: '',
paymentConfigTitle: '支付配置',
paymentConfigDesc: '管理支付服务商与相关设置',
plansPageTitle: '订阅套餐管理',
plansPageDesc: '管理订阅套餐配置',
tabPlanConfig: '套餐配置',
tabUserSubs: '用户订阅',
selectGroup: '请选择分组',
groupMissing: '缺失',
groupInfo: '分组信息',
platform: '平台',
rateMultiplierLabel: '倍率',
dailyLimit: '日限额',
weeklyLimit: '周限额',
monthlyLimit: '月限额',
unlimited: '无限制',
searchUserSubs: '搜索用户订阅...',
daily: '',
weekly: '',
monthly: '',
subsStatus: {
active: '生效中',
expired: '已过期',
revoked: '已撤销',
},
},
},
} }
...@@ -83,6 +83,15 @@ const routes: RouteRecordRaw[] = [ ...@@ -83,6 +83,15 @@ const routes: RouteRecordRaw[] = [
title: 'LinuxDo OAuth Callback' title: 'LinuxDo OAuth Callback'
} }
}, },
{
path: '/auth/oidc/callback',
name: 'OIDCOAuthCallback',
component: () => import('@/views/auth/OidcCallbackView.vue'),
meta: {
requiresAuth: false,
title: 'OIDC OAuth Callback'
}
},
{ {
path: '/forgot-password', path: '/forgot-password',
name: 'ForgotPassword', name: 'ForgotPassword',
...@@ -192,13 +201,73 @@ const routes: RouteRecordRaw[] = [ ...@@ -192,13 +201,73 @@ const routes: RouteRecordRaw[] = [
{ {
path: '/purchase', path: '/purchase',
name: 'PurchaseSubscription', name: 'PurchaseSubscription',
component: () => import('@/views/user/PurchaseSubscriptionView.vue'), component: () => import('@/views/user/PaymentView.vue'),
meta: { meta: {
requiresAuth: true, requiresAuth: true,
requiresAdmin: false, requiresAdmin: false,
title: 'Purchase Subscription', title: 'Purchase Subscription',
titleKey: 'purchase.title', titleKey: 'nav.buySubscription',
descriptionKey: 'purchase.description' descriptionKey: 'purchase.description',
requiresPayment: true
}
},
{
path: '/orders',
name: 'OrderList',
component: () => import('@/views/user/UserOrdersView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'My Orders',
titleKey: 'nav.myOrders',
requiresPayment: true
}
},
{
path: '/payment/qrcode',
name: 'PaymentQRCode',
component: () => import('@/views/user/PaymentQRCodeView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'Payment',
titleKey: 'payment.qr.scanToPay',
requiresPayment: true
}
},
{
path: '/payment/result',
name: 'PaymentResult',
component: () => import('@/views/user/PaymentResultView.vue'),
meta: {
requiresAuth: false,
requiresAdmin: false,
title: 'Payment Result',
titleKey: 'payment.result.success',
requiresPayment: false
}
},
{
path: '/payment/stripe',
name: 'StripePayment',
component: () => import('@/views/user/StripePaymentView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'Stripe Payment',
titleKey: 'payment.stripePay',
requiresPayment: true
}
},
{
path: '/payment/stripe-popup',
name: 'StripePopup',
component: () => import('@/views/user/StripePopupView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'Payment',
requiresPayment: true
} }
}, },
{ {
...@@ -386,6 +455,45 @@ const routes: RouteRecordRaw[] = [ ...@@ -386,6 +455,45 @@ const routes: RouteRecordRaw[] = [
} }
}, },
// ==================== Payment Admin Routes ====================
{
path: '/admin/orders/dashboard',
name: 'AdminPaymentDashboard',
component: () => import('@/views/admin/orders/AdminPaymentDashboardView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Payment Dashboard',
titleKey: 'nav.paymentDashboard',
requiresPayment: true
}
},
{
path: '/admin/orders',
name: 'AdminOrders',
component: () => import('@/views/admin/orders/AdminOrdersView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Order Management',
titleKey: 'nav.orderManagement',
requiresPayment: true
}
},
{
path: '/admin/orders/plans',
name: 'AdminPaymentPlans',
component: () => import('@/views/admin/orders/AdminPaymentPlansView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Subscription Plans',
titleKey: 'nav.paymentPlans',
requiresPayment: true
}
},
// ==================== 404 Not Found ==================== // ==================== 404 Not Found ====================
{ {
path: '/:pathMatch(.*)*', path: '/:pathMatch(.*)*',
...@@ -502,6 +610,16 @@ router.beforeEach((to, _from, next) => { ...@@ -502,6 +610,16 @@ router.beforeEach((to, _from, next) => {
return return
} }
// Check payment requirement (internal payment system only)
if (to.meta.requiresPayment) {
const paymentEnabled = appStore.cachedPublicSettings?.payment_enabled
if (!paymentEnabled) {
next(authStore.isAdmin ? '/admin/dashboard' : '/dashboard')
return
}
}
// 简易模式下限制访问某些页面 // 简易模式下限制访问某些页面
if (authStore.isSimpleMode) { if (authStore.isSimpleMode) {
const restrictedPaths = [ const restrictedPaths = [
......
...@@ -42,5 +42,21 @@ declare module 'vue-router' { ...@@ -42,5 +42,21 @@ declare module 'vue-router' {
* @default false * @default false
*/ */
hideInMenu?: boolean hideInMenu?: boolean
/**
* Whether this route requires internal payment system to be enabled
* @default false
*/
requiresPayment?: boolean
/**
* i18n key for the page title
*/
titleKey?: string
/**
* i18n key for the page description
*/
descriptionKey?: string
} }
} }
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia' import { setActivePinia, createPinia } from 'pinia'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { getPublicSettings } from '@/api/auth'
// Mock API 模块 // Mock API 模块
vi.mock('@/api/admin/system', () => ({ vi.mock('@/api/admin/system', () => ({
...@@ -15,12 +16,14 @@ describe('useAppStore', () => { ...@@ -15,12 +16,14 @@ describe('useAppStore', () => {
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()) setActivePinia(createPinia())
vi.useFakeTimers() vi.useFakeTimers()
localStorage.clear()
// 清除 window.__APP_CONFIG__ // 清除 window.__APP_CONFIG__
delete (window as any).__APP_CONFIG__ delete (window as any).__APP_CONFIG__
}) })
afterEach(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers()
localStorage.clear()
}) })
// --- Toast 消息管理 --- // --- Toast 消息管理 ---
...@@ -291,5 +294,43 @@ describe('useAppStore', () => { ...@@ -291,5 +294,43 @@ describe('useAppStore', () => {
expect(store.publicSettingsLoaded).toBe(false) expect(store.publicSettingsLoaded).toBe(false)
expect(store.cachedPublicSettings).toBeNull() expect(store.cachedPublicSettings).toBeNull()
}) })
it('fetchPublicSettings(force) 会同步更新运行时注入配置', async () => {
vi.mocked(getPublicSettings).mockResolvedValue({
registration_enabled: false,
email_verify_enabled: false,
registration_email_suffix_whitelist: [],
promo_code_enabled: true,
password_reset_enabled: false,
invitation_code_enabled: false,
turnstile_enabled: false,
turnstile_site_key: '',
site_name: 'Updated Site',
site_logo: '',
site_subtitle: '',
api_base_url: '',
contact_info: '',
doc_url: '',
home_content: '',
hide_ccs_import_button: false,
purchase_subscription_enabled: false,
purchase_subscription_url: '',
table_default_page_size: 1000,
table_page_size_options: [20, 100, 1000],
custom_menu_items: [],
custom_endpoints: [],
linuxdo_oauth_enabled: false,
backend_mode_enabled: false,
version: '1.0.0'
})
const store = useAppStore()
await store.fetchPublicSettings(true)
expect((window as any).__APP_CONFIG__.table_default_page_size).toBe(1000)
expect((window as any).__APP_CONFIG__.table_page_size_options).toEqual([20, 100, 1000])
expect(localStorage.getItem('table-page-size')).toBeNull()
expect(localStorage.getItem('table-page-size-source')).toBeNull()
})
}) })
}) })
...@@ -48,6 +48,7 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => { ...@@ -48,6 +48,7 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
const opsMonitoringEnabled = ref(readCachedBool('ops_monitoring_enabled_cached', true)) const opsMonitoringEnabled = ref(readCachedBool('ops_monitoring_enabled_cached', true))
const opsRealtimeMonitoringEnabled = ref(readCachedBool('ops_realtime_monitoring_enabled_cached', true)) const opsRealtimeMonitoringEnabled = ref(readCachedBool('ops_realtime_monitoring_enabled_cached', true))
const opsQueryModeDefault = ref(readCachedString('ops_query_mode_default_cached', 'auto')) const opsQueryModeDefault = ref(readCachedString('ops_query_mode_default_cached', 'auto'))
const paymentEnabled = ref(readCachedBool('payment_enabled_cached', false))
const customMenuItems = ref<CustomMenuItem[]>([]) const customMenuItems = ref<CustomMenuItem[]>([])
async function fetch(force = false): Promise<void> { async function fetch(force = false): Promise<void> {
...@@ -56,7 +57,10 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => { ...@@ -56,7 +57,10 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
loading.value = true loading.value = true
try { try {
const settings = await adminAPI.settings.getSettings() const [settings, paymentConfigResp] = await Promise.all([
adminAPI.settings.getSettings(),
adminAPI.payment.getConfig()
])
opsMonitoringEnabled.value = settings.ops_monitoring_enabled ?? true opsMonitoringEnabled.value = settings.ops_monitoring_enabled ?? true
writeCachedBool('ops_monitoring_enabled_cached', opsMonitoringEnabled.value) writeCachedBool('ops_monitoring_enabled_cached', opsMonitoringEnabled.value)
...@@ -68,6 +72,9 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => { ...@@ -68,6 +72,9 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
customMenuItems.value = Array.isArray(settings.custom_menu_items) ? settings.custom_menu_items : [] customMenuItems.value = Array.isArray(settings.custom_menu_items) ? settings.custom_menu_items : []
paymentEnabled.value = paymentConfigResp.data?.enabled ?? false
writeCachedBool('payment_enabled_cached', paymentEnabled.value)
loaded.value = true loaded.value = true
} catch (err) { } catch (err) {
// Keep cached/default value: do not "flip" the UI based on a transient fetch failure. // Keep cached/default value: do not "flip" the UI based on a transient fetch failure.
...@@ -90,6 +97,12 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => { ...@@ -90,6 +97,12 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
loaded.value = true loaded.value = true
} }
function setPaymentEnabledLocal(value: boolean) {
paymentEnabled.value = value
writeCachedBool('payment_enabled_cached', value)
loaded.value = true
}
function setOpsQueryModeDefaultLocal(value: string) { function setOpsQueryModeDefaultLocal(value: string) {
opsQueryModeDefault.value = value || 'auto' opsQueryModeDefault.value = value || 'auto'
writeCachedString('ops_query_mode_default_cached', opsQueryModeDefault.value) writeCachedString('ops_query_mode_default_cached', opsQueryModeDefault.value)
...@@ -126,10 +139,12 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => { ...@@ -126,10 +139,12 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
opsMonitoringEnabled, opsMonitoringEnabled,
opsRealtimeMonitoringEnabled, opsRealtimeMonitoringEnabled,
opsQueryModeDefault, opsQueryModeDefault,
paymentEnabled,
customMenuItems, customMenuItems,
fetch, fetch,
setOpsMonitoringEnabledLocal, setOpsMonitoringEnabledLocal,
setOpsRealtimeMonitoringEnabledLocal, setOpsRealtimeMonitoringEnabledLocal,
setPaymentEnabledLocal,
setOpsQueryModeDefaultLocal setOpsQueryModeDefaultLocal
} }
}) })
...@@ -284,6 +284,9 @@ export const useAppStore = defineStore('app', () => { ...@@ -284,6 +284,9 @@ export const useAppStore = defineStore('app', () => {
* Apply settings to store state (internal helper to avoid code duplication) * Apply settings to store state (internal helper to avoid code duplication)
*/ */
function applySettings(config: PublicSettings): void { function applySettings(config: PublicSettings): void {
if (typeof window !== 'undefined') {
window.__APP_CONFIG__ = { ...config }
}
cachedPublicSettings.value = config cachedPublicSettings.value = config
siteName.value = config.site_name || 'TrafficAPI' siteName.value = config.site_name || 'TrafficAPI'
siteLogo.value = config.site_logo || '' siteLogo.value = config.site_logo || ''
...@@ -327,11 +330,14 @@ export const useAppStore = defineStore('app', () => { ...@@ -327,11 +330,14 @@ export const useAppStore = defineStore('app', () => {
doc_url: docUrl.value, doc_url: docUrl.value,
home_content: '', home_content: '',
hide_ccs_import_button: false, hide_ccs_import_button: false,
purchase_subscription_enabled: false, payment_enabled: false,
purchase_subscription_url: '', table_default_page_size: 20,
table_page_size_options: [10, 20, 50, 100],
custom_menu_items: [], custom_menu_items: [],
custom_endpoints: [], custom_endpoints: [],
linuxdo_oauth_enabled: false, linuxdo_oauth_enabled: false,
oidc_oauth_enabled: false,
oidc_oauth_provider_name: 'OIDC',
backend_mode_enabled: false, backend_mode_enabled: false,
version: siteVersion.value version: siteVersion.value
} }
......
...@@ -9,6 +9,7 @@ export { useAdminSettingsStore } from './adminSettings' ...@@ -9,6 +9,7 @@ export { useAdminSettingsStore } from './adminSettings'
export { useSubscriptionStore } from './subscriptions' export { useSubscriptionStore } from './subscriptions'
export { useOnboardingStore } from './onboarding' export { useOnboardingStore } from './onboarding'
export { useAnnouncementStore } from './announcements' export { useAnnouncementStore } from './announcements'
export { usePaymentStore } from './payment'
// Re-export types for convenience // Re-export types for convenience
export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types' export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types'
......
/**
* Payment Store
* Manages payment configuration, current order state, and subscription plans
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { paymentAPI } from '@/api/payment'
import type { PaymentConfig, PaymentOrder, SubscriptionPlan, CreateOrderRequest } from '@/types/payment'
export const usePaymentStore = defineStore('payment', () => {
// ==================== State ====================
/** Payment configuration from backend */
const config = ref<PaymentConfig | null>(null)
/** Currently active order (for payment flow) */
const currentOrder = ref<PaymentOrder | null>(null)
/** Available subscription plans */
const plans = ref<SubscriptionPlan[]>([])
const configLoading = ref(false)
const configLoaded = ref(false)
// ==================== Actions ====================
/** Fetch payment configuration */
async function fetchConfig(force = false): Promise<PaymentConfig | null> {
if (configLoaded.value && !force) return config.value
if (configLoading.value) return config.value
configLoading.value = true
try {
const response = await paymentAPI.getConfig()
config.value = response.data
configLoaded.value = true
return config.value
} catch (error: unknown) {
console.error('[payment] Failed to fetch config:', error)
return null
} finally {
configLoading.value = false
}
}
/** Fetch available subscription plans */
async function fetchPlans(): Promise<SubscriptionPlan[]> {
try {
const response = await paymentAPI.getPlans()
// Backend returns features as newline-separated string; parse to array
plans.value = (response.data || []).map((p: Omit<SubscriptionPlan, 'features'> & { features: string | string[] }) => ({
...p,
features: typeof p.features === 'string'
? p.features.split('\n').map((f: string) => f.trim()).filter(Boolean)
: (p.features || []),
}))
return plans.value
} catch (error: unknown) {
console.error('[payment] Failed to fetch plans:', error)
return []
}
}
/** Create a new order and set it as current */
async function createOrder(params: CreateOrderRequest) {
const response = await paymentAPI.createOrder(params)
return response.data
}
/** Poll order status by ID */
async function pollOrderStatus(orderId: number): Promise<PaymentOrder | null> {
try {
const response = await paymentAPI.getOrder(orderId)
const order = response.data
if (currentOrder.value?.id === orderId) {
currentOrder.value = order
}
return order
} catch (error: unknown) {
console.error('[payment] Failed to poll order status:', error)
return null
}
}
/** Clear current order state */
function clearCurrentOrder() {
currentOrder.value = null
}
return {
config,
currentOrder,
plans,
configLoading,
configLoaded,
fetchConfig,
fetchPlans,
createOrder,
pollOrderStatus,
clearCurrentOrder
}
})
...@@ -16,20 +16,22 @@ ...@@ -16,20 +16,22 @@
@apply min-h-screen; @apply min-h-screen;
} }
/* 自定义滚动条 - 默认隐藏,悬停或滚动时显示 */ /* 自定义滚动条 - 仅针对 Firefox,避免 Chrome 取消 webkit 的全局定制 */
* { @supports (-moz-appearance:none) {
scrollbar-width: thin; * {
scrollbar-color: transparent transparent; scrollbar-width: thin;
} scrollbar-color: transparent transparent;
}
*:hover, *:hover,
*:focus-within { *:focus-within {
scrollbar-color: rgba(156, 163, 175, 0.5) transparent; scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
} }
.dark *:hover, .dark *:hover,
.dark *:focus-within { .dark *:focus-within {
scrollbar-color: rgba(75, 85, 99, 0.5) transparent; scrollbar-color: rgba(75, 85, 99, 0.5) transparent;
}
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
...@@ -58,36 +60,7 @@ ...@@ -58,36 +60,7 @@
@apply bg-primary-500/20 text-primary-900 dark:text-primary-100; @apply bg-primary-500/20 text-primary-900 dark:text-primary-100;
} }
/*
* 表格滚动容器:始终显示滚动条,不跟随全局悬停策略。
*
* 浏览器兼容性说明:
* - Chrome 121+ 原生支持 scrollbar-color / scrollbar-width。
* 一旦元素匹配了这两个标准属性,::-webkit-scrollbar-* 被完全忽略。
* 全局 * { scrollbar-width: thin } 使所有元素都走标准属性,
* 因此 Chrome 121+ 只看 scrollbar-color。
* - Chrome < 121 不认识标准属性,只看 ::-webkit-scrollbar-*,
* 所以保留 ::-webkit-scrollbar-thumb 作为回退。
* - Firefox 始终只看 scrollbar-color / scrollbar-width。
*/
.table-wrapper {
scrollbar-width: auto;
scrollbar-color: rgba(156, 163, 175, 0.7) transparent;
}
.dark .table-wrapper {
scrollbar-color: rgba(75, 85, 99, 0.7) transparent;
}
/* 旧版 Chrome (< 121) 兼容回退 */
.table-wrapper::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.table-wrapper::-webkit-scrollbar-thumb {
@apply rounded-full bg-gray-400/70;
}
.dark .table-wrapper::-webkit-scrollbar-thumb {
@apply rounded-full bg-gray-500/70;
}
} }
@layer components { @layer components {
...@@ -151,6 +124,27 @@ ...@@ -151,6 +124,27 @@
@apply dark:shadow-amber-500/20; @apply dark:shadow-amber-500/20;
} }
.btn-stripe {
@apply bg-[#635bff] text-white shadow-md shadow-[#635bff]/25;
@apply hover:bg-[#5851ea] hover:shadow-lg hover:shadow-[#635bff]/30;
@apply dark:bg-[#7a73ff] dark:shadow-[#7a73ff]/20;
@apply dark:hover:bg-[#635bff];
}
.btn-alipay {
@apply bg-[#00AEEF] text-white shadow-md shadow-[#00AEEF]/25;
@apply hover:bg-[#009dd6] hover:shadow-lg hover:shadow-[#00AEEF]/30;
@apply active:bg-[#008cbe];
@apply dark:shadow-[#00AEEF]/20;
}
.btn-wxpay {
@apply bg-[#2BB741] text-white shadow-md shadow-[#2BB741]/25;
@apply hover:bg-[#24a038] hover:shadow-lg hover:shadow-[#2BB741]/30;
@apply active:bg-[#1d8a2f];
@apply dark:shadow-[#2BB741]/20;
}
.btn-sm { .btn-sm {
@apply rounded-lg px-3 py-1.5 text-xs; @apply rounded-lg px-3 py-1.5 text-xs;
} }
...@@ -558,12 +552,18 @@ ...@@ -558,12 +552,18 @@
border-right: 1px solid rgba(139, 92, 246, 0.08); border-right: 1px solid rgba(139, 92, 246, 0.08);
@apply flex flex-col; @apply flex flex-col;
@apply transition-transform duration-300; @apply transition-transform duration-300;
transition-property: width, transform;
} }
.sidebar-header { .sidebar-header {
@apply h-16 px-6; @apply h-16 px-6;
@apply flex items-center gap-3; @apply flex items-center gap-3;
border-bottom: 1px solid rgba(255, 255, 255, 0.05); border-bottom: 1px solid rgba(255, 255, 255, 0.05);
@apply overflow-hidden;
@apply border-b border-gray-100 dark:border-dark-800;
transition:
padding 0.2s ease,
gap 0.2s ease;
} }
.sidebar-nav { .sidebar-nav {
...@@ -571,7 +571,8 @@ ...@@ -571,7 +571,8 @@
} }
.sidebar-link { .sidebar-link {
@apply flex items-center gap-3 rounded-xl px-3 py-2.5; @apply flex items-center gap-3 rounded-xl py-2.5;
@apply overflow-hidden;
@apply text-sm font-medium; @apply text-sm font-medium;
color: rgba(148, 163, 184, 0.85); color: rgba(148, 163, 184, 0.85);
@apply transition-all duration-200; @apply transition-all duration-200;
...@@ -580,6 +581,10 @@ ...@@ -580,6 +581,10 @@
.sidebar-link:hover { .sidebar-link:hover {
background: rgba(255, 255, 255, 0.055); background: rgba(255, 255, 255, 0.055);
color: rgba(226, 232, 240, 1); color: rgba(226, 232, 240, 1);
@apply hover:bg-gray-100 dark:hover:bg-dark-800;
@apply hover:text-gray-900 dark:hover:text-white;
padding-left: 1.0625rem;
padding-right: 0.875rem;
} }
.sidebar-link-active { .sidebar-link-active {
......
...@@ -104,11 +104,14 @@ export interface PublicSettings { ...@@ -104,11 +104,14 @@ export interface PublicSettings {
doc_url: string doc_url: string
home_content: string home_content: string
hide_ccs_import_button: boolean hide_ccs_import_button: boolean
purchase_subscription_enabled: boolean payment_enabled: boolean
purchase_subscription_url: string table_default_page_size: number
table_page_size_options: number[]
custom_menu_items: CustomMenuItem[] custom_menu_items: CustomMenuItem[]
custom_endpoints: CustomEndpoint[] custom_endpoints: CustomEndpoint[]
linuxdo_oauth_enabled: boolean linuxdo_oauth_enabled: boolean
oidc_oauth_enabled: boolean
oidc_oauth_provider_name: string
backend_mode_enabled: boolean backend_mode_enabled: boolean
version: string version: string
} }
...@@ -366,6 +369,13 @@ export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' ...@@ -366,6 +369,13 @@ export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
export type SubscriptionType = 'standard' | 'subscription' export type SubscriptionType = 'standard' | 'subscription'
export interface OpenAIMessagesDispatchModelConfig {
opus_mapped_model?: string
sonnet_mapped_model?: string
haiku_mapped_model?: string
exact_model_mappings?: Record<string, string>
}
export interface Group { export interface Group {
id: number id: number
name: string name: string
...@@ -388,6 +398,8 @@ export interface Group { ...@@ -388,6 +398,8 @@ export interface Group {
fallback_group_id_on_invalid_request: number | null fallback_group_id_on_invalid_request: number | null
// OpenAI Messages 调度开关(用户侧需要此字段判断是否展示 Claude Code 教程) // OpenAI Messages 调度开关(用户侧需要此字段判断是否展示 Claude Code 教程)
allow_messages_dispatch?: boolean allow_messages_dispatch?: boolean
default_mapped_model?: string
messages_dispatch_model_config?: OpenAIMessagesDispatchModelConfig
require_oauth_only: boolean require_oauth_only: boolean
require_privacy_set: boolean require_privacy_set: boolean
created_at: string created_at: string
...@@ -414,6 +426,7 @@ export interface AdminGroup extends Group { ...@@ -414,6 +426,7 @@ export interface AdminGroup extends Group {
// OpenAI Messages 调度配置(仅 openai 平台使用) // OpenAI Messages 调度配置(仅 openai 平台使用)
default_mapped_model?: string default_mapped_model?: string
messages_dispatch_model_config?: OpenAIMessagesDispatchModelConfig
// 分组排序 // 分组排序
sort_order: number sort_order: number
...@@ -1350,6 +1363,8 @@ export interface UsageQueryParams { ...@@ -1350,6 +1363,8 @@ export interface UsageQueryParams {
billing_type?: number | null billing_type?: number | null
start_date?: string start_date?: string
end_date?: string end_date?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
} }
// ==================== Account Usage Statistics ==================== // ==================== Account Usage Statistics ====================
...@@ -1616,3 +1631,6 @@ export interface UpdateScheduledTestPlanRequest { ...@@ -1616,3 +1631,6 @@ export interface UpdateScheduledTestPlanRequest {
max_results?: number max_results?: number
auto_recover?: boolean auto_recover?: boolean
} }
// Payment types
export type { SubscriptionPlan, PaymentOrder, CheckoutInfoResponse } from './payment'
/**
* Payment System Type Definitions
*/
// ==================== Enums / Union Types ====================
export type OrderStatus =
| 'PENDING'
| 'PAID'
| 'RECHARGING'
| 'COMPLETED'
| 'EXPIRED'
| 'CANCELLED'
| 'FAILED'
| 'REFUND_REQUESTED'
| 'REFUNDING'
| 'PARTIALLY_REFUNDED'
| 'REFUNDED'
| 'REFUND_FAILED'
export type PaymentType = 'alipay' | 'wxpay' | 'alipay_direct' | 'wxpay_direct' | 'stripe' | 'easypay'
export type OrderType = 'balance' | 'subscription'
// ==================== Configuration ====================
export interface PaymentConfig {
payment_enabled: boolean
min_amount: number
max_amount: number
daily_limit: number
max_pending_orders: number
order_timeout_minutes: number
balance_disabled: boolean
enabled_payment_types: PaymentType[]
help_image_url: string
help_text: string
stripe_publishable_key: string
}
export interface MethodLimit {
daily_limit: number
daily_used: number
daily_remaining: number
single_min: number
single_max: number
fee_rate: number
available: boolean
}
/** Response from /payment/limits API */
export interface MethodLimitsResponse {
methods: Record<string, MethodLimit>
global_min: number // widest min across all methods; 0 = no minimum
global_max: number // widest max across all methods; 0 = no maximum
}
/** Response from /payment/checkout-info API — single call for the payment page */
export interface CheckoutInfoResponse {
methods: Record<string, MethodLimit>
global_min: number
global_max: number
plans: SubscriptionPlan[]
balance_disabled: boolean
help_text: string
help_image_url: string
stripe_publishable_key: string
}
// ==================== Orders ====================
export interface PaymentOrder {
id: number
user_id: number
amount: number
pay_amount: number
fee_rate: number
payment_type: string
out_trade_no: string
status: OrderStatus
order_type: OrderType
created_at: string
expires_at: string
paid_at?: string
completed_at?: string
refund_amount: number
refund_reason?: string
refund_requested_at?: string
refund_requested_by?: number
refund_request_reason?: string
plan_id?: number
}
// ==================== Plans & Channels ====================
export interface SubscriptionPlan {
id: number
group_id: number
group_platform?: string
group_name?: string
rate_multiplier?: number
daily_limit_usd?: number | null
weekly_limit_usd?: number | null
monthly_limit_usd?: number | null
supported_model_scopes?: string[]
name: string
description: string
price: number
original_price?: number
validity_days: number
validity_unit: string
/** Stored as JSON string in backend; API layer should parse before use */
features: string[]
for_sale: boolean
sort_order: number
}
export interface PaymentChannel {
id: number
group_id?: number
name: string
platform: string
rate_multiplier: number
description: string
models: string[]
features: string[]
enabled: boolean
}
// ==================== Providers ====================
export interface ProviderInstance {
id: number
provider_key: string
name: string
config: Record<string, string>
supported_types: string[]
enabled: boolean
payment_mode: string
refund_enabled: boolean
limits: string
sort_order: number
}
// ==================== Request / Response ====================
export interface CreateOrderRequest {
amount: number
payment_type: string
order_type: string
plan_id?: number
}
export interface CreateOrderResult {
order_id: number
pay_url?: string
qr_code?: string
client_secret?: string
pay_amount: number
expires_at: string
payment_mode?: string
}
export interface DashboardStats {
today_amount: number
total_amount: number
today_count: number
total_count: number
avg_amount: number
daily_series: { date: string; amount: number; count: number }[]
payment_methods: { type: string; amount: number; count: number }[]
top_users: { user_id: number; email: string; amount: number }[]
}
import { afterEach, describe, expect, it } from 'vitest'
import {
DEFAULT_TABLE_PAGE_SIZE,
DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
getConfiguredTableDefaultPageSize,
getConfiguredTablePageSizeOptions,
normalizeTablePageSize
} from '@/utils/tablePreferences'
describe('tablePreferences', () => {
afterEach(() => {
delete window.__APP_CONFIG__
})
it('returns built-in defaults when app config is missing', () => {
expect(getConfiguredTableDefaultPageSize()).toBe(DEFAULT_TABLE_PAGE_SIZE)
expect(getConfiguredTablePageSizeOptions()).toEqual(DEFAULT_TABLE_PAGE_SIZE_OPTIONS)
})
it('uses configured defaults when app config is valid', () => {
window.__APP_CONFIG__ = {
table_default_page_size: 50,
table_page_size_options: [20, 50, 100]
} as any
expect(getConfiguredTableDefaultPageSize()).toBe(50)
expect(getConfiguredTablePageSizeOptions()).toEqual([20, 50, 100])
})
it('allows default page size outside selectable options', () => {
window.__APP_CONFIG__ = {
table_default_page_size: 1000,
table_page_size_options: [20, 50, 100]
} as any
expect(getConfiguredTableDefaultPageSize()).toBe(1000)
expect(getConfiguredTablePageSizeOptions()).toEqual([20, 50, 100])
expect(normalizeTablePageSize(1000)).toBe(100)
expect(normalizeTablePageSize(35)).toBe(50)
})
it('normalizes invalid options without rewriting the configured default itself', () => {
window.__APP_CONFIG__ = {
table_default_page_size: 35,
table_page_size_options: [1001, 50, 10, 10, 2, 0]
} as any
expect(getConfiguredTableDefaultPageSize()).toBe(35)
expect(getConfiguredTablePageSizeOptions()).toEqual([10, 50])
expect(normalizeTablePageSize(undefined)).toBe(50)
})
it('normalizes page size against configured options by rounding up', () => {
window.__APP_CONFIG__ = {
table_default_page_size: 20,
table_page_size_options: [20, 50, 1000]
} as any
expect(normalizeTablePageSize(20)).toBe(20)
expect(normalizeTablePageSize(30)).toBe(50)
expect(normalizeTablePageSize(100)).toBe(1000)
expect(normalizeTablePageSize(1500)).toBe(1000)
expect(normalizeTablePageSize(undefined)).toBe(20)
})
it('keeps built-in selectable defaults at 10, 20, 50, 100', () => {
window.__APP_CONFIG__ = {
table_default_page_size: 1000
} as any
expect(getConfiguredTablePageSizeOptions()).toEqual([10, 20, 50, 100])
})
})
/**
* Centralized API error message extraction
*
* The API client interceptor rejects with a plain object: { status, code, message, error }
* This utility extracts the user-facing message from any error shape.
*/
interface ApiErrorLike {
status?: number
code?: number | string
message?: string
error?: string
reason?: string
metadata?: Record<string, unknown>
response?: {
data?: {
detail?: string
message?: string
code?: number | string
}
}
}
/**
* Extract the error code from an API error object.
*/
export function extractApiErrorCode(err: unknown): string | undefined {
if (!err || typeof err !== 'object') return undefined
const e = err as ApiErrorLike
const code = e.code ?? e.reason ?? e.response?.data?.code
return code != null ? String(code) : undefined
}
/**
* Extract a displayable error message from an API error.
*
* @param err - The caught error (unknown type)
* @param fallback - Fallback message if none can be extracted (use t('common.error') or similar)
* @param i18nMap - Optional map of error codes to i18n translated strings
*/
export function extractApiErrorMessage(
err: unknown,
fallback = 'Unknown error',
i18nMap?: Record<string, string>,
): string {
if (!err) return fallback
// Try i18n mapping by error code first
if (i18nMap) {
const code = extractApiErrorCode(err)
if (code && i18nMap[code]) return i18nMap[code]
}
// Plain object from API client interceptor (most common case)
if (typeof err === 'object' && err !== null) {
const e = err as ApiErrorLike
// Interceptor shape: { message, error }
if (e.message) return e.message
if (e.error) return e.error
// Legacy axios shape: { response.data.detail }
if (e.response?.data?.detail) return e.response.data.detail
if (e.response?.data?.message) return e.response.data.message
}
// Standard Error
if (err instanceof Error) return err.message
// Last resort
const str = String(err)
return str === '[object Object]' ? fallback : str
}
/**
* Detect whether the current device is mobile.
* Uses navigator.userAgentData (modern API) with UA regex fallback.
*/
export function isMobileDevice(): boolean {
const nav = navigator as unknown as Record<string, unknown>
if (nav.userAgentData && typeof (nav.userAgentData as Record<string, unknown>).mobile === 'boolean') {
return (nav.userAgentData as Record<string, unknown>).mobile as boolean
}
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent)
}
/**
* Centralized platform color definitions.
*
* All components that need platform-specific styling should import from here
* instead of defining their own color mappings.
*/
export type Platform = 'anthropic' | 'openai' | 'antigravity' | 'gemini'
// ── Badge (bg + text + border, for inline badges with border) ───────
const BADGE: Record<Platform, string> = {
anthropic: 'bg-orange-500/10 text-orange-600 border-orange-500/30 dark:text-orange-400',
openai: 'bg-green-500/10 text-green-600 border-green-500/30 dark:text-green-400',
antigravity: 'bg-purple-500/10 text-purple-600 border-purple-500/30 dark:text-purple-400',
gemini: 'bg-blue-500/10 text-blue-600 border-blue-500/30 dark:text-blue-400',
}
const BADGE_DEFAULT = 'bg-slate-500/10 text-slate-600 border-slate-500/30 dark:text-slate-400'
// ── Light badge (softer bg, no border) ──────────────────────────────
const BADGE_LIGHT: Record<Platform, string> = {
anthropic: 'bg-orange-500/10 text-orange-600 dark:bg-orange-500/10 dark:text-orange-300',
openai: 'bg-green-500/10 text-green-600 dark:bg-green-500/10 dark:text-green-300',
antigravity: 'bg-purple-500/10 text-purple-600 dark:bg-purple-500/10 dark:text-purple-300',
gemini: 'bg-blue-500/10 text-blue-600 dark:bg-blue-500/10 dark:text-blue-300',
}
// ── Border ──────────────────────────────────────────────────────────
const BORDER: Record<Platform, string> = {
anthropic: 'border-orange-500/20 dark:border-orange-500/20',
openai: 'border-green-500/20 dark:border-green-500/20',
antigravity: 'border-purple-500/20 dark:border-purple-500/20',
gemini: 'border-blue-500/20 dark:border-blue-500/20',
}
const BORDER_DEFAULT = 'border-gray-200 dark:border-dark-700'
// ── Accent bar (gradient) ───────────────────────────────────────────
const ACCENT_BAR: Record<Platform, string> = {
anthropic: 'bg-gradient-to-r from-orange-400 to-orange-500',
openai: 'bg-gradient-to-r from-emerald-400 to-emerald-500',
antigravity: 'bg-gradient-to-r from-purple-400 to-purple-500',
gemini: 'bg-gradient-to-r from-blue-400 to-blue-500',
}
const ACCENT_BAR_DEFAULT = 'bg-gradient-to-r from-primary-400 to-primary-500'
// ── Text (price, icon) ─────────────────────────────────────────────
const TEXT: Record<Platform, string> = {
anthropic: 'text-orange-600 dark:text-orange-400',
openai: 'text-emerald-600 dark:text-emerald-400',
antigravity: 'text-purple-600 dark:text-purple-400',
gemini: 'text-blue-600 dark:text-blue-400',
}
const TEXT_DEFAULT = 'text-primary-600 dark:text-primary-400'
// ── Icon (check mark etc.) ──────────────────────────────────────────
const ICON: Record<Platform, string> = {
anthropic: 'text-orange-500 dark:text-orange-400',
openai: 'text-emerald-500 dark:text-emerald-400',
antigravity: 'text-purple-500 dark:text-purple-400',
gemini: 'text-blue-500 dark:text-blue-400',
}
const ICON_DEFAULT = 'text-primary-500 dark:text-primary-400'
// ── Button (solid bg) ───────────────────────────────────────────────
const BUTTON: Record<Platform, string> = {
anthropic: 'bg-orange-500 text-white hover:bg-orange-600 active:bg-orange-700 dark:bg-orange-500/80 dark:hover:bg-orange-500',
openai: 'bg-green-600 text-white hover:bg-green-700 active:bg-green-800 dark:bg-green-600/80 dark:hover:bg-green-600',
antigravity: 'bg-purple-500 text-white hover:bg-purple-600 active:bg-purple-700 dark:bg-purple-500/80 dark:hover:bg-purple-500',
gemini: 'bg-blue-500 text-white hover:bg-blue-600 active:bg-blue-700 dark:bg-blue-500/80 dark:hover:bg-blue-500',
}
const BUTTON_DEFAULT = 'bg-primary-500 text-white hover:bg-primary-600 dark:bg-primary-600 dark:hover:bg-primary-500'
// ── Discount badge ──────────────────────────────────────────────────
const DISCOUNT: Record<Platform, string> = {
anthropic: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
openai: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
antigravity: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
gemini: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
}
const DISCOUNT_DEFAULT = 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
// ── Header gradient (subscription confirm) ─────────────────────────
const GRADIENT: Record<Platform, string> = {
anthropic: 'from-orange-500 to-orange-600',
openai: 'from-emerald-500 to-emerald-600',
antigravity: 'from-purple-500 to-purple-600',
gemini: 'from-blue-500 to-blue-600',
}
const GRADIENT_DEFAULT = 'from-primary-500 to-primary-600'
// ── Header text (light text on gradient bg) ────────────────────────
const GRADIENT_TEXT: Record<Platform, string> = {
anthropic: 'text-orange-100',
openai: 'text-emerald-100',
antigravity: 'text-purple-100',
gemini: 'text-blue-100',
}
const GRADIENT_TEXT_DEFAULT = 'text-primary-100'
const GRADIENT_SUBTEXT: Record<Platform, string> = {
anthropic: 'text-orange-200',
openai: 'text-emerald-200',
antigravity: 'text-purple-200',
gemini: 'text-blue-200',
}
const GRADIENT_SUBTEXT_DEFAULT = 'text-primary-200'
// ── Public API ──────────────────────────────────────────────────────
function isPlatform(p: string): p is Platform {
return p === 'anthropic' || p === 'openai' || p === 'antigravity' || p === 'gemini'
}
export function platformBadgeClass(p: string): string {
return isPlatform(p) ? BADGE[p] : BADGE_DEFAULT
}
export function platformBadgeLightClass(p: string): string {
return isPlatform(p) ? BADGE_LIGHT[p] : BADGE_DEFAULT
}
export function platformBorderClass(p: string): string {
return isPlatform(p) ? BORDER[p] : BORDER_DEFAULT
}
export function platformAccentBarClass(p: string): string {
return isPlatform(p) ? ACCENT_BAR[p] : ACCENT_BAR_DEFAULT
}
export function platformTextClass(p: string): string {
return isPlatform(p) ? TEXT[p] : TEXT_DEFAULT
}
export function platformIconClass(p: string): string {
return isPlatform(p) ? ICON[p] : ICON_DEFAULT
}
export function platformButtonClass(p: string): string {
return isPlatform(p) ? BUTTON[p] : BUTTON_DEFAULT
}
export function platformDiscountClass(p: string): string {
return isPlatform(p) ? DISCOUNT[p] : DISCOUNT_DEFAULT
}
export function platformGradientClass(p: string): string {
return isPlatform(p) ? GRADIENT[p] : GRADIENT_DEFAULT
}
export function platformGradientTextClass(p: string): string {
return isPlatform(p) ? GRADIENT_TEXT[p] : GRADIENT_TEXT_DEFAULT
}
export function platformGradientSubtextClass(p: string): string {
return isPlatform(p) ? GRADIENT_SUBTEXT[p] : GRADIENT_SUBTEXT_DEFAULT
}
export function platformLabel(p: string): string {
switch (p) {
case 'anthropic': return 'Anthropic'
case 'openai': return 'OpenAI'
case 'antigravity': return 'Antigravity'
case 'gemini': return 'Gemini'
default: return p || 'API'
}
}
const MIN_TABLE_PAGE_SIZE = 5
const MAX_TABLE_PAGE_SIZE = 1000
export const DEFAULT_TABLE_PAGE_SIZE = 20
export const DEFAULT_TABLE_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]
const sanitizePageSize = (value: unknown): number | null => {
const size = Number(value)
if (!Number.isInteger(size)) return null
if (size < MIN_TABLE_PAGE_SIZE || size > MAX_TABLE_PAGE_SIZE) return null
return size
}
const parsePageSizeForSelection = (value: unknown): number | null => {
const size = Number(value)
if (!Number.isInteger(size)) return null
if (size < MIN_TABLE_PAGE_SIZE) return null
return size
}
const getInjectedAppConfig = () => {
if (typeof window === 'undefined') return null
return window.__APP_CONFIG__ ?? null
}
const getSanitizedConfiguredOptions = (): number[] => {
const configured = getInjectedAppConfig()?.table_page_size_options
if (!Array.isArray(configured)) return []
return Array.from(
new Set(
configured
.map((value) => sanitizePageSize(value))
.filter((value): value is number => value !== null)
)
).sort((a, b) => a - b)
}
const normalizePageSizeToOptions = (value: number, options: number[]): number => {
for (const option of options) {
if (option >= value) {
return option
}
}
return options[options.length - 1]
}
export const getConfiguredTableDefaultPageSize = (): number => {
const configured = sanitizePageSize(getInjectedAppConfig()?.table_default_page_size)
if (configured === null) {
return DEFAULT_TABLE_PAGE_SIZE
}
return configured
}
export const getConfiguredTablePageSizeOptions = (): number[] => {
const unique = getSanitizedConfiguredOptions()
if (unique.length === 0) {
return [...DEFAULT_TABLE_PAGE_SIZE_OPTIONS]
}
return unique.length > 0 ? unique : [...DEFAULT_TABLE_PAGE_SIZE_OPTIONS]
}
export const normalizeTablePageSize = (value: unknown): number => {
const normalized = parsePageSizeForSelection(value)
const defaultSize = getConfiguredTableDefaultPageSize()
const options = getConfiguredTablePageSizeOptions()
if (normalized !== null) {
return normalizePageSizeToOptions(normalized, options)
}
return normalizePageSizeToOptions(defaultSize, options)
}
...@@ -148,6 +148,8 @@ ...@@ -148,6 +148,8 @@
:data="accounts" :data="accounts"
:loading="loading" :loading="loading"
row-key="id" row-key="id"
:server-side-sort="true"
@sort="handleSort"
default-sort-key="name" default-sort-key="name"
default-sort-order="asc" default-sort-order="asc"
:sort-storage-key="ACCOUNT_SORT_STORAGE_KEY" :sort-storage-key="ACCOUNT_SORT_STORAGE_KEY"
...@@ -401,6 +403,37 @@ const HIDDEN_COLUMNS_KEY = 'account-hidden-columns' ...@@ -401,6 +403,37 @@ const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
// Sorting settings // Sorting settings
const ACCOUNT_SORT_STORAGE_KEY = 'account-table-sort' const ACCOUNT_SORT_STORAGE_KEY = 'account-table-sort'
type AccountSortOrder = 'asc' | 'desc'
type AccountSortState = {
sort_by: string
sort_order: AccountSortOrder
}
const ACCOUNT_SORTABLE_KEYS = new Set([
'name',
'status',
'schedulable',
'priority',
'rate_multiplier',
'last_used_at',
'expires_at'
])
const loadInitialAccountSortState = (): AccountSortState => {
const fallback: AccountSortState = { sort_by: 'name', sort_order: 'asc' }
try {
const raw = localStorage.getItem(ACCOUNT_SORT_STORAGE_KEY)
if (!raw) return fallback
const parsed = JSON.parse(raw) as { key?: string; order?: string }
const key = typeof parsed.key === 'string' ? parsed.key : ''
if (!ACCOUNT_SORTABLE_KEYS.has(key)) return fallback
return {
sort_by: key,
sort_order: parsed.order === 'desc' ? 'desc' : 'asc'
}
} catch {
return fallback
}
}
const sortState = reactive<AccountSortState>(loadInitialAccountSortState())
// Auto refresh settings // Auto refresh settings
const showAutoRefreshDropdown = ref(false) const showAutoRefreshDropdown = ref(false)
...@@ -594,7 +627,16 @@ const { ...@@ -594,7 +627,16 @@ const {
handlePageSizeChange: baseHandlePageSizeChange handlePageSizeChange: baseHandlePageSizeChange
} = useTableLoader<Account, any>({ } = useTableLoader<Account, any>({
fetchFn: adminAPI.accounts.list, fetchFn: adminAPI.accounts.list,
initialParams: { platform: '', type: '', status: '', privacy_mode: '', group: '', search: '' } initialParams: {
platform: '',
type: '',
status: '',
privacy_mode: '',
group: '',
search: '',
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
}
}) })
const { const {
...@@ -671,6 +713,19 @@ const handlePageSizeChange = (size: number) => { ...@@ -671,6 +713,19 @@ const handlePageSizeChange = (size: number) => {
baseHandlePageSizeChange(size) baseHandlePageSizeChange(size)
} }
const handleSort = (key: string, order: AccountSortOrder) => {
sortState.sort_by = key
sortState.sort_order = order
const requestParams = params as any
requestParams.sort_by = key
requestParams.sort_order = order
pagination.page = 1
hasPendingListSync.value = false
resetAutoRefreshCache()
pendingTodayStatsRefresh.value = true
load()
}
watch(loading, (isLoading, wasLoading) => { watch(loading, (isLoading, wasLoading) => {
if (wasLoading && !isLoading && pendingTodayStatsRefresh.value) { if (wasLoading && !isLoading && pendingTodayStatsRefresh.value) {
pendingTodayStatsRefresh.value = false pendingTodayStatsRefresh.value = false
...@@ -774,6 +829,8 @@ const refreshAccountsIncrementally = async () => { ...@@ -774,6 +829,8 @@ const refreshAccountsIncrementally = async () => {
privacy_mode?: string privacy_mode?: string
group?: string group?: string
search?: string search?: string
sort_by?: string
sort_order?: AccountSortOrder
}, },
{ etag: autoRefreshETag.value } { etag: autoRefreshETag.value }
...@@ -1103,19 +1160,58 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => { ...@@ -1103,19 +1160,58 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => {
} }
const handleBulkUpdated = () => { showBulkEdit.value = false; clearSelection(); reload() } const handleBulkUpdated = () => { showBulkEdit.value = false; clearSelection(); reload() }
const handleDataImported = () => { showImportData.value = false; reload() } const handleDataImported = () => { showImportData.value = false; reload() }
const ACCOUNT_UNGROUPED_GROUP_QUERY_VALUE = 'ungrouped'
const ACCOUNT_PRIVACY_MODE_UNSET_QUERY_VALUE = '__unset__'
const buildAccountQueryFilters = () => ({
platform: params.platform || '',
type: params.type || '',
status: params.status || '',
group: params.group || '',
privacy_mode: params.privacy_mode || '',
search: params.search || '',
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
})
const accountMatchesCurrentFilters = (account: Account) => { const accountMatchesCurrentFilters = (account: Account) => {
if (params.platform && account.platform !== params.platform) return false const filters = buildAccountQueryFilters()
if (params.type && account.type !== params.type) return false if (filters.platform && account.platform !== filters.platform) return false
if (params.status) { if (filters.type && account.type !== filters.type) return false
if (params.status === 'rate_limited') { if (filters.status) {
if (!account.rate_limit_reset_at) return false const now = Date.now()
const resetAt = new Date(account.rate_limit_reset_at).getTime() const rateLimitResetAt = account.rate_limit_reset_at ? new Date(account.rate_limit_reset_at).getTime() : Number.NaN
if (!Number.isFinite(resetAt) || resetAt <= Date.now()) return false const isRateLimited = Number.isFinite(rateLimitResetAt) && rateLimitResetAt > now
} else if (account.status !== params.status) { const tempUnschedUntil = account.temp_unschedulable_until ? new Date(account.temp_unschedulable_until).getTime() : Number.NaN
const isTempUnschedulable = Number.isFinite(tempUnschedUntil) && tempUnschedUntil > now
if (filters.status === 'active') {
if (account.status !== 'active' || isRateLimited || isTempUnschedulable || !account.schedulable) return false
} else if (filters.status === 'rate_limited') {
if (account.status !== 'active' || !isRateLimited || isTempUnschedulable) return false
} else if (filters.status === 'temp_unschedulable') {
if (account.status !== 'active' || !isTempUnschedulable) return false
} else if (filters.status === 'unschedulable') {
if (account.status !== 'active' || account.schedulable || isRateLimited || isTempUnschedulable) return false
} else if (account.status !== filters.status) {
return false
}
}
if (filters.group) {
const groupIds = account.group_ids ?? account.groups?.map((group) => group.id) ?? []
if (filters.group === ACCOUNT_UNGROUPED_GROUP_QUERY_VALUE) {
if (groupIds.length > 0) return false
} else if (!groupIds.includes(Number(filters.group))) {
return false
}
}
const privacyMode = typeof account.extra?.privacy_mode === 'string' ? account.extra.privacy_mode : ''
if (filters.privacy_mode) {
if (filters.privacy_mode === ACCOUNT_PRIVACY_MODE_UNSET_QUERY_VALUE) {
if (privacyMode.trim() !== '') return false
} else if (privacyMode !== filters.privacy_mode) {
return false return false
} }
} }
const search = String(params.search || '').trim().toLowerCase() const search = String(filters.search || '').trim().toLowerCase()
if (search && !account.name.toLowerCase().includes(search)) return false if (search && !account.name.toLowerCase().includes(search)) return false
return true return true
} }
...@@ -1181,12 +1277,7 @@ const handleExportData = async () => { ...@@ -1181,12 +1277,7 @@ const handleExportData = async () => {
? { ids: selIds.value, includeProxies: includeProxyOnExport.value } ? { ids: selIds.value, includeProxies: includeProxyOnExport.value }
: { : {
includeProxies: includeProxyOnExport.value, includeProxies: includeProxyOnExport.value,
filters: { filters: buildAccountQueryFilters()
platform: params.platform,
type: params.type,
status: params.status,
search: params.search
}
} }
) )
const timestamp = formatExportTimestamp() const timestamp = formatExportTimestamp()
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment