Skip to content

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/*.tscore/ui-*.tsscene/*physics/* 全量内联grep [\p{Han}]
UI 渲染模型命令式 DOM,SlideMenu updateControls()core/reactivity.tsscheduleRefresh() 驱动重渲染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 导航 targetsettings-targets.ts

1.2 痛点

  • 无切换能力:用户无法切换界面语言。
  • 抽取成本高:字符串散落内联,无中央目录,逐条迁移机械但量大(估算数千条)。
  • 动态字符串setStatus(\已加载 ${n} 个模型`)` 等模板插值需占位符方案,不能简单 key 查表。
  • 排序 collationlibrary-core.ts:432localeCompare(b.label, 'zh') 硬编码中文排序,需随语言切换。

1.3 与竞品的关系

维度DanceXR本联邦(目标)
语言数5(简/繁中、英、日、韩)5(对齐)
切换入口系统菜单 → 语言设置 → 语言(对齐 settings-targets.ts
持久化设置内localStorage(MVP,零 Go 改动)

二、方案设计

2.1 核心思路:自研轻量 core/i18n/

不引入 i18next 等重库。现有 UI 是纯命令式 DOM + 自研 reactivitysubscribe/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.ts

2.2 语言清单(与竞品对齐)

codeBCP-47说明MVP 优先级
zh-CNzh-CN简体中文(基准,当前默认)P0
enen英语P0(试点)
jaja日语P1
koko韩语P1
zh-TWzh-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 验证:

  1. Phase 1(试点)core/i18n/* + menus/settings.ts + menus/settings-targets.ts + menus/library.ts
  2. Phase 2menus/model-detail.tsmenus/scene-*.tsmenus/motion-*.ts
  3. Phase 3core/ui-*.tscore/dialog.tscore/state.ts 状态消息
  4. Phase 4scene/*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.mjszh-CN 为基准,比对 en/ja/ko/zh-TW 的 key 集合,列出缺失 / 多余 key。已挂 CI(test-frontend job,--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 + localStoragescheduleRefresh 体系零摩擦、零依赖、热切换天然需自维护 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.tsgetLang/setLang + localStorage 持久化,镜像 setMmdRuntimeType
  • [x] 新建 core/i18n/t.tst(key, params?) + 回退链)
  • [x] 新建 core/i18n/locales/zh-CN.ts + en.ts(基准 bundle + 英语试点;settings/lang 命名空间)
  • [x] menus/settings-targets.tsSETTINGS.LANGUAGE
  • [x] menus/settings.ts 增「语言」行(根级 folder)+ 子菜单 radio(buildSettingsLanguageLevellang: target → setLang
  • [x] core/init.ts 期(ADR-102 后由 main.ts 迁出)initI18n() 读取语言并同步 <html lang>
  • [x] 热切换试点:设置根级 9 项 + 语言子菜单均经 t() 化,点击语言即 setLangscheduleRefresh 热刷新(注:原计划的 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.tsmenus/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.tsen.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_LANGSAVAILABLE_LANGS.includes(code) 过滤。ja/ko/zh-TW 在补全 bundle(Phase 4)前不再作为可选项,消除「切换无效」误导。locale.tsSUPPORTED_LANGS 注释已澄清其为「规划清单」语义。
  • [x] Library 菜单 i18nmenus/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] 排序 collationlibrary-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-*.tsmenus/motion-*.ts)内的 setStatus/toast 已在 Phase 2 一并 t('scene.*'/'motion.*') 化;本阶段仅剩中央/非菜单模块。

  • [x] core/ui-*.tscore/dialog.tscore/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:434 collation 随语言切换(localeCompare(b.label, getLang())

Phase 4: 多语言补全 + 防回归(按需)

  • [x] locales/ja.tsko.tszh-TW.ts bundle 已建(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 守门),但翻译质量仍需流程保障,避免机翻腔。建议流程:

  1. 术语决策先行:新增 key 前先在 terminology.md §2.2 登记推荐译法 + 社区惯用变体,再写入 bundle。本表是翻译决策的单一事实源。
  2. 社区惯用优先:ja/ko 译法允许按 MMD 社区习惯微调(如 ja「加载模型」可用 モデル読込 名词化),不强制机械对照。详见 terminology.md §2.2 备注列。
  3. zh-TW 用语硬性差异:保存→儲存、软件→軟體、音频→音訊、信息→資訊、视频→影片、数据→資料。机翻常错,须人工核对。
  4. 关键术语审校:MMD 专业术语(モーフ/表情/ボーン/剛体/物理演算 等)建议由熟悉 MMD 社区的母语用户审校,非纯 AI 产出。
  5. 社区贡献入口:未来可在 GitHub 开 i18n 审校 issue 模板,收集母语用户反馈,定期回灌 bundle。

边界

  • 本 ADR 不引入 i18next 等第三方 i18n 库。
  • 本 ADR 不修改 Go 后端;语言持久化永久使用 localStorage,不再升级为 Go UIState
  • 本 ADR 不涉及 模型内文件名 / 资源路径编码(属 ADR-057/058 范畴)。
  • 本 ADR 不处理 运行时动态加载的第三方内容文本(如 PMX 内部字符串)。
  • 多 AI 协作:实施阶段若触碰 settings.ts / types.ts / config 默认值 / core/orbit.ts,须先在当日 memory/YYYY-MM-DD.md 认领(见项目铁律)。

七、验证方式

  1. 热切换:打开任意菜单 → 设置 → 语言 → 切到 en → 已开菜单标签即时变为英文;刷新应用后仍为 en(localStorage 持久化)。
  2. 回退:切到尚未翻译完整的语言,缺失 key 显示 zh-CN 文本而非空白。
  3. 动态字符串:加载多个模型,状态栏 已加载 N 个模型 在切换语言后正确本地化且 N 正确插值。
  4. 排序:语言切到 en 后,模型库列表按英语 locale 排序。
  5. 回归npm run check && npm run test && npm run build 全绿。

八、相关 ADR

  • ADR-043 — DanceXR 差距分析
  • ADR-044 — 竞品分析
  • ADR-041 — CI 自动检查(防回归规则可挂此)
  • ADR-054 — 路线图(i18n 可作为后续能力项登记)