mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 14:57:36 +08:00
fix(i18n): fail builds on missing locale keys
This commit is contained in:
@@ -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 \
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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}' 冲突:匹配范围重叠。源模式按大小写不敏感匹配,已有条目已覆盖其所有大小写变体。",
|
||||
|
||||
@@ -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 协议注入',
|
||||
|
||||
@@ -12,6 +12,12 @@ export default {
|
||||
delete: '删除',
|
||||
edit: '编辑',
|
||||
create: '创建',
|
||||
apply: '应用',
|
||||
clear: '清除',
|
||||
creating: '创建中...',
|
||||
required: '必填',
|
||||
sending: '发送中...',
|
||||
tryAgain: '请重试',
|
||||
update: '更新',
|
||||
confirm: '确认',
|
||||
reset: '重置',
|
||||
|
||||
Reference in New Issue
Block a user