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

merge v0.1.111

parents 68f67198 ad64190b
const STORAGE_KEY = 'table-page-size'
const DEFAULT_PAGE_SIZE = 20
import { getConfiguredTableDefaultPageSize, normalizeTablePageSize } from '@/utils/tablePreferences'
/**
* 从 localStorage 读取/写入 pageSize
* 全局共享一个 key,所有表格统一偏好
* 读取当前系统配置的表格默认每页条数。
* 不再使用本地持久化缓存,所有页面统一以通用表格设置为准。
*/
export function getPersistedPageSize(fallback = DEFAULT_PAGE_SIZE): number {
try {
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 {
// 静默失败
}
export function getPersistedPageSize(fallback = getConfiguredTableDefaultPageSize()): number {
return normalizeTablePageSize(getConfiguredTableDefaultPageSize() || fallback)
}
import { ref, reactive, onUnmounted, toRaw } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import type { BasePaginationResponse, FetchOptions } from '@/types'
import { getPersistedPageSize, setPersistedPageSize } from './usePersistedPageSize'
import { getPersistedPageSize } from './usePersistedPageSize'
interface PaginationState {
page: number
......@@ -88,7 +88,6 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
const handlePageSizeChange = (size: number) => {
pagination.page_size = size
pagination.page = 1
setPersistedPageSize(size)
load()
}
......
......@@ -315,6 +315,8 @@ export default {
chooseFile: 'Choose File',
notAvailable: 'N/A',
now: 'Now',
today: 'Today',
tomorrow: 'Tomorrow',
unknown: 'Unknown',
minutes: 'min',
time: {
......@@ -360,7 +362,11 @@ export default {
mySubscriptions: 'My Subscriptions',
buySubscription: 'Recharge / Subscription',
docs: 'Docs',
sora: 'Sora Studio'
myOrders: 'My Orders',
orderManagement: 'Orders',
paymentDashboard: 'Payment Dashboard',
paymentConfig: 'Payment Config',
paymentPlans: 'Plans'
},
// Auth
......@@ -435,6 +441,7 @@ export default {
invitationCodeInvalid: 'Invalid or used invitation code',
invitationCodeValidating: 'Validating invitation code...',
invitationCodeInvalidCannotRegister: 'Invalid invitation code. Please check and try again',
oauthOrContinue: 'or continue with email',
linuxdo: {
signIn: 'Continue with Linux.do',
orContinue: 'or continue with email',
......@@ -449,6 +456,20 @@ export default {
completing: 'Completing registration…',
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: {
code: 'Code',
state: 'State',
......@@ -1618,7 +1639,6 @@ export default {
openai: 'OpenAI',
gemini: 'Gemini',
antigravity: 'Antigravity',
sora: 'Sora'
},
deleteConfirm:
"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 {
title: 'Image Generation Pricing',
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: {
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.',
......@@ -1666,9 +1676,23 @@ export default {
title: 'OpenAI Messages Dispatch',
allowDispatch: 'Allow /v1/messages dispatch',
allowDispatchHint: 'When enabled, API keys in this OpenAI group can dispatch requests through /v1/messages endpoint',
defaultModel: 'Default mapped model',
defaultModelPlaceholder: 'e.g., gpt-4.1',
defaultModelHint: 'When account has no model mapping configured, all request models will be mapped to this model'
familyMappingTitle: 'Family Default Mapping',
familyMappingHint: 'Requests that match the Opus, Sonnet, or Haiku families will prefer the target model configured here.',
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: {
title: 'Invalid Request Fallback Group',
......@@ -2032,7 +2056,6 @@ export default {
openai: 'OpenAI',
gemini: 'Gemini',
antigravity: 'Antigravity',
sora: 'Sora'
},
types: {
oauth: 'OAuth',
......@@ -2042,10 +2065,6 @@ export default {
codeAssist: 'Code Assist',
antigravityOauth: 'Antigravity OAuth',
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',
upstreamDesc: 'Connect via Base URL + API Key'
},
......@@ -2059,6 +2078,7 @@ export default {
rateLimited: 'Rate Limited',
overloaded: 'Overloaded',
tempUnschedulable: 'Temp Unschedulable',
unschedulable: 'Unschedulable',
rateLimitedUntil: 'Rate limited and removed from scheduling. Auto resumes at {time}',
rateLimitedAutoResume: 'Auto resumes in {time}',
modelRateLimitedUntil: '{model} rate limited until {time}',
......@@ -2308,8 +2328,6 @@ export default {
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.',
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: {
apiKeyPassthrough: 'Auto passthrough (auth only)',
......@@ -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.',
selectedModels: 'Selected {count} model(s)',
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',
actualModel: 'Actual model',
addMapping: 'Add Mapping',
......@@ -2476,8 +2491,6 @@ export default {
creating: 'Creating...',
updating: 'Updating...',
accountCreated: 'Account created successfully',
soraAccountCreated: 'Sora account created simultaneously',
soraAccountFailed: 'Failed to create Sora account, please add manually later',
accountUpdated: 'Account updated successfully',
failedToCreate: 'Failed to create account',
failedToUpdate: 'Failed to update account',
......@@ -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.',
refreshTokenPlaceholder: 'Paste your OpenAI Refresh Token...\nSupports multiple, one per line',
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.',
sessionTokenPlaceholder: 'Paste your Sora Session Token...\nSupports multiple, one per line',
sessionTokenDesc: 'Enter your existing Session Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.',
sessionTokenPlaceholder: 'Paste your Session Token...\nSupports multiple, one per line',
sessionTokenRawLabel: 'Raw Input',
sessionTokenRawPlaceholder: 'Paste /api/auth/session raw payload or Session Token...',
sessionTokenRawHint: 'You can paste full JSON. The system will auto-parse ST and AT.',
......@@ -2826,7 +2839,6 @@ export default {
reAuthorizeAccount: 'Re-Authorize Account',
claudeCodeAccount: 'Claude Code Account',
openaiAccount: 'OpenAI Account',
soraAccount: 'Sora Account',
geminiAccount: 'Gemini Account',
antigravityAccount: 'Antigravity Account',
inputMethod: 'Input Method',
......@@ -2860,11 +2872,6 @@ export default {
geminiImageTestMode: 'Mode: Gemini image generation test',
geminiImagePreview: 'Generated images:',
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
viewStats: 'View Stats',
usageStatistics: 'Usage Statistics',
......@@ -4202,7 +4209,7 @@ export default {
gateway: 'Gateway',
email: 'Email',
backup: 'Backup',
data: 'Sora Storage',
payment: 'Payment',
},
emailTabDisabledTitle: 'Email Verification Not Enabled',
emailTabDisabledHint: 'Enable email verification in the Security tab to configure SMTP settings.',
......@@ -4263,6 +4270,57 @@ export default {
quickSetCopy: 'Generate & Copy (current site)',
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: {
title: 'Default User Settings',
description: 'Default values for new users',
......@@ -5044,99 +5102,263 @@ export default {
}
},
// Sora Studio
sora: {
title: 'Sora Studio',
description: 'Generate videos and images with Sora AI',
notEnabled: 'Feature Not Available',
notEnabledDesc: 'The Sora Studio feature has not been enabled by the administrator. Please contact your admin.',
tabGenerate: 'Generate',
tabLibrary: 'Library',
noActiveGenerations: 'No active generations',
startGenerating: 'Enter a prompt below to start creating',
storage: 'Storage',
promptPlaceholder: 'Describe what you want to create...',
generate: 'Generate',
generating: 'Generating...',
selectModel: 'Select Model',
statusPending: 'Pending',
statusGenerating: 'Generating',
statusCompleted: 'Completed',
statusFailed: 'Failed',
statusCancelled: 'Cancelled',
cancel: 'Cancel',
delete: 'Delete',
save: 'Save to Cloud',
saved: 'Saved',
// Payment System
payment: {
title: 'Recharge / Subscription',
amountLabel: 'Amount',
quickAmounts: 'Quick Amounts',
customAmount: 'Custom Amount',
enterAmount: 'Enter amount',
paymentMethod: 'Payment Method',
fee: 'Fee',
actualPay: 'Actual Payment',
createOrder: 'Confirm Payment',
methods: {
easypay: 'EasyPay',
alipay: 'Alipay',
wxpay: 'WeChat Pay',
stripe: 'Stripe',
card: 'Card',
link: 'Link',
alipay_direct: 'Alipay (Direct)',
wxpay_direct: 'WeChat Pay (Direct)',
},
status: {
pending: 'Pending',
paid: 'Paid',
recharging: 'Recharging',
completed: 'Completed',
expired: 'Expired',
cancelled: 'Cancelled',
failed: 'Failed',
refund_requested: 'Refund Requested',
refunding: 'Refunding',
refunded: 'Refunded',
partially_refunded: 'Partially Refunded',
refund_failed: 'Refund Failed',
},
qr: {
scanToPay: 'Scan to Pay',
scanAlipay: 'Alipay QR Payment',
scanWxpay: 'WeChat QR Payment',
scanAlipayHint: 'Open Alipay on your phone and scan the QR code to pay',
scanWxpayHint: 'Open WeChat on your phone and scan the QR code to pay',
payInNewWindow: 'Complete Payment in New Window',
payInNewWindowHint: 'The payment page has opened in a new window. Please complete the payment there and return to this page.',
openPayWindow: 'Reopen Payment Page',
expiresIn: 'Expires in',
expired: 'Order Expired',
expiredDesc: 'This order has expired. Please create a new one.',
cancelled: 'Order Cancelled',
cancelledDesc: 'You have cancelled this payment.',
waitingPayment: 'Waiting for payment...',
cancelOrder: 'Cancel Order',
},
orders: {
title: 'My Orders',
empty: 'No orders yet',
orderId: 'Order ID',
orderNo: 'Order No.',
amount: 'Amount',
payAmount: 'Paid',
status: 'Status',
paymentMethod: 'Payment Method',
createdAt: 'Created',
cancel: 'Cancel Order',
userId: 'User ID',
orderType: 'Order Type',
actions: 'Actions',
requestRefund: 'Request Refund',
},
result: {
success: 'Payment Successful',
subscriptionSuccess: 'Subscription Successful',
failed: 'Payment Failed',
backToRecharge: 'Back to Recharge',
viewOrders: 'View Orders',
},
currentBalance: 'Current Balance',
rechargeAccount: 'Recharge Account',
activeSubscription: 'Active Subscription',
noActiveSubscription: 'No active subscription',
tabTopUp: 'Top Up',
tabSubscribe: 'Subscribe',
noPlans: 'No subscription plans available',
notAvailable: 'Top-up is currently unavailable',
confirmSubscription: 'Confirm Subscription',
confirmCancel: 'Are you sure you want to cancel this order?',
amountTooLow: 'Minimum amount is {min}',
amountTooHigh: 'Maximum amount is {max}',
amountNoMethod: 'No payment method available for this amount',
refundReason: 'Refund Reason',
refundReasonPlaceholder: 'Please describe your refund reason',
stripeLoadFailed: 'Failed to load payment component. Please refresh and try again.',
stripeMissingParams: 'Missing order ID or client secret',
stripeNotConfigured: 'Stripe is not configured',
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',
download: 'Download',
justNow: 'Just now',
minutesAgo: '{n} min ago',
hoursAgo: '{n} hr ago',
noSavedWorks: 'No saved works',
saveWorksHint: 'Save your completed generations to the library',
filterAll: 'All',
filterVideo: 'Video',
filterImage: 'Image',
confirmDelete: 'Are you sure you want to delete this work?',
loading: 'Loading...',
loadMore: 'Load More',
noStorageWarningTitle: 'No Storage Configured',
noStorageWarningDesc: 'Generated content is only available via temporary upstream links that expire in ~15 minutes. Consider configuring S3 storage.',
mediaTypeVideo: 'Video',
mediaTypeImage: 'Image',
notificationCompleted: 'Generation Complete',
notificationFailed: 'Generation Failed',
notificationCompletedBody: 'Your {model} task has completed',
notificationFailedBody: 'Your {model} task has failed',
upstreamExpiresSoon: 'Expiring soon',
upstreamExpired: 'Link expired',
upstreamCountdown: '{time} remaining',
previewTitle: 'Preview',
closePreview: 'Close',
beforeUnloadWarning: 'You have unsaved generated content. Are you sure you want to leave?',
downloadTitle: 'Download Generated Content',
downloadExpirationWarning: 'This link expires in approximately 15 minutes. Please download and save promptly.',
downloadNow: 'Download Now',
referenceImage: 'Reference Image',
removeImage: 'Remove',
imageTooLarge: 'Image size cannot exceed 20MB',
// Sora dark theme additions
welcomeTitle: 'Turn your imagination into video',
welcomeSubtitle: 'Enter a description and Sora will create realistic videos or images for you. Try the examples below to get started.',
queueTasks: 'tasks',
queueWaiting: 'Queued',
waiting: 'Waiting',
waited: 'Waited',
errorCategory: 'Content Policy Violation',
savedToCloud: 'Saved to Cloud',
downloadLocal: 'Download',
canDownload: 'to download',
regenrate: 'Regenerate',
regenerate: 'Regenerate',
creatorPlaceholder: 'Describe the video or image you want to create...',
videoModels: 'Video Models',
imageModels: 'Image Models',
noStorageConfigured: 'No Storage',
selectCredential: 'Select Credential',
apiKeys: 'API Keys',
subscriptions: 'Subscriptions',
subscription: 'Subscription',
noCredentialHint: 'Please create an API Key or contact admin for subscription',
uploadReference: 'Upload reference image',
generatingCount: 'Generating {current}/{max}',
noStorageToastMessage: 'Cloud storage is not configured. Please use "Download" to save files after generation, otherwise they will be lost.',
galleryCount: '{count} works',
galleryEmptyTitle: 'No works yet',
galleryEmptyDesc: 'Your creations will be displayed here. Go to the generate page to start your first creation.',
startCreating: 'Start Creating',
yesterday: 'Yesterday',
landscape: 'Landscape',
portrait: 'Portrait',
square: 'Square',
examplePrompt1: 'A golden Shiba Inu walking through the streets of Shibuya, Tokyo, camera following, cinematic shot, 4K',
examplePrompt2: 'Drone aerial view, green aurora reflecting on a glacial lake in Iceland, slow push-in',
examplePrompt3: 'Cyberpunk futuristic city, neon lights reflected in rain puddles, nightscape, cinematic colors',
examplePrompt4: 'Chinese ink painting style, a small boat drifting among misty mountains and rivers, classical atmosphere'
}
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 {
chooseFile: '选择文件',
notAvailable: '不可用',
now: '现在',
today: '今天',
tomorrow: '明天',
unknown: '未知',
minutes: '分钟',
time: {
......@@ -360,7 +362,11 @@ export default {
mySubscriptions: '我的订阅',
buySubscription: '充值/订阅',
docs: '文档',
sora: 'Sora 创作'
myOrders: '我的订单',
orderManagement: '订单管理',
paymentDashboard: '支付概览',
paymentConfig: '支付配置',
paymentPlans: '订阅套餐'
},
// Auth
......@@ -434,6 +440,7 @@ export default {
invitationCodeInvalid: '邀请码无效或已被使用',
invitationCodeValidating: '正在验证邀请码...',
invitationCodeInvalidCannotRegister: '邀请码无效,请检查后重试',
oauthOrContinue: '或使用邮箱密码继续',
linuxdo: {
signIn: '使用 Linux.do 登录',
orContinue: '或使用邮箱密码继续',
......@@ -448,6 +455,19 @@ export default {
completing: '正在完成注册...',
completeRegistrationFailed: '注册失败,请检查邀请码后重试。'
},
oidc: {
signIn: '使用 {providerName} 登录',
callbackTitle: '正在完成 {providerName} 登录',
callbackProcessing: '正在验证 {providerName} 登录信息,请稍候...',
callbackHint: '如果页面未自动跳转,请返回登录页重试。',
callbackMissingToken: '登录信息缺失,请返回重试。',
backToLogin: '返回登录',
invitationRequired: '该 {providerName} 账号尚未注册,站点已开启邀请码注册,请输入邀请码以完成注册。',
invalidPendingToken: '注册凭证已失效,请重新登录。',
completeRegistration: '完成注册',
completing: '正在完成注册...',
completeRegistrationFailed: '注册失败,请检查邀请码后重试。'
},
oauth: {
code: '授权码',
state: '状态',
......@@ -1655,7 +1675,6 @@ export default {
openai: 'OpenAI',
gemini: 'Gemini',
antigravity: 'Antigravity',
sora: 'Sora'
},
saving: '保存中...',
noGroups: '暂无分组',
......@@ -1729,16 +1748,6 @@ export default {
title: '图片生成计费',
description: '配置图片生成模型的图片生成价格,留空则使用默认价格'
},
soraPricing: {
title: 'Sora 按次计费',
description: '配置 Sora 图片/视频按次收费价格,留空则默认不计费',
image360: '图片 360px ($)',
image540: '图片 540px ($)',
video: '视频(标准)($)',
videoHd: '视频(Pro-HD)($)',
storageQuota: '存储配额',
storageQuotaHint: '单位 GB,设置该分组用户的 Sora 存储配额上限,0 表示使用系统默认'
},
claudeCode: {
title: 'Claude Code 客户端限制',
tooltip:
......@@ -1753,9 +1762,23 @@ export default {
title: 'OpenAI Messages 调度配置',
allowDispatch: '允许 /v1/messages 调度',
allowDispatchHint: '启用后,此 OpenAI 分组的 API Key 可以通过 /v1/messages 端点调度请求',
defaultModel: '默认映射模型',
defaultModelPlaceholder: '例如: gpt-4.1',
defaultModelHint: '当账号未配置模型映射时,所有请求模型将映射到此模型'
familyMappingTitle: '系列默认映射',
familyMappingHint: '当请求命中 Opus、Sonnet、Haiku 系列时,会优先使用这里配置的目标模型。',
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: {
title: '无效请求兜底分组',
......@@ -2214,7 +2237,6 @@ export default {
anthropic: 'Anthropic',
gemini: 'Gemini',
antigravity: 'Antigravity',
sora: 'Sora'
},
types: {
oauth: 'OAuth',
......@@ -2224,10 +2246,6 @@ export default {
codeAssist: 'Code Assist',
antigravityOauth: 'Antigravity OAuth',
antigravityApikey: '通过 Base URL + API Key 连接',
soraApiKey: 'API Key / 上游透传',
soraApiKeyHint: '连接另一个 TrafficAPI 或兼容 API',
soraBaseUrlRequired: 'Sora apikey 账号必须设置上游地址(Base URL)',
soraBaseUrlInvalidScheme: 'Base URL 必须以 http:// 或 https:// 开头',
upstream: '对接上游',
upstreamDesc: '通过 Base URL + API Key 连接上游',
api_key: 'API Key',
......@@ -2243,6 +2261,7 @@ export default {
rateLimited: '限流中',
overloaded: '过载中',
tempUnschedulable: '临时不可调度',
unschedulable: '不可调度',
rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复',
rateLimitedAutoResume: '{time} 自动恢复',
modelRateLimitedUntil: '{model} 限流至 {time}',
......@@ -2456,8 +2475,6 @@ export default {
codexCLIOnly: '仅允许 Codex 官方客户端',
codexCLIOnlyDesc: '仅对 OpenAI OAuth 生效。开启后仅允许 Codex 官方客户端家族访问;关闭后完全绕过并保持原逻辑。',
modelRestrictionDisabledByPassthrough: '已开启自动透传:模型白名单/映射不会生效。',
enableSora: '同时启用 Sora',
enableSoraHint: 'Sora 使用相同的 OpenAI 账号,开启后将同时创建 Sora 平台账号'
},
anthropic: {
apiKeyPassthrough: '自动透传(仅替换认证)',
......@@ -2471,9 +2488,6 @@ export default {
mapRequestModels: '将请求模型映射到实际模型。左边是请求的模型,右边是发送到 API 的实际模型。',
selectedModels: '已选择 {count} 个模型',
supportsAllModels: '(支持所有模型)',
soraModelsLoadFailed: '加载 Sora 模型列表失败,已回退到默认列表',
soraModelsLoading: '正在加载 Sora 模型...',
soraModelsRetry: '加载失败,点击重试',
requestModel: '请求模型',
actualModel: '实际模型',
addMapping: '添加映射',
......@@ -2620,8 +2634,6 @@ export default {
creating: '创建中...',
updating: '更新中...',
accountCreated: '账号创建成功',
soraAccountCreated: 'Sora 账号已同时创建',
soraAccountFailed: 'Sora 账号创建失败,请稍后手动添加',
accountUpdated: '账号更新成功',
failedToCreate: '创建账号失败',
failedToUpdate: '更新账号失败',
......@@ -2729,8 +2741,8 @@ export default {
refreshTokenDesc: '输入您已有的 OpenAI Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
refreshTokenPlaceholder: '粘贴您的 OpenAI Refresh Token...\n支持多个,每行一个',
sessionTokenAuth: '手动输入 ST',
sessionTokenDesc: '输入您已有的 Sora Session Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
sessionTokenPlaceholder: '粘贴您的 Sora Session Token...\n支持多个,每行一个',
sessionTokenDesc: '输入您已有的 Session Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
sessionTokenPlaceholder: '粘贴您的 Session Token...\n支持多个,每行一个',
sessionTokenRawLabel: '原始字符串',
sessionTokenRawPlaceholder: '粘贴 /api/auth/session 原始数据或 Session Token...',
sessionTokenRawHint: '支持粘贴完整 JSON,系统会自动解析 ST 和 AT。',
......@@ -2959,7 +2971,6 @@ export default {
reAuthorizeAccount: '重新授权账号',
claudeCodeAccount: 'Claude Code 账号',
openaiAccount: 'OpenAI 账号',
soraAccount: 'Sora 账号',
geminiAccount: 'Gemini 账号',
antigravityAccount: 'Antigravity 账号',
inputMethod: '输入方式',
......@@ -2991,11 +3002,6 @@ export default {
geminiImageTestMode: '模式:Gemini 生图测试',
geminiImagePreview: '生成结果:',
geminiImageReceived: '已收到第 {count} 张测试图片',
soraUpstreamBaseUrlHint: '上游 Sora 服务地址(另一个 TrafficAPI 实例或兼容 API)',
soraTestHint: 'Sora 测试将执行连通性与能力检测(/backend/me、订阅信息、Sora2 邀请码与剩余额度)。',
soraTestTarget: '检测目标:Sora 账号能力',
soraTestMode: '模式:连通性 + 能力探测',
soraTestingFlow: '执行 Sora 连通性与能力检测...',
// Stats Modal
viewStats: '查看统计',
usageStatistics: '使用统计',
......@@ -4368,7 +4374,7 @@ export default {
gateway: '网关服务',
email: '邮件设置',
backup: '数据备份',
data: 'Sora 存储',
payment: '支付设置',
},
emailTabDisabledTitle: '邮箱验证未启用',
emailTabDisabledHint: '请在「安全与认证」选项卡中启用邮箱验证后,再配置 SMTP 设置。',
......@@ -4429,6 +4435,57 @@ export default {
quickSetCopy: '使用当前站点生成并复制',
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: {
title: '用户默认设置',
description: '新用户的默认值',
......@@ -4485,6 +4542,15 @@ export default {
apiBaseUrl: 'API 端点地址',
apiBaseUrlHint: '用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址',
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: {
title: '自定义端点',
description: '添加额外的 API 端点地址,用户可在「API Keys」页面快速复制',
......@@ -4560,6 +4626,102 @@ export default {
moveUp: '上移',
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: {
title: 'SMTP 设置',
description: '配置用于发送验证码的邮件服务',
......@@ -5233,99 +5395,263 @@ export default {
}
},
// Sora 创作
sora: {
title: 'Sora 创作',
description: '使用 Sora AI 生成视频与图片',
notEnabled: '功能未开放',
notEnabledDesc: '管理员尚未启用 Sora 创作功能,请联系管理员开通。',
tabGenerate: '生成',
tabLibrary: '作品库',
noActiveGenerations: '暂无生成任务',
startGenerating: '在下方输入提示词,开始创作',
storage: '存储',
promptPlaceholder: '描述你想创作的内容...',
generate: '生成',
generating: '生成中...',
selectModel: '选择模型',
statusPending: '等待中',
statusGenerating: '生成中',
statusCompleted: '已完成',
statusFailed: '失败',
statusCancelled: '已取消',
cancel: '取消',
delete: '删除',
save: '保存到云端',
saved: '已保存',
// Payment System
payment: {
title: '充值/订阅',
amountLabel: '充值金额',
quickAmounts: '快捷金额',
customAmount: '自定义金额',
enterAmount: '输入金额',
paymentMethod: '支付方式',
fee: '手续费',
actualPay: '实付金额',
createOrder: '确认支付',
methods: {
easypay: '易支付',
alipay: '支付宝',
wxpay: '微信支付',
stripe: 'Stripe',
card: '银行卡',
link: 'Link',
alipay_direct: '支付宝(直连)',
wxpay_direct: '微信支付(直连)',
},
status: {
pending: '待支付',
paid: '已支付',
recharging: '充值中',
completed: '已完成',
expired: '已过期',
cancelled: '已取消',
failed: '失败',
refund_requested: '退款申请中',
refunding: '退款中',
refunded: '已退款',
partially_refunded: '部分退款',
refund_failed: '退款失败',
},
qr: {
scanToPay: '请扫码支付',
scanAlipay: '支付宝扫码支付',
scanWxpay: '微信扫码支付',
scanAlipayHint: '请使用手机打开支付宝,扫描二维码完成支付',
scanWxpayHint: '请使用手机打开微信,扫描二维码完成支付',
payInNewWindow: '请在新窗口中完成支付',
payInNewWindowHint: '支付页面已在新窗口打开,请在新窗口中完成支付后返回此页面',
openPayWindow: '重新打开支付页面',
expiresIn: '剩余支付时间',
expired: '订单已过期',
expiredDesc: '订单已超时,请重新创建订单',
cancelled: '订单已取消',
cancelledDesc: '您已取消本次支付',
waitingPayment: '等待支付...',
cancelOrder: '取消订单',
},
orders: {
title: '我的订单',
empty: '暂无订单',
orderId: '订单 ID',
orderNo: '订单编号',
amount: '金额',
payAmount: '实付',
status: '状态',
paymentMethod: '支付方式',
createdAt: '创建时间',
cancel: '取消订单',
userId: '用户 ID',
orderType: '订单类型',
actions: '操作',
requestRefund: '申请退款',
},
result: {
success: '支付成功',
subscriptionSuccess: '订阅成功',
failed: '支付失败',
backToRecharge: '返回充值',
viewOrders: '查看订单',
},
currentBalance: '当前余额',
rechargeAccount: '充值账户',
activeSubscription: '当前订阅',
noActiveSubscription: '暂无有效订阅',
tabTopUp: '充值',
tabSubscribe: '订阅',
noPlans: '暂无可用订阅套餐',
notAvailable: '充值功能暂未开放',
confirmSubscription: '确认订阅',
confirmCancel: '确定要取消此订单吗?',
amountTooLow: '最低金额为 {min}',
amountTooHigh: '最高金额为 {max}',
amountNoMethod: '该金额没有可用的支付方式',
refundReason: '退款原因',
refundReasonPlaceholder: '请描述您的退款原因',
stripeLoadFailed: '支付组件加载失败,请刷新页面重试',
stripeMissingParams: '缺少订单ID或支付密钥',
stripeNotConfigured: 'Stripe 未配置',
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: '重试',
download: '下载',
justNow: '刚刚',
minutesAgo: '{n} 分钟前',
hoursAgo: '{n} 小时前',
noSavedWorks: '暂无保存的作品',
saveWorksHint: '生成完成后,将作品保存到作品库',
filterAll: '全部',
filterVideo: '视频',
filterImage: '图片',
confirmDelete: '确定删除此作品?',
loading: '加载中...',
loadMore: '加载更多',
noStorageWarningTitle: '未配置存储',
noStorageWarningDesc: '生成的内容仅通过上游临时链接提供,约 15 分钟后过期。建议管理员配置 S3 存储。',
mediaTypeVideo: '视频',
mediaTypeImage: '图片',
notificationCompleted: '生成完成',
notificationFailed: '生成失败',
notificationCompletedBody: '您的 {model} 任务已完成',
notificationFailedBody: '您的 {model} 任务失败了',
upstreamExpiresSoon: '即将过期',
upstreamExpired: '链接已过期',
upstreamCountdown: '剩余 {time}',
previewTitle: '作品预览',
closePreview: '关闭',
beforeUnloadWarning: '您有未保存的生成内容,确定要离开吗?',
downloadTitle: '下载生成内容',
downloadExpirationWarning: '此链接约 15 分钟后过期,请尽快下载保存。',
downloadNow: '立即下载',
referenceImage: '参考图',
removeImage: '移除',
imageTooLarge: '图片大小不能超过 20MB',
// Sora 暗色主题新增
welcomeTitle: '将你的想象力变成视频',
welcomeSubtitle: '输入一段描述,Sora 将为你创作逼真的视频或图片。尝试以下示例开始创作。',
queueTasks: '个任务',
queueWaiting: '队列中等待',
waiting: '等待中',
waited: '已等待',
errorCategory: '内容策略限制',
savedToCloud: '已保存到云端',
downloadLocal: '本地下载',
canDownload: '可下载',
regenrate: '重新生成',
regenerate: '重新生成',
creatorPlaceholder: '描述你想要生成的视频或图片...',
videoModels: '视频模型',
imageModels: '图片模型',
noStorageConfigured: '存储未配置',
selectCredential: '选择凭证',
apiKeys: 'API 密钥',
subscriptions: '订阅',
subscription: '订阅',
noCredentialHint: '请先创建 API Key 或联系管理员分配订阅',
uploadReference: '上传参考图片',
generatingCount: '正在生成 {current}/{max}',
noStorageToastMessage: '管理员未开通云存储,生成完成后请使用"本地下载"保存文件,否则将会丢失。',
galleryCount: '共 {count} 个作品',
galleryEmptyTitle: '还没有任何作品',
galleryEmptyDesc: '你的创作成果将会展示在这里。前往生成页,开始你的第一次创作吧。',
startCreating: '开始创作',
yesterday: '昨天',
landscape: '横屏',
portrait: '竖屏',
square: '方形',
examplePrompt1: '一只金色的柴犬在东京涩谷街头散步,镜头跟随,电影感画面,4K 高清',
examplePrompt2: '无人机航拍视角,冰岛极光下的冰川湖面反射绿色光芒,慢速推进',
examplePrompt3: '赛博朋克风格的未来城市,霓虹灯倒映在雨后积水中,夜景,电影级色彩',
examplePrompt4: '水墨画风格,一叶扁舟在山水间漂泊,薄雾缭绕,中国古典意境'
}
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[] = [
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',
name: 'ForgotPassword',
......@@ -192,13 +201,73 @@ const routes: RouteRecordRaw[] = [
{
path: '/purchase',
name: 'PurchaseSubscription',
component: () => import('@/views/user/PurchaseSubscriptionView.vue'),
component: () => import('@/views/user/PaymentView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'Purchase Subscription',
titleKey: 'purchase.title',
descriptionKey: 'purchase.description'
titleKey: 'nav.buySubscription',
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[] = [
}
},
// ==================== 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 ====================
{
path: '/:pathMatch(.*)*',
......@@ -502,6 +610,16 @@ router.beforeEach((to, _from, next) => {
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) {
const restrictedPaths = [
......
......@@ -42,5 +42,21 @@ declare module 'vue-router' {
* @default false
*/
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 { setActivePinia, createPinia } from 'pinia'
import { useAppStore } from '@/stores/app'
import { getPublicSettings } from '@/api/auth'
// Mock API 模块
vi.mock('@/api/admin/system', () => ({
......@@ -15,12 +16,14 @@ describe('useAppStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.useFakeTimers()
localStorage.clear()
// 清除 window.__APP_CONFIG__
delete (window as any).__APP_CONFIG__
})
afterEach(() => {
vi.useRealTimers()
localStorage.clear()
})
// --- Toast 消息管理 ---
......@@ -291,5 +294,43 @@ describe('useAppStore', () => {
expect(store.publicSettingsLoaded).toBe(false)
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', () => {
const opsMonitoringEnabled = ref(readCachedBool('ops_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 paymentEnabled = ref(readCachedBool('payment_enabled_cached', false))
const customMenuItems = ref<CustomMenuItem[]>([])
async function fetch(force = false): Promise<void> {
......@@ -56,7 +57,10 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
loading.value = true
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
writeCachedBool('ops_monitoring_enabled_cached', opsMonitoringEnabled.value)
......@@ -68,6 +72,9 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
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
} catch (err) {
// Keep cached/default value: do not "flip" the UI based on a transient fetch failure.
......@@ -90,6 +97,12 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
loaded.value = true
}
function setPaymentEnabledLocal(value: boolean) {
paymentEnabled.value = value
writeCachedBool('payment_enabled_cached', value)
loaded.value = true
}
function setOpsQueryModeDefaultLocal(value: string) {
opsQueryModeDefault.value = value || 'auto'
writeCachedString('ops_query_mode_default_cached', opsQueryModeDefault.value)
......@@ -126,10 +139,12 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
opsMonitoringEnabled,
opsRealtimeMonitoringEnabled,
opsQueryModeDefault,
paymentEnabled,
customMenuItems,
fetch,
setOpsMonitoringEnabledLocal,
setOpsRealtimeMonitoringEnabledLocal,
setPaymentEnabledLocal,
setOpsQueryModeDefaultLocal
}
})
......@@ -284,6 +284,9 @@ export const useAppStore = defineStore('app', () => {
* Apply settings to store state (internal helper to avoid code duplication)
*/
function applySettings(config: PublicSettings): void {
if (typeof window !== 'undefined') {
window.__APP_CONFIG__ = { ...config }
}
cachedPublicSettings.value = config
siteName.value = config.site_name || 'TrafficAPI'
siteLogo.value = config.site_logo || ''
......@@ -327,11 +330,14 @@ export const useAppStore = defineStore('app', () => {
doc_url: docUrl.value,
home_content: '',
hide_ccs_import_button: false,
purchase_subscription_enabled: false,
purchase_subscription_url: '',
payment_enabled: false,
table_default_page_size: 20,
table_page_size_options: [10, 20, 50, 100],
custom_menu_items: [],
custom_endpoints: [],
linuxdo_oauth_enabled: false,
oidc_oauth_enabled: false,
oidc_oauth_provider_name: 'OIDC',
backend_mode_enabled: false,
version: siteVersion.value
}
......
......@@ -9,6 +9,7 @@ export { useAdminSettingsStore } from './adminSettings'
export { useSubscriptionStore } from './subscriptions'
export { useOnboardingStore } from './onboarding'
export { useAnnouncementStore } from './announcements'
export { usePaymentStore } from './payment'
// Re-export types for convenience
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,7 +16,8 @@
@apply min-h-screen;
}
/* 自定义滚动条 - 默认隐藏,悬停或滚动时显示 */
/* 自定义滚动条 - 仅针对 Firefox,避免 Chrome 取消 webkit 的全局定制 */
@supports (-moz-appearance:none) {
* {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
......@@ -31,6 +32,7 @@
.dark *:focus-within {
scrollbar-color: rgba(75, 85, 99, 0.5) transparent;
}
}
::-webkit-scrollbar {
@apply h-2 w-2;
......@@ -58,36 +60,7 @@
@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 {
......@@ -151,6 +124,27 @@
@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 {
@apply rounded-lg px-3 py-1.5 text-xs;
}
......@@ -558,12 +552,18 @@
border-right: 1px solid rgba(139, 92, 246, 0.08);
@apply flex flex-col;
@apply transition-transform duration-300;
transition-property: width, transform;
}
.sidebar-header {
@apply h-16 px-6;
@apply flex items-center gap-3;
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 {
......@@ -571,7 +571,8 @@
}
.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;
color: rgba(148, 163, 184, 0.85);
@apply transition-all duration-200;
......@@ -580,6 +581,10 @@
.sidebar-link:hover {
background: rgba(255, 255, 255, 0.055);
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 {
......
......@@ -104,11 +104,14 @@ export interface PublicSettings {
doc_url: string
home_content: string
hide_ccs_import_button: boolean
purchase_subscription_enabled: boolean
purchase_subscription_url: string
payment_enabled: boolean
table_default_page_size: number
table_page_size_options: number[]
custom_menu_items: CustomMenuItem[]
custom_endpoints: CustomEndpoint[]
linuxdo_oauth_enabled: boolean
oidc_oauth_enabled: boolean
oidc_oauth_provider_name: string
backend_mode_enabled: boolean
version: string
}
......@@ -366,6 +369,13 @@ export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
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 {
id: number
name: string
......@@ -388,6 +398,8 @@ export interface Group {
fallback_group_id_on_invalid_request: number | null
// OpenAI Messages 调度开关(用户侧需要此字段判断是否展示 Claude Code 教程)
allow_messages_dispatch?: boolean
default_mapped_model?: string
messages_dispatch_model_config?: OpenAIMessagesDispatchModelConfig
require_oauth_only: boolean
require_privacy_set: boolean
created_at: string
......@@ -414,6 +426,7 @@ export interface AdminGroup extends Group {
// OpenAI Messages 调度配置(仅 openai 平台使用)
default_mapped_model?: string
messages_dispatch_model_config?: OpenAIMessagesDispatchModelConfig
// 分组排序
sort_order: number
......@@ -1350,6 +1363,8 @@ export interface UsageQueryParams {
billing_type?: number | null
start_date?: string
end_date?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
}
// ==================== Account Usage Statistics ====================
......@@ -1616,3 +1631,6 @@ export interface UpdateScheduledTestPlanRequest {
max_results?: number
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 @@
:data="accounts"
:loading="loading"
row-key="id"
:server-side-sort="true"
@sort="handleSort"
default-sort-key="name"
default-sort-order="asc"
:sort-storage-key="ACCOUNT_SORT_STORAGE_KEY"
......@@ -401,6 +403,37 @@ const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
// Sorting settings
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
const showAutoRefreshDropdown = ref(false)
......@@ -594,7 +627,16 @@ const {
handlePageSizeChange: baseHandlePageSizeChange
} = useTableLoader<Account, any>({
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 {
......@@ -671,6 +713,19 @@ const handlePageSizeChange = (size: number) => {
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) => {
if (wasLoading && !isLoading && pendingTodayStatsRefresh.value) {
pendingTodayStatsRefresh.value = false
......@@ -774,6 +829,8 @@ const refreshAccountsIncrementally = async () => {
privacy_mode?: string
group?: string
search?: string
sort_by?: string
sort_order?: AccountSortOrder
},
{ etag: autoRefreshETag.value }
......@@ -1103,19 +1160,58 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => {
}
const handleBulkUpdated = () => { showBulkEdit.value = false; clearSelection(); 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) => {
if (params.platform && account.platform !== params.platform) return false
if (params.type && account.type !== params.type) return false
if (params.status) {
if (params.status === 'rate_limited') {
if (!account.rate_limit_reset_at) return false
const resetAt = new Date(account.rate_limit_reset_at).getTime()
if (!Number.isFinite(resetAt) || resetAt <= Date.now()) return false
} else if (account.status !== params.status) {
const filters = buildAccountQueryFilters()
if (filters.platform && account.platform !== filters.platform) return false
if (filters.type && account.type !== filters.type) return false
if (filters.status) {
const now = Date.now()
const rateLimitResetAt = account.rate_limit_reset_at ? new Date(account.rate_limit_reset_at).getTime() : Number.NaN
const isRateLimited = Number.isFinite(rateLimitResetAt) && rateLimitResetAt > now
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
}
}
const search = String(params.search || '').trim().toLowerCase()
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
}
}
const search = String(filters.search || '').trim().toLowerCase()
if (search && !account.name.toLowerCase().includes(search)) return false
return true
}
......@@ -1181,12 +1277,7 @@ const handleExportData = async () => {
? { ids: selIds.value, includeProxies: includeProxyOnExport.value }
: {
includeProxies: includeProxyOnExport.value,
filters: {
platform: params.platform,
type: params.type,
status: params.status,
search: params.search
}
filters: buildAccountQueryFilters()
}
)
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