Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 32 additions & 32 deletions BitFun-Installer/src/i18n/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
},
"installPath": {
"notAbsolute": "安裝路徑必須是絕對路徑。",
"filesystemRoot": "不能安裝到磁盤根目錄,請選擇磁盤下的某個文件夾。",
"pathNotDirectory": "所選路徑已存在但不是文件夾。",
"filesystemRoot": "不能安裝到磁碟根目錄,請選擇磁碟下的某個資料夾。",
"pathNotDirectory": "所選路徑已存在但不是資料夾。",
"directoryMustBeEmptyOrBitfun": "安裝目錄必須為空,或已包含 BitFun 安裝。",
"inspectDirectoryFailed": "無法讀取安裝目錄,請檢查權限後重試。",
"directoryNotWritable": "安裝目錄不可寫入。請更換路徑,或以管理員身份運行安裝器(見下方說明)。",
"parentNotWritable": "上級目錄不可寫入。系統目錄(如 Program Files)通常需要管理員權限(見下方說明)。",
"adminHint": "若需安裝到受保護位置(例如 Program Files),請關閉本安裝器,在安裝程序上右鍵選擇「以管理員身份運行」後重新安裝。也可安裝到當前用戶目錄(例如 %LOCALAPPDATA%\\Programs),一般無需管理員權限。"
"adminHint": "若需安裝到受保護位置(例如 Program Files),請關閉本安裝器,在安裝程式上右鍵選擇「以管理員身份運行」後重新安裝。也可安裝到目前用戶目錄(例如 %LOCALAPPDATA%\\Programs),一般無需管理員權限。"
}
},
"options": {
Expand All @@ -24,35 +24,35 @@
"browse": "瀏覽",
"required": "所需空間",
"available": "可用空間",
"insufficientSpace": "磁盤空間不足",
"insufficientSpace": "磁碟空間不足",
"optionsLabel": "安裝選項",
"desktopShortcut": "創建桌面快捷方式",
"startMenu": "添加到開始菜單",
"desktopShortcut": "建立桌面快捷方式",
"startMenu": "新增到開始菜單",
"launchAfterInstall": "安裝後啟動 BitFun",
"back": "返回",
"install": "安裝",
"installing": "準備中…",
"existingInstallTitle": "檢測到本機已安裝 BitFun",
"existingInstallVersion": "已安裝版本:{{version}}",
"existingInstallLocation": "安裝位置:{{path}}",
"existingInstallBinaryMissing": "該路徑下未找到主程序文件,可先運行卸載程序或重新安裝。",
"existingInstallHint": "直接點擊「安裝」可在原位置升級或修復。若需先卸載,可點擊下方按鈕運行卸載程序。",
"existingInstallRunUninstaller": "運行卸載程序"
"existingInstallBinaryMissing": "該路徑下未找到主程式檔案,可先運行解除安裝程式或重新安裝。",
"existingInstallHint": "直接點擊「安裝」可在原位置升級或修復。若需先解除安裝,可點擊下方按鈕運行解除安裝程式。",
"existingInstallRunUninstaller": "運行解除安裝程式"
},
"model": {
"title": "模型",
"subtitle": "安裝完成,繼續配置模型與主題",
"subtitle": "安裝完成,繼續設定模型與主題",
"provider": "服務商",
"config": "連接信息",
"config": "連接資訊",
"modelName": "模型名稱(如 deepseek-v4-flash)",
"skip": "稍後配置",
"skip": "稍後設定",
"nextTheme": "下一步:主題",
"description": "設定和管理 AI 模型提供商",
"providerLabel": "選擇模型提供商",
"selectProvider": "或選擇預設提供商",
"customProvider": "自定義配置",
"customProvider": "自定義設定",
"modelNamePlaceholder": "輸入自定義模型名稱...",
"baseUrlPlaceholder": "示例:https://open.bigmodel.cn/api/paas/v4/chat/completions",
"baseUrlPlaceholder": "範例:https://open.bigmodel.cn/api/paas/v4/chat/completions",
"providers": {
"openbitfun": {
"name": "OpenBitFun",
Expand All @@ -70,7 +70,7 @@
"name": "MiniMax",
"description": "MiniMax 系列模型",
"urlOptions": {
"default": "Anthropic格式-默認",
"default": "Anthropic 格式-默認",
"openai": "OpenAI 相容格式"
}
},
Expand All @@ -86,18 +86,18 @@
"name": "智譜AI",
"description": "智譜AI GLM 系列模型",
"urlOptions": {
"default": "OpenAI格式-默認",
"anthropic": "Anthropic格式",
"codingPlan": "OpenAI格式-CodingPlan"
"default": "OpenAI 格式-默認",
"anthropic": "Anthropic 格式",
"codingPlan": "OpenAI 格式-CodingPlan"
}
},
"qwen": {
"name": "通義千問",
"description": "阿里雲百鍊大模型平臺",
"urlOptions": {
"default": "OpenAI格式-默認",
"codingPlan": "OpenAI格式-Coding Plan",
"codingPlanAnthropic": "Anthropic格式-Coding Plan"
"default": "OpenAI 格式-默認",
"codingPlan": "OpenAI 格式-Coding Plan",
"codingPlanAnthropic": "Anthropic 格式-Coding Plan"
}
},
"volcengine": {
Expand All @@ -108,8 +108,8 @@
"name": "硅基流動",
"description": "硅基流動大模型平臺",
"urlOptions": {
"default": "OpenAI格式-默認",
"anthropic": "Anthropic格式"
"default": "OpenAI 格式-默認",
"anthropic": "Anthropic 格式"
}
},
"nvidia": {
Expand All @@ -130,8 +130,8 @@
"fetchingModels": "正在擷取模型清單...",
"fetchFailedFallback": "拉取模型列表失敗,已回退到常用預設模型",
"fetchEmptyFallback": "供應商未返回可用模型,已回退到常用預設模型",
"usingPresetModels": "當前顯示的是常用預設模型",
"addCustomModel": "添加自定義模型",
"usingPresetModels": "目前顯示的是常用預設模型",
"addCustomModel": "新增自定義模型",
"form": {
"baseUrl": "API 位址",
"apiKey": "API密鑰",
Expand All @@ -153,15 +153,15 @@
"progress": {
"title": "安裝中",
"prepare": "準備中",
"extract": "正在解壓文件",
"extract": "正在解壓檔案",
"registry": "正在註冊應用",
"shortcuts": "正在創建快捷方式",
"shortcuts": "正在建立快捷方式",
"path": "正在更新 PATH",
"config": "正在應用啟動偏好設置",
"complete": "即將完成",
"starting": "啟動中...",
"failed": "安裝失敗",
"confirmContinue": "繼續完成配置"
"confirmContinue": "繼續完成設定"
},
"themeSetup": {
"title": "主題與啟動",
Expand All @@ -182,13 +182,13 @@
"finish": "完成"
},
"uninstall": {
"title": "卸載 BitFun",
"title": "解除安裝 BitFun",
"subtitle": "將移除 BitFun 及其集成項(快捷方式、右鍵菜單、PATH)。",
"installPath": "安裝目錄",
"pathUnknown": "未檢測到安裝目錄",
"confirm": "開始卸載",
"uninstalling": "正在卸載...",
"completed": "卸載已完成,可關閉窗口。",
"confirm": "開始解除安裝",
"uninstalling": "正在解除安裝...",
"completed": "解除安裝已完成,可關閉視窗。",
"cancel": "取消",
"close": "關閉"
}
Expand Down
7 changes: 5 additions & 2 deletions docs/architecture/i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ to their current locale data.
namespaces load lazily through the i18next backend when a component requests
them with `useI18n(namespace)` or `i18nService.loadNamespace(namespace)`.
- Mobile Web owns mobile-only resources and must not import Web UI locale
catalogs.
catalogs. User-visible date/time values should be formatted through the
Mobile Web i18n context instead of per-component `Intl` or `toLocale*` calls.
- Installer owns installer-only resources and must not depend on app runtime
locale catalogs.
- Backend owns backend Fluent resources and uses generated contract helpers for
Expand Down Expand Up @@ -226,7 +227,9 @@ durable architecture and development rules in this document and
`scripts/i18n-locale-format-baseline.json` are temporary no-growth baselines for
existing call-site debt. They should move downward as literal fallback copy and
direct locale formatting are moved behind owned locale resources and i18n
formatting helpers.
formatting helpers. Direct `Intl` usage is allowed only inside surface i18n
owners that expose those helpers; product call sites should not be added to the
baseline when a helper can represent the same value.

## Backend And Frontend Language Contract

Expand Down
8 changes: 5 additions & 3 deletions docs/development/i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
`BitFun-Installer`, Rust crates, or server apps.
- Persist and send canonical app locale ids, not aliases.
- Use surface i18n formatting helpers for user-visible dates, times, numbers,
and currency instead of direct `Intl.*` or `toLocale*` calls.
and currency instead of direct `Intl.*` or `toLocale*` calls. Direct `Intl`
usage belongs only inside the surface i18n owner that exposes those helpers.
- In Web UI, request route or feature namespaces through `useI18n(namespace)`.
Add a namespace to `WEB_UI_BOOTSTRAP_NAMESPACES` only when a synchronous
`i18nService.t('namespace:key')` call must run during module initialization.
Expand Down Expand Up @@ -153,8 +154,9 @@ such as a server-provided role name or description.
Direct user-visible locale formatting calls are tracked by
`scripts/i18n-locale-format-baseline.json`. New code should format through the
surface i18n API, such as Web UI `useI18n().formatNumber` or
`i18nService.formatDate`, so language changes and fallback rules stay aligned.
Lower the baseline whenever a call site moves behind the shared helper.
`i18nService.formatDate`, or Mobile Web `useI18n().formatDate`, so language
changes and fallback rules stay aligned. Lower the baseline whenever a call
site moves behind the shared helper.

Repeated values are not automatically wrong. Generic atoms such as
`common:actions.cancel` should be reused when the meaning is identical, but
Expand Down
65 changes: 53 additions & 12 deletions scripts/i18n-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ let errorCount = 0;
let warningCount = 0;
let auditTypeScript = null;
let cliOptions = { reportJsonPath: null };
let governanceBaselineCache;
const reportCategories = [
'confirmedUnusedKeys',
'dynamicKeyCandidates',
Expand Down Expand Up @@ -250,13 +251,16 @@ function sortByReportIdentity(left, right) {

function countEntriesBy(entries, field, options = {}) {
const emptyLabel = options.emptyLabel ?? '';
const includeKeys = options.includeKeys ?? [];
const counts = entries.reduce((acc, entry) => {
const value = entry[field] ?? emptyLabel;
const key = value === '' ? emptyLabel : value;
acc.set(key, (acc.get(key) ?? 0) + 1);
return acc;
}, new Map(includeKeys.map((key) => [key, 0])));

return Object.fromEntries(
Array.from(entries.reduce((counts, entry) => {
const value = entry[field] ?? emptyLabel;
const key = value === '' ? emptyLabel : value;
counts.set(key, (counts.get(key) ?? 0) + 1);
return counts;
}, new Map()).entries())
Array.from(counts.entries())
.sort(([left], [right]) => String(left).localeCompare(String(right))),
);
}
Expand All @@ -267,6 +271,9 @@ function finalizeGovernanceReport() {
governanceReport.summary.counts[category] = governanceReport[category].length;
}

const governanceSurfaceIds = collectGovernanceBudgetSurfaceIds();
const localeFormatSurfaceIds = collectLocaleFormatSurfaceIds();

governanceReport.summary.byCategory = {
confirmedUnusedKeys: {
bySurface: countEntriesBy(governanceReport.confirmedUnusedKeys, 'surface'),
Expand All @@ -279,20 +286,20 @@ function finalizeGovernanceReport() {
sharedTermDuplicates: {
byNamespace: countEntriesBy(governanceReport.sharedTermDuplicates, 'namespace', { emptyLabel: '<none>' }),
bySharedKey: countEntriesBy(governanceReport.sharedTermDuplicates, 'sharedKey'),
bySurface: countEntriesBy(governanceReport.sharedTermDuplicates, 'surface'),
bySurface: countEntriesBy(governanceReport.sharedTermDuplicates, 'surface', { includeKeys: governanceSurfaceIds }),
},
l10nQualityCandidates: {
byComparisonLocale: countEntriesBy(governanceReport.l10nQualityCandidates, 'comparisonLocale'),
byNamespace: countEntriesBy(governanceReport.l10nQualityCandidates, 'namespace', { emptyLabel: '<none>' }),
bySurface: countEntriesBy(governanceReport.l10nQualityCandidates, 'surface'),
bySurface: countEntriesBy(governanceReport.l10nQualityCandidates, 'surface', { includeKeys: governanceSurfaceIds }),
},
literalDefaultValueFallbacks: {
byFile: countEntriesBy(governanceReport.literalDefaultValueFallbacks, 'file'),
byNamespace: countEntriesBy(governanceReport.literalDefaultValueFallbacks, 'namespace', { emptyLabel: '<none>' }),
},
localeFormatCandidates: {
byFile: countEntriesBy(governanceReport.localeFormatCandidates, 'file'),
bySurface: countEntriesBy(governanceReport.localeFormatCandidates, 'surface'),
bySurface: countEntriesBy(governanceReport.localeFormatCandidates, 'surface', { includeKeys: localeFormatSurfaceIds }),
},
};
}
Expand Down Expand Up @@ -1242,8 +1249,13 @@ function readL10nIdenticalAllowlist() {
}

function readGovernanceBaseline() {
if (governanceBaselineCache !== undefined) {
return governanceBaselineCache;
}

if (!fs.existsSync(governanceBaselinePath)) {
reportError('Missing scripts/i18n-governance-baseline.json');
governanceBaselineCache = null;
return null;
}

Expand All @@ -1252,6 +1264,7 @@ function readGovernanceBaseline() {
baseline = readJsonFile(governanceBaselinePath);
} catch (error) {
reportError(`Failed to parse scripts/i18n-governance-baseline.json: ${error.message}`);
governanceBaselineCache = null;
return null;
}

Expand All @@ -1260,12 +1273,29 @@ function readGovernanceBaseline() {
}
if (!isPlainObject(baseline.budgets)) {
reportError('scripts/i18n-governance-baseline.json must define a budgets object');
governanceBaselineCache = null;
return null;
}

governanceBaselineCache = baseline;
return baseline;
}

function collectGovernanceBudgetSurfaceIds() {
const baseline = readGovernanceBaseline();
if (!baseline) return [];

const surfaces = new Set();
for (const budget of Object.values(baseline.budgets)) {
if (!isPlainObject(budget?.bySurface)) continue;
for (const surface of Object.keys(budget.bySurface)) {
surfaces.add(surface);
}
}

return Array.from(surfaces).sort();
}

function allowlistTargetForGroup(entry, group) {
if (entry.surface !== group.surface) return null;
if (entry.namespace && entry.namespace !== group.namespace) return null;
Expand Down Expand Up @@ -1959,7 +1989,10 @@ function countCjkSourceLines(scanRoot, predicate) {
function shouldSkipLocaleFormatSourceScan(file) {
const normalized = toPosixPath(path.relative(root, file));
return (
// Surface i18n owners are the only approved locations for direct Intl usage;
// product code must call their exported formatting helpers instead.
normalized === 'src/web-ui/src/infrastructure/i18n/core/I18nService.ts' ||
normalized === 'src/mobile-web/src/i18n/I18nProvider.tsx' ||
normalized.endsWith('/generatedLocaleContract.ts') ||
normalized.endsWith('.test.ts') ||
normalized.endsWith('.test.tsx') ||
Expand All @@ -1968,9 +2001,8 @@ function shouldSkipLocaleFormatSourceScan(file) {
);
}

function collectLocaleFormatUsageFindings() {
const formatPattern = /\b(?:new\s+)?Intl\.(?:DateTimeFormat|NumberFormat|RelativeTimeFormat)\s*\(|\.\s*toLocale(?:String|DateString|TimeString)\s*\(/g;
const specs = [
function createLocaleFormatScanSpecs() {
return [
{
surface: 'web-ui',
root: webSourceDir,
Expand Down Expand Up @@ -2004,6 +2036,15 @@ function collectLocaleFormatUsageFindings() {
predicate: (file) => file.endsWith('.js'),
},
];
}

function collectLocaleFormatSurfaceIds() {
return sortedUnique(createLocaleFormatScanSpecs().map((spec) => spec.surface));
}

function collectLocaleFormatUsageFindings() {
const formatPattern = /\b(?:new\s+)?Intl\.(?:DateTimeFormat|NumberFormat|RelativeTimeFormat)\s*\(|\.\s*toLocale(?:String|DateString|TimeString)\s*\(/g;
const specs = createLocaleFormatScanSpecs();
const findings = [];

for (const spec of specs) {
Expand Down
Loading
Loading