这篇讲 最贴近用户界面的三个断点:深色模式从半套双写收成单一真源、无障碍从零体系变成 CI 门禁、以及从零自研的一套零依赖国际化方案。前两件快速过,i18n 是重头,最后顺带说首启向导和一个端到端测试的坑。
深色模式:真源只能有一个
旧实现有个典型的双写缺陷:侧边栏的主题开关只写 localStorage,设置页的主题选项只写数据库。用户在一处切换,另一处的状态不跟着变,刷新后谁生效取决于读取顺序;而且没有「跟随系统」,深色用户在浅色系统下首屏必白闪。
新方案把关系彻底倒过来:
- 设置表是唯一真源。水合完成后,主题一律以设置接口为准并回写;
- localStorage 只是水合前的首帧缓存。页面 head 的阻塞阶段有一段 init script,在 React 渲染前就读取缓存给 html 挂上深色标记,深色用户不会先看到一帧白底;
- light/dark/system 三态。system 态用 matchMedia 解析成实际亮暗,并监听系统变化实时跟随,不必重启应用。
防闪的关键是时序:脚本必须在 head 的阻塞阶段跑完,早于任何页面像素上屏:
text
<head> 阻塞阶段:读缓存(system 态先用 matchMedia 解析)→ 给 <html> 挂暗色标记
React 水合后:主题改读设置表,缓存只负责水合前的那一帧
视觉侧配套补齐:CSS 变量亮暗两套令牌全配对(含危险色双态)、代码高亮给暗色配色、全域走查清理硬编码状态色------仅这清理一项就动了 67 个文件。硬编码的颜色值在深色页不会自动变,只有语义令牌会。
无障碍:从键盘第一站到 CI 门禁
无障碍没有银弹,这版做的是结构性的几件:
| 项 | 做法 |
|---|---|
| 键盘入口 | skip link(视觉隐藏、聚焦显现)+ main landmark,Tab 第一站直达正文 |
| 命令面板 | 补全 combobox/listbox/aria-activedescendant 语义,读屏能播报选项与当前高亮项 |
| 焦点可见 | 全局 :focus-visible 焦点环,只在键盘导航时出现 |
| 确认对话框 | 应用内 ConfirmProvider 替代 10 处 window.confirm |
| 对比度 | 次要文字按 4.5:1 调整 |
换掉原生 confirm 的收益比预期大:自定义对话框同时拿到了焦点管理(打开时聚焦、关闭后还焦)和默认按钮控制------删除、清空这类危险动作,默认焦点一律落在「取消」上,按回车不会误删。
命令面板的语义改造值得多说一句,因为它是键盘流的高频入口:输入框是 combobox,候选列表是 listbox,方向键移动高亮时 aria-activedescendant 实时指向当前项,读屏播报的是高亮选项而不是输入框;Esc 关闭面板后焦点还给唤起它的元素。这些属性不写,视觉上没有任何差别,但读屏用户拿到的是一个「无法操作的输入框」。
门禁用 axe-core 接 Playwright,覆盖六个核心场景(对话/设置/工作流/知识库四页空态、命令面板语义、skip link)。serious/critical 必须为零才过,次要问题只出报告,人工逐项关,避免一次性红灯淹没导致门禁形同虚设。
i18n:为什么自研而不是引库
界面文案曾经全部硬编码中文。改造前评估过主流 i18n 库,结论是不适配:它们大多围绕「路由分段 + 资源包」设计(不同语言对应不同 URL),而这是一个 Electron 内嵌 SPA,语言是用户设置项,切换时没有路由可切;同一份翻译能力还需要同时跑在 React 组件、非组件回调和服务端渲染的分享导出里。
需要的内核其实很小,于是落在 shared 包里的是一个纯函数 t():
- 点路径寻址 :
t('settings.appearance.theme')按嵌套字典取值; - 变量插值 :
{name}形式,解析时做花括号配平扫描,坏模板不会静默吞字; - ICU-lite 复数:支持 one/other 两形,覆盖「一条/多条」这类绝大多数实际需求,不实现完整 ICU 语法;
- 缺键三级回退:当前语言字典 → zh-CN → 键名字符串本身;另带缺失回调,测试环境开 strict 模式,缺键直接抛错。
调用形态大致是这样(说明性伪代码):
typescript
t('common.itemsCount', { count: 3 }, 3);
// 数值命中 one 形 → 「1 条记录」的译文模板
// 命中 other 形 → 「{count} 条记录」,插值后渲染
复数判定只认 one/other 两形,花括号用配平扫描解析------模板里多一个或少一个花括号会被当成坏模板处理,绝不静默吞字。刻意不实现完整 ICU:单人项目里 95% 的复数需求就是「一个/多个」,完整语法的解析成本与误用面都不划算。
纯函数意味着 React 之外的任何地方都能调:错误解析、服务端 HTML 渲染,用的是同一个 t,没有任何框架绑定。
字典体系:让漏译无法悄悄发生
真正的工程量在字典的组织结构,而不是翻译函数本身。
字典按业务域拆成 21 个命名空间文件(通用、导航、设置、对话、助手、知识、记忆、向导、帮助......)。关键约束有三层:
- zh-CN 是唯一结构真源 。中文字典用
satisfies锁定结构,形状错了编译就红;英文字典是 DeepPartial------允许部分缺失,但不允许结构错; - MessageKey 联合类型编译期锁键。所有合法键名从中文字典机械派生,拼写错一个键在编译期就是错误,不用等运行到那个界面;
- parity 测试锁叶子键 100% 对齐 。测试遍历两本字典的全部叶子键,中文有的英文必须有,反之亦然------新增一个中文文案却忘了补英文,测试当场红,漏译无法悄悄发生。
text
类型层:zh-CN satisfies 锁结构 → MessageKey 联合 → t() 调用编译期校验
测试层:parity 叶子键对齐 + strict 模式缺键即抛
运行层:当前字典 → zh-CN → 键名本身,三级回退保底
错误信息也收口本地化:服务端只返回稳定错误码,客户端解析器把错误码映射到 errors 字典,网络层异常归一到网络错误键,需要细节时以插值参数附带------服务端不再拼接用户可见的中文句子。
text
业务异常(带 code) → errors.<code> 字典项,详情走插值
请求没发出去(TypeError)→ 统一网络错误键
需要上下文(型号、供应商名)→ 插值参数,不进错误码
日期、数字、百分比统一走 Intl 收口,业务代码里不再散落手写的本地化调用。服务端渲染的分享导出按链接上的 ?locale= 参数走一份不依赖 React 的纯翻译器;非核心面板(高级编辑器、调试台等)不强行翻译,英文态下缺键显式回退中文,而不是把裸键名显示给用户。
整个 i18n 改造涉及 113 个文件、净增约 4700 行(大半是双语字典本身),是这个版本最大的一块增量,但换来的性质是:加一种语言只是加一份 DeepPartial 字典,漏一个键就过不了测试。
首启向导:编排既有能力,不造平行实现
发布前最后一块是新用户引导:4 步向导(欢迎 → 配供应商 → 语音可选 → 完成),由首启标记门控,完成或跳过都写 true,之后不再弹。
一个刻意的设计:向导里的操作全部复用正式业务链路。Ollama 的「检测」做的就是正式流程------查找或创建 Ollama 供应商、测连接、拉取远端模型列表供一键添加,离线则给出安装指引与拉取命令;语音资源走正式下载 API,带进度后台下载,不阻断向导。引导页因此只是既有能力的编排壳,不会和设置页长出两套行为。老用户由数据迁移按「有无用户痕迹」(设置表有行或有过会话)回填标记,升级后不弹窗;全新空库默认不写,首启才弹。
配套的帮助入口也是同一思路:设置页内嵌双语帮助中心(约 10 条 FAQ,每条有锚点,错误边界可以直接链到对应条目),Ctrl+/ 唤起快捷键浮层;快捷键清单在 shared 包维护单一真源,渲染层和桌面壳的全局快捷键共用同一份定义,不会两处漂移。
这里有个端到端测试的坑值得记一笔。e2e 串行套件共享一个临时数据根,向导一旦自动弹出,模态遮罩会让后续所有 spec 找不到表单元素而集体超时。解法是给向导 spec 加 00- 前缀保证最先执行,入口先确定性地重置首启标记复现弹窗,走完落 true------由它把首启态「消费」掉,后续套件运行在一个已完成引导的干净世界里。
完检的意义
深色令牌、焦点环、翻译键,这些东西用户不会专门夸,但任何一件缺失都会立刻被感知:白底一闪、Tab 后焦点消失、界面半句中文半句键名。发布前的完检不是打磨心情,而是把「作者自己用得爽」和「任何环境下的任何人都能用」之间的差距,用真源、类型和测试逐条钉死。