refactor: replace sync.Map credits state with AICredits rate limit key

Replace process-memory sync.Map + per-model runtime state with a single
"AICredits" key in model_rate_limits, making credits exhaustion fully
isomorphic with model-level rate limiting.

Scheduler: rate-limited accounts with overages enabled + credits available
are now scheduled instead of excluded.

Forwarding: when model is rate-limited + credits available, inject credits
proactively without waiting for a 429 round trip.

Storage: credits exhaustion stored as model_rate_limits["AICredits"] with
5h duration, reusing SetModelRateLimit/isRateLimitActiveForKey.

Frontend: show credits_active (yellow ) when model rate-limited but
credits available, credits_exhausted (red) when AICredits key active.

Tests: add unit tests for shouldMarkCreditsExhausted, injectEnabledCreditTypes,
clearCreditsExhausted, and update existing overages tests.
This commit is contained in:
erio
2026-03-16 04:31:22 +08:00
parent e14c87597a
commit 8a260defc2
12 changed files with 692 additions and 327 deletions

View File

@@ -88,14 +88,25 @@
]"
>
<div v-for="item in activeModelStatuses" :key="`${item.kind}-${item.model}`" class="group relative mb-1 break-inside-avoid">
<!-- 积分已用尽 -->
<span
v-if="item.kind === 'overages'"
v-if="item.kind === 'credits_exhausted'"
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
>
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
{{ t('admin.accounts.status.creditsExhausted') }}
<span class="text-[10px] opacity-70">{{ formatModelResetTime(item.reset_at) }}</span>
</span>
<!-- 正在走积分模型限流但积分可用-->
<span
v-else-if="item.kind === 'credits_active'"
class="inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
>
<span></span>
{{ formatScopeName(item.model) }}
<span class="text-[10px] opacity-70">{{ formatModelResetTime(item.reset_at) }}</span>
</span>
<!-- 普通模型限流 -->
<span
v-else
class="inline-flex items-center gap-1 rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
@@ -109,9 +120,11 @@
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-56 -translate-x-1/2 whitespace-normal rounded bg-gray-900 px-3 py-2 text-center text-xs leading-relaxed text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
{{
item.kind === 'overages'
? t('admin.accounts.status.modelCreditOveragesUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) })
: t('admin.accounts.status.modelRateLimitedUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) })
item.kind === 'credits_exhausted'
? t('admin.accounts.status.creditsExhaustedUntil', { time: formatTime(item.reset_at) })
: item.kind === 'credits_active'
? t('admin.accounts.status.modelCreditOveragesUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) })
: t('admin.accounts.status.modelRateLimitedUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) })
}}
<div
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
@@ -165,12 +178,12 @@ const isRateLimited = computed(() => {
})
type AccountModelStatusItem = {
kind: 'rate_limit' | 'overages'
kind: 'rate_limit' | 'credits_exhausted' | 'credits_active'
model: string
reset_at: string
}
// Computed: active model statuses (普通模型限流 + 超量请求运行态)
// Computed: active model statuses (普通模型限流 + 积分耗尽 + 走积分中)
const activeModelStatuses = computed<AccountModelStatusItem[]>(() => {
const extra = props.account.extra as Record<string, unknown> | undefined
const modelLimits = extra?.model_rate_limits as
@@ -179,19 +192,26 @@ const activeModelStatuses = computed<AccountModelStatusItem[]>(() => {
const now = new Date()
const items: AccountModelStatusItem[] = []
if (modelLimits) {
items.push(...Object.entries(modelLimits)
.filter(([, info]) => new Date(info.rate_limit_reset_at) > now)
.map(([model, info]) => ({ kind: 'rate_limit' as const, model, reset_at: info.rate_limit_reset_at })))
}
if (!modelLimits) return items
const overagesStates = extra?.antigravity_credits_overages as
| Record<string, { activated_at?: string; active_until: string }>
| undefined
if (overagesStates) {
items.push(...Object.entries(overagesStates)
.filter(([, info]) => new Date(info.active_until) > now)
.map(([model, info]) => ({ kind: 'overages' as const, model, reset_at: info.active_until })))
// 检查 AICredits key 是否生效(积分是否耗尽)
const aiCreditsEntry = modelLimits['AICredits']
const hasActiveAICredits = aiCreditsEntry && new Date(aiCreditsEntry.rate_limit_reset_at) > now
const allowOverages = !!(extra?.allow_overages)
for (const [model, info] of Object.entries(modelLimits)) {
if (new Date(info.rate_limit_reset_at) <= now) continue
if (model === 'AICredits') {
// AICredits key → 积分已用尽
items.push({ kind: 'credits_exhausted', model, reset_at: info.rate_limit_reset_at })
} else if (allowOverages && !hasActiveAICredits) {
// 普通模型限流 + overages 启用 + 积分可用 → 正在走积分
items.push({ kind: 'credits_active', model, reset_at: info.rate_limit_reset_at })
} else {
// 普通模型限流
items.push({ kind: 'rate_limit', model, reset_at: info.rate_limit_reset_at })
}
}
return items
@@ -216,7 +236,7 @@ const formatScopeName = (scope: string): string => {
'gemini-3.1-pro-high': 'G3PH',
'gemini-3.1-pro-low': 'G3PL',
'gemini-3-pro-image': 'G3PI',
'gemini-3.1-flash-image': 'GImage',
'gemini-3.1-flash-image': 'G31FI',
// 其他
'gpt-oss-120b-medium': 'GPT120',
'tab_flash_lite_preview': 'TabFL',

View File

@@ -43,17 +43,18 @@ function makeAccount(overrides: Partial<Account>): Account {
}
describe('AccountStatusIndicator', () => {
it('会将超量请求中的模型显示为独立状态', () => {
it('模型限流 + overages 启用 + 无 AICredits key → 显示 ⚡ (credits_active)', () => {
const wrapper = mount(AccountStatusIndicator, {
props: {
account: makeAccount({
id: 1,
name: 'ag-1',
extra: {
antigravity_credits_overages: {
allow_overages: true,
model_rate_limits: {
'claude-sonnet-4-5': {
activated_at: '2026-03-15T00:00:00Z',
active_until: '2099-03-15T00:00:00Z'
rate_limited_at: '2026-03-15T00:00:00Z',
rate_limit_reset_at: '2099-03-15T00:00:00Z'
}
}
}
@@ -70,7 +71,7 @@ describe('AccountStatusIndicator', () => {
expect(wrapper.text()).toContain('CSon45')
})
it('普通模型限流仍显示原有限流状态', () => {
it('模型限流 + overages 未启用 → 普通限流样式(无 ⚡)', () => {
const wrapper = mount(AccountStatusIndicator, {
props: {
account: makeAccount({
@@ -96,4 +97,66 @@ describe('AccountStatusIndicator', () => {
expect(wrapper.text()).toContain('CSon45')
expect(wrapper.text()).not.toContain('⚡')
})
it('AICredits key 生效 → 显示积分已用尽 (credits_exhausted)', () => {
const wrapper = mount(AccountStatusIndicator, {
props: {
account: makeAccount({
id: 3,
name: 'ag-3',
extra: {
allow_overages: true,
model_rate_limits: {
'AICredits': {
rate_limited_at: '2026-03-15T00:00:00Z',
rate_limit_reset_at: '2099-03-15T00:00:00Z'
}
}
}
})
},
global: {
stubs: {
Icon: true
}
}
})
expect(wrapper.text()).toContain('account.creditsExhausted')
})
it('模型限流 + overages 启用 + AICredits key 生效 → 普通限流样式(积分耗尽,无 ⚡)', () => {
const wrapper = mount(AccountStatusIndicator, {
props: {
account: makeAccount({
id: 4,
name: 'ag-4',
extra: {
allow_overages: true,
model_rate_limits: {
'claude-sonnet-4-5': {
rate_limited_at: '2026-03-15T00:00:00Z',
rate_limit_reset_at: '2099-03-15T00:00:00Z'
},
'AICredits': {
rate_limited_at: '2026-03-15T00:00:00Z',
rate_limit_reset_at: '2099-03-15T00:00:00Z'
}
}
}
})
},
global: {
stubs: {
Icon: true
}
}
})
// 模型限流 + 积分耗尽 → 不应显示 ⚡
expect(wrapper.text()).toContain('CSon45')
expect(wrapper.text()).not.toContain('⚡')
// AICredits 积分耗尽状态应显示
expect(wrapper.text()).toContain('account.creditsExhausted')
})
})

View File

@@ -245,6 +245,7 @@ export default {
// Common
common: {
loading: 'Loading...',
justNow: 'just now',
save: 'Save',
cancel: 'Cancel',
delete: 'Delete',
@@ -1655,6 +1656,14 @@ export default {
enabled: 'Enabled',
disabled: 'Disabled'
},
claudeMaxSimulation: {
title: 'Claude Max Usage Simulation',
tooltip:
'When enabled, for Claude models without upstream cache-write usage, the system deterministically maps tokens to a small input plus 1h cache creation while keeping total tokens unchanged.',
enabled: 'Enabled (simulate 1h cache)',
disabled: 'Disabled',
hint: 'Only token categories in usage billing logs are adjusted. No per-request mapping state is persisted.'
},
supportedScopes: {
title: 'Supported Model Families',
tooltip: 'Select the model families this group supports. Unchecked families will not be routed to this group.',
@@ -1868,6 +1877,8 @@ export default {
rateLimitedAutoResume: 'Auto resumes in {time}',
modelRateLimitedUntil: '{model} rate limited until {time}',
modelCreditOveragesUntil: '{model} using AI Credits until {time}',
creditsExhausted: 'Credits Exhausted',
creditsExhaustedUntil: 'AI Credits exhausted, expected recovery at {time}',
overloadedUntil: 'Overloaded until {time}',
viewTempUnschedDetails: 'View temp unschedulable details'
},
@@ -1969,7 +1980,7 @@ export default {
resetQuota: 'Reset Quota',
quotaLimit: 'Quota Limit',
quotaLimitPlaceholder: '0 means unlimited',
quotaLimitHint: 'Set daily/weekly/total spending limits (USD). Account will be paused when any limit is reached. Changing limits won\'t reset usage.',
quotaLimitHint: 'Set daily/weekly/total spending limits (USD). Anthropic API key accounts can also configure client affinity. Changing limits won\'t reset usage.',
quotaLimitToggle: 'Enable Quota Limit',
quotaLimitToggleHint: 'When enabled, account will be paused when usage reaches the set limit',
quotaDailyLimit: 'Daily Limit',
@@ -2166,7 +2177,7 @@ export default {
// Quota control (Anthropic OAuth/SetupToken only)
quotaControl: {
title: 'Quota Control',
hint: 'Only applies to Anthropic OAuth/Setup Token accounts',
hint: 'Configure cost window, session limits, client affinity and other scheduling controls.',
windowCost: {
label: '5h Window Cost Limit',
hint: 'Limit account cost usage within the 5-hour window',
@@ -2221,8 +2232,26 @@ export default {
hint: 'Force all cache creation tokens to be billed as the selected TTL tier (5m or 1h)',
target: 'Target TTL',
targetHint: 'Select the TTL tier for billing'
},
clientAffinity: {
label: 'Client Affinity Scheduling',
hint: 'When enabled, new sessions prefer accounts previously used by this client to reduce account switching'
}
},
affinityNoClients: 'No affinity clients',
affinityClients: '{count} affinity clients:',
affinitySection: 'Client Affinity',
affinitySectionHint: 'Control how clients are distributed across accounts. Configure zone thresholds to balance load.',
affinityToggle: 'Enable Client Affinity',
affinityToggleHint: 'New sessions prefer accounts previously used by this client',
affinityBase: 'Base Limit (Green Zone)',
affinityBasePlaceholder: 'Empty = no limit',
affinityBaseHint: 'Max clients in green zone (full priority scheduling)',
affinityBaseOffHint: 'No green zone limit. All clients receive full priority scheduling.',
affinityBuffer: 'Buffer (Yellow Zone)',
affinityBufferPlaceholder: 'e.g. 3',
affinityBufferHint: 'Additional clients allowed in the yellow zone (degraded priority)',
affinityBufferInfinite: 'Unlimited',
expired: 'Expired',
proxy: 'Proxy',
noProxy: 'No Proxy',
@@ -2677,7 +2706,7 @@ export default {
geminiFlashDaily: 'Flash',
gemini3Pro: 'G3P',
gemini3Flash: 'G3F',
gemini3Image: 'GImage',
gemini3Image: 'G31FI',
claude: 'Claude'
},
tier: {
@@ -4190,40 +4219,55 @@ export default {
usage: 'Usage: Add to request header - x-api-key: <your-admin-api-key>'
},
soraS3: {
title: 'Sora S3 Storage',
description: 'Manage multiple Sora S3 endpoints and switch the active profile',
title: 'Sora Storage',
description: 'Manage Sora media storage profiles with S3 and Google Drive support',
newProfile: 'New Profile',
reloadProfiles: 'Reload Profiles',
empty: 'No Sora S3 profiles yet, create one first',
createTitle: 'Create Sora S3 Profile',
editTitle: 'Edit Sora S3 Profile',
empty: 'No storage profiles yet, create one first',
createTitle: 'Create Storage Profile',
editTitle: 'Edit Storage Profile',
selectProvider: 'Select Storage Type',
providerS3Desc: 'S3-compatible object storage',
providerGDriveDesc: 'Google Drive cloud storage',
profileID: 'Profile ID',
profileName: 'Profile Name',
setActive: 'Set as active after creation',
saveProfile: 'Save Profile',
activateProfile: 'Activate',
profileCreated: 'Sora S3 profile created',
profileSaved: 'Sora S3 profile saved',
profileDeleted: 'Sora S3 profile deleted',
profileActivated: 'Sora S3 active profile switched',
profileCreated: 'Storage profile created',
profileSaved: 'Storage profile saved',
profileDeleted: 'Storage profile deleted',
profileActivated: 'Active storage profile switched',
profileIDRequired: 'Profile ID is required',
profileNameRequired: 'Profile name is required',
profileSelectRequired: 'Please select a profile first',
endpointRequired: 'S3 endpoint is required when enabled',
bucketRequired: 'Bucket is required when enabled',
accessKeyRequired: 'Access Key ID is required when enabled',
deleteConfirm: 'Delete Sora S3 profile {profileID}?',
deleteConfirm: 'Delete storage profile {profileID}?',
columns: {
profile: 'Profile',
profileId: 'Profile ID',
name: 'Name',
provider: 'Type',
active: 'Active',
endpoint: 'Endpoint',
bucket: 'Bucket',
storagePath: 'Storage Path',
capacityUsage: 'Capacity / Used',
capacityUnlimited: 'Unlimited',
videoCount: 'Videos',
videoCompleted: 'completed',
videoInProgress: 'in progress',
quota: 'Default Quota',
updatedAt: 'Updated At',
actions: 'Actions'
actions: 'Actions',
rootFolder: 'Root folder',
testInTable: 'Test',
testingInTable: 'Testing...',
testTimeout: 'Test timed out (15s)'
},
enabled: 'Enable S3 Storage',
enabledHint: 'When enabled, Sora generated media files will be automatically uploaded to S3 storage',
enabled: 'Enable Storage',
enabledHint: 'When enabled, Sora generated media files will be automatically uploaded',
endpoint: 'S3 Endpoint',
region: 'Region',
bucket: 'Bucket',
@@ -4232,16 +4276,38 @@ export default {
secretAccessKey: 'Secret Access Key',
secretConfigured: '(Configured, leave blank to keep)',
cdnUrl: 'CDN URL',
cdnUrlHint: 'Optional. When configured, files are accessed via CDN URL instead of presigned URLs',
cdnUrlHint: 'Optional. When configured, files are accessed via CDN URL',
forcePathStyle: 'Force Path Style',
defaultQuota: 'Default Storage Quota',
defaultQuotaHint: 'Default quota when not specified at user or group level. 0 means unlimited',
testConnection: 'Test Connection',
testing: 'Testing...',
testSuccess: 'S3 connection test successful',
testFailed: 'S3 connection test failed',
saved: 'Sora S3 settings saved successfully',
saveFailed: 'Failed to save Sora S3 settings'
testSuccess: 'Connection test successful',
testFailed: 'Connection test failed',
saved: 'Storage settings saved successfully',
saveFailed: 'Failed to save storage settings',
gdrive: {
authType: 'Authentication Method',
serviceAccount: 'Service Account',
clientId: 'Client ID',
clientSecret: 'Client Secret',
clientSecretConfigured: '(Configured, leave blank to keep)',
refreshToken: 'Refresh Token',
refreshTokenConfigured: '(Configured, leave blank to keep)',
serviceAccountJson: 'Service Account JSON',
serviceAccountConfigured: '(Configured, leave blank to keep)',
folderId: 'Folder ID (optional)',
authorize: 'Authorize Google Drive',
authorizeHint: 'Get Refresh Token via OAuth2',
oauthFieldsRequired: 'Please fill in Client ID and Client Secret first',
oauthSuccess: 'Google Drive authorization successful',
oauthFailed: 'Google Drive authorization failed',
closeWindow: 'This window will close automatically',
processing: 'Processing authorization...',
testStorage: 'Test Storage',
testSuccess: 'Google Drive storage test passed (upload, access, delete all OK)',
testFailed: 'Google Drive storage test failed'
}
},
streamTimeout: {
title: 'Stream Timeout Handling',
@@ -4712,6 +4778,7 @@ export default {
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',
@@ -4728,6 +4795,13 @@ export default {
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'
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'
}
}

View File

@@ -245,6 +245,7 @@ export default {
// Common
common: {
loading: '加载中...',
justNow: '刚刚',
save: '保存',
cancel: '取消',
delete: '删除',
@@ -1974,7 +1975,7 @@ export default {
resetQuota: '重置配额',
quotaLimit: '配额限制',
quotaLimitPlaceholder: '0 表示不限制',
quotaLimitHint: '设置日/周/总使用额度(美元),任一维度达到限额后账号暂停调度。修改限额不会重置已用额度。',
quotaLimitHint: '设置日/周/总使用额度(美元),任一维度达到限额后账号暂停调度。Anthropic API Key 账号还可配置客户端亲和。修改限额不会重置已用额度。',
quotaLimitToggle: '启用配额限制',
quotaLimitToggleHint: '开启后,当账号用量达到设定额度时自动暂停调度',
quotaDailyLimit: '日限额',
@@ -2053,6 +2054,8 @@ export default {
rateLimitedAutoResume: '{time} 自动恢复',
modelRateLimitedUntil: '{model} 限流至 {time}',
modelCreditOveragesUntil: '{model} 正在使用 AI Credits至 {time}',
creditsExhausted: '积分已用尽',
creditsExhaustedUntil: 'AI Credits 已用尽,预计 {time} 恢复',
overloadedUntil: '负载过重,重置时间:{time}',
viewTempUnschedDetails: '查看临时不可调度详情'
},
@@ -2106,7 +2109,7 @@ export default {
geminiFlashDaily: 'Flash',
gemini3Pro: 'G3P',
gemini3Flash: 'G3F',
gemini3Image: 'GImage',
gemini3Image: 'G31FI',
claude: 'Claude'
},
tier: {
@@ -2316,7 +2319,7 @@ export default {
// Quota control (Anthropic OAuth/SetupToken only)
quotaControl: {
title: '配额控制',
hint: '仅适用于 Anthropic OAuth/Setup Token 账号',
hint: '配置费用窗口、会话限制、客户端亲和等调度控制。',
windowCost: {
label: '5h窗口费用控制',
hint: '限制账号在5小时窗口内的费用使用',
@@ -2371,8 +2374,26 @@ export default {
hint: '将所有缓存创建 token 强制按指定的 TTL 类型5分钟或1小时计费',
target: '目标 TTL',
targetHint: '选择计费使用的 TTL 类型'
},
clientAffinity: {
label: '客户端亲和调度',
hint: '启用后,新会话会优先调度到该客户端之前使用过的账号,避免频繁切换账号'
}
},
affinityNoClients: '无亲和客户端',
affinityClients: '{count} 个亲和客户端:',
affinitySection: '客户端亲和',
affinitySectionHint: '控制客户端在账号间的分布。通过配置区域阈值来平衡负载。',
affinityToggle: '启用客户端亲和',
affinityToggleHint: '新会话优先调度到该客户端之前使用过的账号',
affinityBase: '基础限额(绿区)',
affinityBasePlaceholder: '留空表示不限制',
affinityBaseHint: '绿区最大客户端数量(完整优先级调度)',
affinityBaseOffHint: '未开启绿区限制,所有客户端均享受完整优先级调度',
affinityBuffer: '缓冲区(黄区)',
affinityBufferPlaceholder: '例如 3',
affinityBufferHint: '黄区允许的额外客户端数量(降级优先级调度)',
affinityBufferInfinite: '不限制',
expired: '已过期',
proxy: '代理',
noProxy: '无代理',
@@ -4363,40 +4384,55 @@ export default {
usage: '使用方法:在请求头中添加 x-api-key: <your-admin-api-key>'
},
soraS3: {
title: 'Sora S3 存储配置',
description: '以多配置列表方式管理 Sora S3 端点,并可切换生效配置',
title: 'Sora 存储配置',
description: '以多配置列表管理 Sora 媒体存储,支持 S3 和 Google Drive',
newProfile: '新建配置',
reloadProfiles: '刷新列表',
empty: '暂无 Sora S3 配置,请先创建',
createTitle: '新建 Sora S3 配置',
editTitle: '编辑 Sora S3 配置',
empty: '暂无存储配置,请先创建',
createTitle: '新建存储配置',
editTitle: '编辑存储配置',
selectProvider: '选择存储类型',
providerS3Desc: 'S3 兼容对象存储',
providerGDriveDesc: 'Google Drive 云盘',
profileID: '配置 ID',
profileName: '配置名称',
setActive: '创建后设为生效',
saveProfile: '保存配置',
activateProfile: '设为生效',
profileCreated: 'Sora S3 配置创建成功',
profileSaved: 'Sora S3 配置保存成功',
profileDeleted: 'Sora S3 配置删除成功',
profileActivated: 'Sora S3 生效配置已切换',
profileCreated: '存储配置创建成功',
profileSaved: '存储配置保存成功',
profileDeleted: '存储配置删除成功',
profileActivated: '生效配置已切换',
profileIDRequired: '请填写配置 ID',
profileNameRequired: '请填写配置名称',
profileSelectRequired: '请先选择配置',
endpointRequired: '启用时必须填写 S3 端点',
bucketRequired: '启用时必须填写存储桶',
accessKeyRequired: '启用时必须填写 Access Key ID',
deleteConfirm: '确定删除 Sora S3 配置 {profileID} 吗?',
deleteConfirm: '确定删除存储配置 {profileID} 吗?',
columns: {
profile: '配置',
profileId: 'Profile ID',
name: '名称',
provider: '存储类型',
active: '生效状态',
endpoint: '端点',
bucket: '存储',
storagePath: '存储路径',
capacityUsage: '容量 / 已用',
capacityUnlimited: '无限制',
videoCount: '视频数',
videoCompleted: '完成',
videoInProgress: '进行中',
quota: '默认配额',
updatedAt: '更新时间',
actions: '操作'
actions: '操作',
rootFolder: '根目录',
testInTable: '测试',
testingInTable: '测试中...',
testTimeout: '测试超时15秒'
},
enabled: '启用 S3 存储',
enabledHint: '启用后Sora 生成的媒体文件将自动上传到 S3 存储',
enabled: '启用存储',
enabledHint: '启用后Sora 生成的媒体文件将自动上传到存储',
endpoint: 'S3 端点',
region: '区域',
bucket: '存储桶',
@@ -4405,16 +4441,38 @@ export default {
secretAccessKey: 'Secret Access Key',
secretConfigured: '(已配置,留空保持不变)',
cdnUrl: 'CDN URL',
cdnUrlHint: '可选,配置后使用 CDN URL 访问文件,否则使用预签名 URL',
cdnUrlHint: '可选,配置后使用 CDN URL 访问文件',
forcePathStyle: '强制路径风格Path Style',
defaultQuota: '默认存储配额',
defaultQuotaHint: '未在用户或分组级别指定配额时的默认值0 表示无限制',
testConnection: '测试连接',
testing: '测试中...',
testSuccess: 'S3 连接测试成功',
testFailed: 'S3 连接测试失败',
saved: 'Sora S3 设置保存成功',
saveFailed: '保存 Sora S3 设置失败'
testSuccess: '连接测试成功',
testFailed: '连接测试失败',
saved: '存储设置保存成功',
saveFailed: '保存存储设置失败',
gdrive: {
authType: '认证方式',
serviceAccount: '服务账号',
clientId: 'Client ID',
clientSecret: 'Client Secret',
clientSecretConfigured: '(已配置,留空保持不变)',
refreshToken: 'Refresh Token',
refreshTokenConfigured: '(已配置,留空保持不变)',
serviceAccountJson: '服务账号 JSON',
serviceAccountConfigured: '(已配置,留空保持不变)',
folderId: 'Folder ID可选',
authorize: '授权 Google Drive',
authorizeHint: '通过 OAuth2 获取 Refresh Token',
oauthFieldsRequired: '请先填写 Client ID 和 Client Secret',
oauthSuccess: 'Google Drive 授权成功',
oauthFailed: 'Google Drive 授权失败',
closeWindow: '此窗口将自动关闭',
processing: '正在处理授权...',
testStorage: '测试存储',
testSuccess: 'Google Drive 存储测试成功(上传、访问、删除均正常)',
testFailed: 'Google Drive 存储测试失败'
}
},
streamTimeout: {
title: '流超时处理',
@@ -4910,6 +4968,7 @@ export default {
downloadLocal: '本地下载',
canDownload: '可下载',
regenrate: '重新生成',
regenerate: '重新生成',
creatorPlaceholder: '描述你想要生成的视频或图片...',
videoModels: '视频模型',
imageModels: '图片模型',
@@ -4926,6 +4985,13 @@ export default {
galleryEmptyTitle: '还没有任何作品',
galleryEmptyDesc: '你的创作成果将会展示在这里。前往生成页,开始你的第一次创作吧。',
startCreating: '开始创作',
yesterday: '昨天'
yesterday: '昨天',
landscape: '横屏',
portrait: '竖屏',
square: '方形',
examplePrompt1: '一只金色的柴犬在东京涩谷街头散步镜头跟随电影感画面4K 高清',
examplePrompt2: '无人机航拍视角,冰岛极光下的冰川湖面反射绿色光芒,慢速推进',
examplePrompt3: '赛博朋克风格的未来城市,霓虹灯倒映在雨后积水中,夜景,电影级色彩',
examplePrompt4: '水墨画风格,一叶扁舟在山水间漂泊,薄雾缭绕,中国古典意境'
}
}

View File

@@ -403,6 +403,8 @@ export interface AdminGroup extends Group {
// MCP XML 协议注入(仅 antigravity 平台使用)
mcp_xml_inject: boolean
// Claude usage 模拟开关(仅 anthropic 平台使用)
simulate_claude_max_enabled: boolean
// 支持的模型系列(仅 antigravity 平台使用)
supported_model_scopes?: string[]
@@ -497,6 +499,7 @@ export interface CreateGroupRequest {
fallback_group_id?: number | null
fallback_group_id_on_invalid_request?: number | null
mcp_xml_inject?: boolean
simulate_claude_max_enabled?: boolean
supported_model_scopes?: string[]
// 从指定分组复制账号
copy_accounts_from_group_ids?: number[]
@@ -525,6 +528,7 @@ export interface UpdateGroupRequest {
fallback_group_id?: number | null
fallback_group_id_on_invalid_request?: number | null
mcp_xml_inject?: boolean
simulate_claude_max_enabled?: boolean
supported_model_scopes?: string[]
copy_accounts_from_group_ids?: number[]
}
@@ -664,7 +668,6 @@ export interface Account {
// Extra fields including Codex usage and model-level rate limits (Antigravity smart retry)
extra?: (CodexUsageSnapshot & {
model_rate_limits?: Record<string, { rate_limited_at: string; rate_limit_reset_at: string }>
antigravity_credits_overages?: Record<string, { activated_at: string; active_until: string }>
} & Record<string, unknown>)
proxy_id: number | null
concurrency: number
@@ -721,6 +724,12 @@ export interface Account {
cache_ttl_override_enabled?: boolean | null
cache_ttl_override_target?: string | null
// 客户端亲和调度(仅 Anthropic/Antigravity 平台有效)
// 启用后新会话会优先调度到客户端之前使用过的账号
client_affinity_enabled?: boolean | null
affinity_client_count?: number | null
affinity_clients?: string[] | null
// API Key 账号配额限制
quota_limit?: number | null
quota_used?: number | null