diff --git a/Makefile b/Makefile index b0bc1b99f6..86cdaa1711 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ .PHONY: build build-backend build-frontend test test-backend test-frontend test-frontend-critical FRONTEND_CRITICAL_VITEST := \ + src/i18n/__tests__/localeKeyCompleteness.spec.ts \ src/api/__tests__/client.spec.ts \ src/api/__tests__/tokenRefresh.spec.ts \ src/api/__tests__/channelMonitorV2.spec.ts \ diff --git a/frontend/package.json b/frontend/package.json index 8af7a4668b..ea14f61962 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vue-tsc -b && vite build", + "check:i18n": "vitest run src/i18n/__tests__/localeKeyCompleteness.spec.ts", + "build": "pnpm run check:i18n && vue-tsc -b && vite build", "preview": "vite preview", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", "lint:check": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts", diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 451ac2a1ef..87af79dd5a 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -6368,7 +6368,7 @@ const handleOpenAIImportCodexPAT = async (accessToken: string) => { extra: withUpstreamRequestIdHeader(extra) }) - appStore.showSuccess(t('admin.accounts.messages.accountCreated')) + appStore.showSuccess(t('admin.accounts.accountCreated')) emit('created') handleClose() } catch (error: any) { diff --git a/frontend/src/i18n/__tests__/localeKeyCompleteness.spec.ts b/frontend/src/i18n/__tests__/localeKeyCompleteness.spec.ts new file mode 100644 index 0000000000..c6a08b8b8d --- /dev/null +++ b/frontend/src/i18n/__tests__/localeKeyCompleteness.spec.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' + +import en from '../locales/en' +import zh from '../locales/zh' + +type LocaleValue = Record + +function flattenLeafKeys(value: unknown, prefix = ''): string[] { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return prefix ? [prefix] : [] + } + + return Object.entries(value as LocaleValue).flatMap(([key, child]) => { + const path = prefix ? `${prefix}.${key}` : key + return flattenLeafKeys(child, path) + }) +} + +function collectStaticSourceKeys(source: string): string[] { + const keys = new Set() + + // Covers useI18n().t(), the global $t(), and direct i18n.t() calls in both + // TypeScript and Vue script/template source. Dynamic suffixes are checked by + // the runtime type of the value and cannot be proven from source text alone. + const translationCalls = /(?:\bi18n\.t|\$t|\bt)\s*\(\s*(['"])([^'"\r\n]+)\1/g + for (const match of source.matchAll(translationCalls)) { + const key = match[2] + if (!key.endsWith('.')) { + keys.add(key) + } + } + + // Router metadata and i18n-t are key references without a t() call. + const keyReferences = /(?:keypath|titleKey|descriptionKey)\s*[:=]\s*(['"])([^'"\r\n]+)\1/g + for (const match of source.matchAll(keyReferences)) { + keys.add(match[2]) + } + + const i18nTKeypaths = /]*\bkeypath\s*=\s*(['"])([^'"\r\n]+)\1/gi + for (const match of source.matchAll(i18nTKeypaths)) { + keys.add(match[2]) + } + + return [...keys] +} + +function sourceKeys(): string[] { + const sourceFiles = import.meta.glob('../../**/*.{ts,vue}', { + query: '?raw', + import: 'default', + eager: true + }) as Record + + return Object.entries(sourceFiles) + .filter(([path]) => !path.includes('/__tests__/') && !/\.(spec|test)\.ts$/.test(path)) + .flatMap(([, source]) => collectStaticSourceKeys(source)) +} + +function missingKeys(usedKeys: string[], availableKeys: Set): string[] { + return usedKeys.filter((key) => !availableKeys.has(key)).sort() +} + +describe('locale key completeness', () => { + const enKeys = new Set(flattenLeafKeys(en)) + const zhKeys = new Set(flattenLeafKeys(zh)) + const usedKeys = [...new Set(sourceKeys())].sort() + + it('keeps English and Chinese locale schemas identical', () => { + expect([...enKeys].filter((key) => !zhKeys.has(key)).sort()).toEqual([]) + expect([...zhKeys].filter((key) => !enKeys.has(key)).sort()).toEqual([]) + }) + + it('contains a non-empty message for every locale leaf', () => { + for (const [locale, messages] of Object.entries({ en, zh })) { + const emptyKeys = flattenLeafKeys(messages).filter((key) => { + let current: unknown = messages + for (const segment of key.split('.')) { + current = (current as LocaleValue)[segment] + } + return typeof current !== 'string' || current.trim() === '' + }) + expect(emptyKeys, `${locale} has empty or non-string messages`).toEqual([]) + } + }) + + it('contains every statically referenced production key', () => { + expect(missingKeys(usedKeys, enKeys), 'English locale is missing referenced keys').toEqual([]) + expect(missingKeys(usedKeys, zhKeys), 'Chinese locale is missing referenced keys').toEqual([]) + }) +}) diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 601accc20d..fb7a81a330 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -748,6 +748,8 @@ export default { modelRestriction: 'Model Restriction (Optional)', modelWhitelist: 'Model Whitelist', modelMapping: 'Model Mapping', + fromModel: 'Request model', + toModel: 'Target model', selectAllowedModels: 'Select allowed models. Leave empty to support all models.', mapRequestModels: 'Map request models to actual models. Left is the requested model, right is the actual model sent to API.', diff --git a/frontend/src/i18n/locales/en/admin/channels.ts b/frontend/src/i18n/locales/en/admin/channels.ts index ff8648cdf2..280597f7dc 100644 --- a/frontend/src/i18n/locales/en/admin/channels.ts +++ b/frontend/src/i18n/locales/en/admin/channels.ts @@ -64,6 +64,8 @@ export default { updateError: 'Failed to update channel', deleteError: 'Failed to delete channel', nameRequired: 'Please enter a channel name', + noGroupsSelected: 'Select at least one group for {platform}', + emptyModelsInPricing: 'Add at least one model to the {platform} pricing rule', duplicateModels: 'Model "{0}" appears in multiple pricing entries', modelConflict: "Model patterns '{model1}' and '{model2}' conflict: overlapping match range. Model names are matched case-insensitively, so an existing entry already covers all case variants — no need to add the variant separately.", mappingConflict: "Mapping source patterns '{model1}' and '{model2}' conflict: overlapping match range. Source patterns are matched case-insensitively, so an existing entry already covers all case variants.", diff --git a/frontend/src/i18n/locales/en/admin/ops.ts b/frontend/src/i18n/locales/en/admin/ops.ts index 4bb48c64da..46cd922b6b 100644 --- a/frontend/src/i18n/locales/en/admin/ops.ts +++ b/frontend/src/i18n/locales/en/admin/ops.ts @@ -579,6 +579,16 @@ export default { alertTitle: 'Alert Evaluator', groupAvailabilityTitle: 'Group Availability Monitor', evalIntervalSeconds: 'Evaluation Interval (seconds)', + metricThresholds: 'Metric Thresholds', + metricThresholdsHint: 'Configure alert thresholds for metrics, values exceeding thresholds will be displayed in red', + slaMinPercent: 'SLA Minimum Percentage', + slaMinPercentHint: 'SLA below this value will be displayed in red (default: 99.5%)', + ttftP99MaxMs: 'TTFT P99 Maximum (ms)', + ttftP99MaxMsHint: 'TTFT P99 above this value will be displayed in red (default: 500ms)', + requestErrorRateMaxPercent: 'Request Error Rate Maximum (%)', + requestErrorRateMaxPercentHint: 'Request error rate above this value will be displayed in red (default: 5%)', + upstreamErrorRateMaxPercent: 'Upstream Error Rate Maximum (%)', + upstreamErrorRateMaxPercentHint: 'Upstream error rate above this value will be displayed in red (default: 5%)', silencing: { title: 'Alert Silencing (Maintenance Mode)', enabled: 'Enable silencing', diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index b0b7ba8e74..bb43695a11 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -494,6 +494,7 @@ export default { leaveEmptyToKeep: 'Leave empty to keep current password', generatePassword: 'Generate random password', copyPassword: 'Copy password', + passwordCopied: 'Password copied', creating: 'Creating...', updating: 'Updating...', form: { @@ -829,7 +830,7 @@ export default { accountsAvailable: 'Avail:', accountsRateLimited: 'Limited:', accountsTotal: 'Total:', - accountsUnit: '', + accountsUnit: 'accounts', rateAndAccounts: '{rate}x rate · {count} accounts', accountsCount: '{count} accounts', rateLabel: 'rate', @@ -1220,12 +1221,6 @@ export default { searchAccountPlaceholder: 'Search accounts...', accountsHint: 'Select accounts to prioritize for this model pattern' }, - mcpXml: { - title: 'MCP XML Protocol Injection', - tooltip: 'When enabled, if the request contains MCP tools, an XML format call protocol prompt will be injected into the system prompt. Disable this to avoid interference with certain clients.', - enabled: 'Enabled', - disabled: 'Disabled' - }, claudeMaxSimulation: { title: 'Claude Max Usage Simulation', tooltip: @@ -1234,6 +1229,12 @@ export default { disabled: 'Disabled', hint: 'Only token categories in usage billing logs are adjusted. No per-request mapping state is persisted.' }, + mcpXml: { + title: 'MCP XML Protocol Injection', + tooltip: 'When enabled, if the request contains MCP tools, an XML format call protocol prompt will be injected into the system prompt. Disable this to avoid interference with certain clients.', + enabled: 'Enabled', + disabled: 'Disabled' + }, supportedScopes: { title: 'Supported Model Families', tooltip: 'Select the model families this group supports. Unchecked families will not be routed to this group.', diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts index d0afbee551..dffb64e0a3 100644 --- a/frontend/src/i18n/locales/en/common.ts +++ b/frontend/src/i18n/locales/en/common.ts @@ -12,6 +12,12 @@ export default { delete: 'Delete', edit: 'Edit', create: 'Create', + apply: 'Apply', + clear: 'Clear', + creating: 'Creating...', + required: 'Required', + sending: 'Sending...', + tryAgain: 'Please try again', update: 'Update', confirm: 'Confirm', reset: 'Reset', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index ad5cf05d3c..64f5e96e5f 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -825,6 +825,8 @@ export default { modelRestriction: '模型限制(可选)', modelWhitelist: '模型白名单', modelMapping: '模型映射', + fromModel: '请求模型', + toModel: '目标模型', selectAllowedModels: '选择允许的模型。留空则支持所有模型。', mapRequestModels: '将请求模型映射到实际模型。左边是请求的模型,右边是发送到 API 的实际模型。', selectedModels: '已选择 {count} 个模型', diff --git a/frontend/src/i18n/locales/zh/admin/channels.ts b/frontend/src/i18n/locales/zh/admin/channels.ts index d4a5145676..db3045f9a0 100644 --- a/frontend/src/i18n/locales/zh/admin/channels.ts +++ b/frontend/src/i18n/locales/zh/admin/channels.ts @@ -64,6 +64,8 @@ export default { updateError: '更新渠道失败', deleteError: '删除渠道失败', nameRequired: '请输入渠道名称', + noGroupsSelected: '请为 {platform} 至少选择一个分组', + emptyModelsInPricing: '请为 {platform} 定价规则至少添加一个模型', duplicateModels: '模型「{0}」在多个定价条目中重复', modelConflict: "模型模式 '{model1}' 和 '{model2}' 冲突:匹配范围重叠。模型名称按大小写不敏感匹配,已有条目已覆盖其所有大小写变体,无需重复添加。", mappingConflict: "模型映射源 '{model1}' 和 '{model2}' 冲突:匹配范围重叠。源模式按大小写不敏感匹配,已有条目已覆盖其所有大小写变体。", diff --git a/frontend/src/i18n/locales/zh/admin/ops.ts b/frontend/src/i18n/locales/zh/admin/ops.ts index 3e568e1dd7..c95ea7feee 100644 --- a/frontend/src/i18n/locales/zh/admin/ops.ts +++ b/frontend/src/i18n/locales/zh/admin/ops.ts @@ -579,6 +579,16 @@ export default { alertTitle: '告警评估器', groupAvailabilityTitle: '分组可用性监控', evalIntervalSeconds: '评估间隔(秒)', + metricThresholds: '指标阈值配置', + metricThresholdsHint: '配置各项指标的告警阈值,超出阈值时将以红色显示', + slaMinPercent: 'SLA 最低百分比', + slaMinPercentHint: 'SLA 低于此值时显示为红色(默认:99.5%)', + ttftP99MaxMs: 'TTFT P99 最大值(毫秒)', + ttftP99MaxMsHint: 'TTFT P99 高于此值时显示为红色(默认:500ms)', + requestErrorRateMaxPercent: '请求错误率最大值(%)', + requestErrorRateMaxPercentHint: '请求错误率高于此值时显示为红色(默认:5%)', + upstreamErrorRateMaxPercent: '上游错误率最大值(%)', + upstreamErrorRateMaxPercentHint: '上游错误率高于此值时显示为红色(默认:5%)', silencing: { title: '告警静默(维护模式)', enabled: '启用静默', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index a06abf9784..a9e0fb31f6 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -494,6 +494,7 @@ export default { leaveEmptyToKeep: '留空则保持原密码不变', generatePassword: '生成随机密码', copyPassword: '复制密码', + passwordCopied: '密码已复制', creating: '创建中...', updating: '更新中...', columns: { @@ -1213,6 +1214,12 @@ export default { selectAccounts: '选择账号', noAccounts: '此分组暂无账号', loadingAccounts: '加载账号中...', + removeRule: '删除规则', + noRules: '暂无路由规则', + noRulesHint: '添加路由规则以将特定模型请求优先路由到指定账号', + searchAccountPlaceholder: '搜索账号...', + accountsHint: '选择此模型模式优先使用的账号' + }, claudeMaxSimulation: { title: 'Claude Max 用量模拟', tooltip: @@ -1220,12 +1227,6 @@ export default { enabled: '已启用(模拟 1h 缓存)', disabled: '已禁用', hint: '仅调整用量计费日志中的 token 类别。不会持久化每个请求的映射状态。' - }, - removeRule: '删除规则', - noRules: '暂无路由规则', - noRulesHint: '添加路由规则以将特定模型请求优先路由到指定账号', - searchAccountPlaceholder: '搜索账号...', - accountsHint: '选择此模型模式优先使用的账号' }, mcpXml: { title: 'MCP XML 协议注入', diff --git a/frontend/src/i18n/locales/zh/common.ts b/frontend/src/i18n/locales/zh/common.ts index c5b2c4002e..2c61d3c7d0 100644 --- a/frontend/src/i18n/locales/zh/common.ts +++ b/frontend/src/i18n/locales/zh/common.ts @@ -12,6 +12,12 @@ export default { delete: '删除', edit: '编辑', create: '创建', + apply: '应用', + clear: '清除', + creating: '创建中...', + required: '必填', + sending: '发送中...', + tryAgain: '请重试', update: '更新', confirm: '确认', reset: '重置',