fix(i18n): fail builds on missing locale keys

This commit is contained in:
Steven Moder
2026-09-06 04:16:06 +08:00
parent ab99d56e96
commit b3ad9b67cf
14 changed files with 149 additions and 15 deletions
+1
View File
@@ -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 \
+2 -1
View File
@@ -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",
@@ -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) {
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'
import en from '../locales/en'
import zh from '../locales/zh'
type LocaleValue = Record<string, unknown>
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<string>()
// 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 = /<i18n-t\b[^>]*\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<string, string>
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>): 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([])
})
})
@@ -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.',
@@ -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.",
+10
View File
@@ -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',
@@ -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.',
+6
View File
@@ -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',
@@ -825,6 +825,8 @@ export default {
modelRestriction: '模型限制(可选)',
modelWhitelist: '模型白名单',
modelMapping: '模型映射',
fromModel: '请求模型',
toModel: '目标模型',
selectAllowedModels: '选择允许的模型。留空则支持所有模型。',
mapRequestModels: '将请求模型映射到实际模型。左边是请求的模型,右边是发送到 API 的实际模型。',
selectedModels: '已选择 {count} 个模型',
@@ -64,6 +64,8 @@ export default {
updateError: '更新渠道失败',
deleteError: '删除渠道失败',
nameRequired: '请输入渠道名称',
noGroupsSelected: '请为 {platform} 至少选择一个分组',
emptyModelsInPricing: '请为 {platform} 定价规则至少添加一个模型',
duplicateModels: '模型「{0}」在多个定价条目中重复',
modelConflict: "模型模式 '{model1}' 和 '{model2}' 冲突:匹配范围重叠。模型名称按大小写不敏感匹配,已有条目已覆盖其所有大小写变体,无需重复添加。",
mappingConflict: "模型映射源 '{model1}' 和 '{model2}' 冲突:匹配范围重叠。源模式按大小写不敏感匹配,已有条目已覆盖其所有大小写变体。",
+10
View File
@@ -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: '启用静默',
@@ -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 协议注入',
+6
View File
@@ -12,6 +12,12 @@ export default {
delete: '删除',
edit: '编辑',
create: '创建',
apply: '应用',
clear: '清除',
creating: '创建中...',
required: '必填',
sending: '发送中...',
tryAgain: '请重试',
update: '更新',
confirm: '确认',
reset: '重置',