Appearance
ADR-059: i18n 多语言切换框架
状态: 已完成(2026-07-10 全部 Phase 落地,奇偶校验脚本已接 CI;2026-07-22 废弃 Go UIState 持久化升级预留路径;2026-07-30 补齐首访系统语言自动识别;剩余 ja/ko/zh-TW 翻译质量为人工/AI 走查任务,非框架范畴) 日期: 2026-07-10 关联: ADR-043(DanceXR 差距分析)、ADR-044(竞品分析) 背景: 当前全仓 UI 字符串为硬编码中文,约 100 个
.ts文件含中文字面量,分布于menus/、core/ui-*、scene/、physics/。无 i18n 框架、无语言偏好入口。竞品 DanceXR 已支持 5 种语言(简/繁中、英、日、韩)。本 ADR 锁定一套与现有core/reactivity体系对齐的轻量 i18n 方案。
一、问题边界
1.1 现状清点
| 项 | 事实 | 来源 |
|---|---|---|
| i18n 框架 | 无。node_modules/y18n 仅为传递依赖,不可用 | 全仓 grep |
| 字符串分布 | 约 100 个 .ts 文件含中文字面量;menus/*.ts、core/ui-*.ts、scene/*、physics/* 全量内联 | grep [\p{Han}] |
| UI 渲染模型 | 命令式 DOM,SlideMenu updateControls() 由 core/reactivity.ts 的 scheduleRefresh() 驱动重渲染 | core/reactivity.ts |
| 偏好持久化 | UIState(30+ 字段) 仅 7 个落盘到 Go;语言项不在其中 | core/types.ts:219 |
| 现有先例 | getMmdRuntimeType/setMmdRuntimeType(core/state.ts:33) 用 localStorage+try/catch 存前端偏好 | state.ts |
| 设置归口 | menus/settings-targets.ts 已集中所有 settings 导航 target | settings-targets.ts |
1.2 痛点
- 无切换能力:用户无法切换界面语言。
- 抽取成本高:字符串散落内联,无中央目录,逐条迁移机械但量大(估算数千条)。
- 动态字符串:
setStatus(\已加载 ${n} 个模型`)` 等模板插值需占位符方案,不能简单 key 查表。 - 排序 collation:
library-core.ts:432用localeCompare(b.label, 'zh')硬编码中文排序,需随语言切换。
1.3 与竞品的关系
| 维度 | DanceXR | 本联邦(目标) |
|---|---|---|
| 语言数 | 5(简/繁中、英、日、韩) | 5(对齐) |
| 切换入口 | 系统菜单 → 语言 | 设置 → 语言(对齐 settings-targets.ts) |
| 持久化 | 设置内 | localStorage(MVP,零 Go 改动) |
二、方案设计
2.1 核心思路:自研轻量 core/i18n/
不引入 i18next 等重库。现有 UI 是纯命令式 DOM + 自研 reactivity(subscribe/scheduleRefresh),已具备「改状态 → 重渲染所有已开菜单」的热切换能力。i18next 的组件级订阅模型与本体系重叠,且其 Bundle 机制对 SPA 命令式渲染是过度设计。
core/i18n/
locale.ts // currentLang signal + get/set + localStorage 持久化(镜像 setMmdRuntimeType)
t.ts // t(key, params?) 翻译函数;缺失回退 zh-CN → key
locales/
zh-CN.ts // 基准 bundle(现有硬编码字符串迁移目标)
zh-TW.ts
en.ts
ja.ts
ko.ts2.2 语言清单(与竞品对齐)
| code | BCP-47 | 说明 | MVP 优先级 |
|---|---|---|---|
zh-CN | zh-CN | 简体中文(基准,当前默认) | P0 |
en | en | 英语 | P0(试点) |
ja | ja | 日语 | P1 |
ko | ko | 韩语 | P1 |
zh-TW | zh-TW | 繁体中文 | P2 |
2.3 热切换机制
ts
// core/i18n/locale.ts
import { reactive, scheduleRefresh } from '../reactivity';
type LangCode = 'zh-CN' | 'en' | 'ja' | 'ko' | 'zh-TW';
const LANG_KEY = 'uiLang';
const FALLBACK: LangCode = 'zh-CN';
const state = reactive({ lang: loadLang() });
export function getLang(): LangCode { return state.lang; }
// [doc:adr-059] 切换语言 → 持久化 + 触发所有已开菜单重渲染
export function setLang(lang: LangCode): void {
state.lang = lang;
saveLang(lang); // try/catch localStorage,镜像 setMmdRuntimeType
scheduleRefresh(); // 已开 SlideMenu 的 updateControls() 自动重读 t()
}
// 语言决策优先级:用户手选(localStorage)> 系统语言(navigator)> 基准 zh-CN
function loadLang(): LangCode {
try {
const v = localStorage.getItem(LANG_KEY) as LangCode | null;
if (v && SUPPORTED.includes(v)) return v; // 用户显式选择最高优先
} catch { /* localStorage 不可用:继续尝试系统语言 */ }
return detectSystemLang() ?? FALLBACK; // 首访:读浏览器/WebView 语言偏好
}
function saveLang(l: LangCode): void {
try { localStorage.setItem(LANG_KEY, l); } catch { /* ignore */ }
}首访系统语言自动识别(2026-07-30 补充,§3.6):loadLang 在 localStorage 无合法记录时调用 detectSystemLang(),按 navigator.languages 顺序取首个受支持语言,实现「看客下菜」。详见 §3.6。
关键:所有菜单标签在 updateControls() 渲染时调用 t() 读取,因此 scheduleRefresh() 后已开面板标签自动刷新,无需重建菜单树。
2.4 t() API 与回退
ts
// core/i18n/t.ts
import { getLang } from './locale';
import { zhCN } from './locales/zh-CN';
type Bundle = Record<string, string>;
const bundles: Record<string, Bundle> = { 'zh-CN': zhCN /*, en, ja, ko, zh-TW */ };
// [doc:adr-059] {name} 占位符替换;缺失回退 zh-CN → key 本身(开发期可见)
export function t(key: string, params?: Record<string, string | number>): string {
const lang = getLang();
let s = bundles[lang]?.[key] ?? zhCN[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
s = s.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v));
}
}
return s;
}2.5 设置页入口
ts
// menus/settings-targets.ts —— 新增导航 target
export const SETTINGS = {
// ... 现有 10 项
LANGUAGE: 'settings:language', // [doc:adr-059]
} as const;menus/settings.ts 新增「语言」行 → 子菜单列出 5 种语言,radio 选中 getLang();点击调 setLang(code)(自动 scheduleRefresh)。
2.6 字符串抽取约定
| 类型 | 改造前 | 改造后 |
|---|---|---|
| 静态标签 | '模型库' | t('menu.library.title') |
| 动态状态 | `已加载 ${n} 个模型` | t('status.modelsLoaded', { n }) |
| emoji | 保留(通用符号,不翻译) | 保留 |
| 排序 | localeCompare(b.label, 'zh') | localeCompare(b.label, getLang()) |
三、详细实现
3.1 首屏时序
在 core/init.ts 早期(ADR-102 后 init 入口由 main.ts 迁出;菜单渲染前)调用 initI18n():
ts
// [doc:adr-059] 菜单渲染前确定语言,避免首帧闪烁
import { getLang } from './core/i18n/locale';
export function initI18n(): void { /* 预读 localStorage 已由 locale.ts 模块加载期完成 */ void getLang(); }locale bundle 为同步导入的 TS 对象(体积小、可 tree-shake),无需异步 fetch,规避首屏时序风险。
3.2 抽取分批策略(降低回归风险)
按模块分批,每批 npm run check && npm run test && npm run build 验证:
- Phase 1(试点):
core/i18n/*+menus/settings.ts+menus/settings-targets.ts+menus/library.ts - Phase 2:
menus/model-detail.ts、menus/scene-*.ts、menus/motion-*.ts - Phase 3:
core/ui-*.ts、core/dialog.ts、core/state.ts状态消息 - Phase 4:
scene/*、physics/*中的setStatus/toast
3.3 动态字符串统一
全仓 setStatus(\...${x}...`)等模板插值,统一改为t('status.xxx', { x })。需建立状态消息 key 命名空间 status.*`。
3.4 排序 collation
library-core.ts:432 改为 localeCompare(b.label, getLang()),使列表排序随语言 locale 调整。
3.5 防回归(可选 Phase)
- key 奇偶校验脚本(已实现,已接 CI,strict 模式):
scripts/i18n-check.mjs以zh-CN为基准,比对en/ja/ko/zh-TW的 key 集合,列出缺失 / 多余 key。已挂 CI(test-frontendjob,--strict模式阻塞)。新增 key 必须同步翻译到所有 bundle,否则 CI 失败。- 运行:
node ../scripts/i18n-check.mjs --strict;任何缺失即 exit 1。 - npm:
npm run check:i18n(在frontend/内)。
- 运行:
- 字面量 lint-grep(未来可选):新增 eslint / grep 预检,禁止在
menus/、core/ui-*、scene/、physics/(非locales/)直接出现未包裹中文字面量。当前未被 i18n 化代码仍含中文(如settings-filename.ts等命名空间尚未迁移),故暂不强制,避免误伤。
3.6 首访系统语言自动识别(2026-07-30 补充)
背景:早期 loadLang 在 localStorage 无记录时一律回落 zh-CN,首访的英/日/韩用户被强制发简体中文。五语 bundle 已备齐,却未“看客下菜”。
方案:新增 detectSystemLang()(core/i18n/locale.ts,已导出供单测),仅在 localStorage 无合法记录时由 loadLang 调用。优先级链:用户手选(localStorage)> 系统语言(navigator)> 基准 zh-CN,不覆盖任何人已有的显式选择。
匹配规则(按 navigator.languages 顺序,取首个命中;languages 为空时回落 navigator.language 单值):
| navigator 语言标签 | 匹配结果 | 说明 |
|---|---|---|
zh-Hant / zh-TW / zh-HK / zh-MO(含脚本) | zh-TW | 繁体:Hant 脚本或港澳台地区码 |
其余 zh 变体(zh / zh-CN / zh-SG / zh-Hans …) | zh-CN | 简体(含新加坡 zh-SG) |
en / ja / ko 及地区变体(en-US/ja-JP…) | 对应基准语言 | 取 - 前缀比对 |
其余(fr/de…) | null | 均不命中→ loadLang 回落 FALLBACK |
健壮性:navigator 不存在、languages 为空、访问抛异常三条路径均有兜底(try/catch + typeof navigator 守卫),最终稳落 zh-CN。
繁简判定的规则型语义:采“Hant/TW/HK/MO 判繁,其余 zh 判简”的白名单式规则(非穷举)。若未来接入更多 zh 地区,需回看此表。
测试:core/i18n/locale.detect.test.ts——vi.stubGlobal('navigator', ...) 覆盖偏好,7 项用例覆盖繁/简/基准/顺序优先/无命中回 null/language 单值降级/navigator 缺失。
四、决策对比
| 方案 | 描述 | 优点 | 缺点 |
|---|---|---|---|
A. 自研轻量 core/i18n/(本 ADR) | signal + t() + TS bundle + localStorage | 与 scheduleRefresh 体系零摩擦、零依赖、热切换天然 | 需自维护 bundle 与抽取流程 |
| B. i18next | 成熟 i18n 库 | 生态全、plural/ICU 内置 | 与自研 reactivity 重叠;Bundle 体积与 API 对命令式 DOM 过度设计;需适配层 |
C. Go UIState + SetUILanguage | 语言进 Go 持久化 | 与设置导入/导出统一、跨设备 | 需改 Go + wails3 generate;与 MmdRuntimeType 的 localStorage 先例不一致 |
选 A 为 MVP:复用 setMmdRuntimeType 的 localStorage 先例,零 Go 改动,最快跑通热切换闭环。
持久化升级路径(已废弃):原预留"语言进 Go UIState + SetUILanguage 绑定"的升级路径已被废弃。经评估,迁移至 Go 持久化会破坏首屏同步时序、增加热切换异步复杂度、并在 npm run dev 无 Wails 模式下引入 binding mock 成本,收益(设置包统一、跨设备同步)与本项目当前阶段不匹配。语言持久化永久采用 localStorage,与 setMmdRuntimeType 先例保持一致。若未来出现强需求(如云账号同步、设置包必须包含语言),应作为新 ADR 重新评估,而非直接执行本 ADR 预留路径。
五、实施路标
Phase 1: 核心框架 + 试点(~2–3 天)
- [x] 新建
core/i18n/locale.ts(getLang/setLang+ localStorage 持久化,镜像setMmdRuntimeType) - [x] 新建
core/i18n/t.ts(t(key, params?)+ 回退链) - [x] 新建
core/i18n/locales/zh-CN.ts+en.ts(基准 bundle + 英语试点;settings/lang命名空间) - [x]
menus/settings-targets.ts增SETTINGS.LANGUAGE - [x]
menus/settings.ts增「语言」行(根级 folder)+ 子菜单 radio(buildSettingsLanguageLevel,lang:target →setLang) - [x]
core/init.ts期(ADR-102 后由main.ts迁出)initI18n()读取语言并同步<html lang> - [x] 热切换试点:设置根级 9 项 + 语言子菜单均经
t()化,点击语言即setLang→scheduleRefresh热刷新(注:原计划的library.ts试点改为设置页 pilot——library.ts经核查无用户界面中文字符串,故以设置页为演示载体) - [x] 验证:
npm run check✅ /npm run test✅(1099 passed)/npm run build✅
Phase 2: 批量抽取 menus/(已完成 2026-07-07)
- [x]
menus/model-detail.ts、menus/scene-*.ts(7 文件:scene-menu / scene-render-levels / scene-stage-levels / scene-prop-levels / scene-stage-lights / scene-physics-levels / scene-render-presets)、menus/motion-*.ts(4 文件:motion-popup / motion-cloth-levels / motion-procmotion-levels / motion-camera-levels)全量t()化 - [x] 补全
locales/zh-CN.ts;en.ts翻译与zh-CN同步(feature 命名空间model-detail.*/motion.*/scene.*,含 procmotion + camera 子域) - [x] 约定:功能反馈消息进 feature 命名空间(如
scene.statusXxx),不另立status.*;模块级const LABELS中文固化陷阱 → 统一改为KEYS映射(仅存 i18n key)在运行时t(),保证热切换刷新 - [x] 验证:
npm run check✅ /npm run test✅(1100 passed)/npm run build✅
修复记录(2026-07-07)
- [x] 幽灵语言修复:
t.ts新增AVAILABLE_LANGS(= 有 bundle 的语言);settings.ts语言子菜单SUPPORTED_LANGS用AVAILABLE_LANGS.includes(code)过滤。ja/ko/zh-TW 在补全 bundle(Phase 4)前不再作为可选项,消除「切换无效」误导。locale.ts的SUPPORTED_LANGS注释已澄清其为「规划清单」语义。 - [x] Library 菜单 i18n:
menus/library-core.ts全量t()化(~47 个library.*key,zh-CN.ts/en.ts同步,407 key 完全对齐);AVAILABLE_LANGS已含 zh-CN+en。原 Phase 1 完成说明称「library.ts无中文 UI」不准确——真正渲染实现library-core.ts当时含大量硬编码中文,本次已修正。 - [x] 排序 collation:
library-core.ts列表排序localeCompare(b.label, 'zh')→localeCompare(b.label, getLang()),随语言切换(ADR §3.4)。 - [x] 验证:
npm run check✅ /npm run test✅(1100 passed)/npm run build✅
Phase 3: core / 非菜单模块抽取(~2–3 天)
注:菜单域(含
menus/scene-*.ts、menus/motion-*.ts)内的setStatus/toast 已在 Phase 2 一并t('scene.*'/'motion.*')化;本阶段仅剩中央/非菜单模块。
- [x]
core/ui-*.ts、core/dialog.ts、core/state.ts状态消息(剩余缺口:core/shortcut-registry.ts 快捷键注册表 label/group(ADR-102/036 后由main.ts迁出)、settings-shortcuts.ts 渲染、dialog.ts 默认按钮、ui-rows.ts 监听目录行 — 2026-07-10 收尾) - [x]
physics/*(cloth-manager 等)、scene/*非菜单模块内setStatus/toast 改为t('scene.*'/'physics.*', params)(核查:scene 11 文件 + physics/ragdoll-manager/cloth-manager 均已 t() 化) - [x]
library-core.ts:434collation 随语言切换(localeCompare(b.label, getLang()))
Phase 4: 多语言补全 + 防回归(按需)
- [x]
locales/ja.ts、ko.ts、zh-TW.tsbundle 已建(t.ts 已注册,语言菜单可选);翻译 key 对齐由scripts/i18n-check.mjs奇偶校验守门,剩余缺口见 §3.5 - [x] i18n bundle key 奇偶校验脚本
scripts/i18n-check.mjs接入 CI(见 §3.5,strict 模式,缺口清零后升级)
修订记录(2026-07-22)
- [x] 废弃 Go 持久化升级路径:原预留"语言进 Go
UIState+SetUILanguage绑定"的升级路径被废弃。语言持久化永久采用localStorage,与setMmdRuntimeType先例保持一致。理由见 §四决策对比。若未来出现强需求,应通过新 ADR 重新评估。
六、风险与边界
| 风险 | 等级 | 缓解 |
|---|---|---|
| 字符串抽取量大、易漏易错 | 高 | 分批 + 每批 check/test/build;可选防回归 grep 规则 |
| 动态字符串占位符约定不一致 | 中 | 统一 {name} 语法;t() 强制 params 类型 |
| 首屏闪烁(语言未定先渲染) | 低 | locale 模块加载期即读 localStorage,菜单渲染前已定 |
| 已开菜单未重渲染 | 低 | 所有标签在 updateControls() 内调 t(),由 scheduleRefresh() 触发 |
| 持久化与设置导入/导出不统一 | 低 | 已接受;语言跟随本地偏好,与 MmdRuntimeType 先例一致。Go 升级路径已废弃。 |
| 翻译内容质量(en/ja/ko) | 中 | 独立工作量,可由用户或 AI 提供;架构不阻塞 |
| ja/ko 翻译审校流程缺失 | 中 | 见下方「翻译审校流程」补充说明 |
翻译审校流程(2026-07-16 补充)
五语言 bundle 已全部补齐(1236 key 完全对齐,CI strict 守门),但翻译质量仍需流程保障,避免机翻腔。建议流程:
- 术语决策先行:新增 key 前先在
terminology.md §2.2登记推荐译法 + 社区惯用变体,再写入 bundle。本表是翻译决策的单一事实源。 - 社区惯用优先:ja/ko 译法允许按 MMD 社区习惯微调(如 ja「加载模型」可用
モデル読込名词化),不强制机械对照。详见terminology.md §2.2备注列。 - zh-TW 用语硬性差异:保存→儲存、软件→軟體、音频→音訊、信息→資訊、视频→影片、数据→資料。机翻常错,须人工核对。
- 关键术语审校:MMD 专业术语(モーフ/表情/ボーン/剛体/物理演算 等)建议由熟悉 MMD 社区的母语用户审校,非纯 AI 产出。
- 社区贡献入口:未来可在 GitHub 开 i18n 审校 issue 模板,收集母语用户反馈,定期回灌 bundle。
边界
- 本 ADR 不引入 i18next 等第三方 i18n 库。
- 本 ADR 不修改 Go 后端;语言持久化永久使用
localStorage,不再升级为 GoUIState。 - 本 ADR 不涉及 模型内文件名 / 资源路径编码(属 ADR-057/058 范畴)。
- 本 ADR 不处理 运行时动态加载的第三方内容文本(如 PMX 内部字符串)。
- 多 AI 协作:实施阶段若触碰
settings.ts/types.ts/config默认值 /core/orbit.ts,须先在当日memory/YYYY-MM-DD.md认领(见项目铁律)。
七、验证方式
- 热切换:打开任意菜单 → 设置 → 语言 → 切到
en→ 已开菜单标签即时变为英文;刷新应用后仍为en(localStorage 持久化)。 - 回退:切到尚未翻译完整的语言,缺失 key 显示
zh-CN文本而非空白。 - 动态字符串:加载多个模型,状态栏
已加载 N 个模型在切换语言后正确本地化且N正确插值。 - 排序:语言切到
en后,模型库列表按英语 locale 排序。 - 回归:
npm run check && npm run test && npm run build全绿。