# 102 · 语言切换从「偏好设置」搬进「用户设置」,并撤除偏好设置分区 - 日期:2026-09-15 - 触发:用户「把偏好设置中的 语言切换功能放到用户设置中,去掉偏好设置这个栏目,并检查语言切换功能是否正常」 - 对象:`poc/business-plugins`(**0.3.22 → 0.3.23**)+ `poc/portal-entry`(**0.5.2 → 0.5.3**) - 状态:✅ 已实施并投放(两实例磁盘层已核) > **TL;DR**|**结论**:语言切换搬进 **`@dsh-local/portal-entry` 的「用户设置」分区**(id `user-settings`,order 103), > `business-plugins` 的「**偏好设置**」分区(id `preferences`,order 99)**整块撤除**。 > **关键**:语言是**用户级偏好** —— 与「账号 / 退出登录」同区即可,单开一个分区就是**重复入口**。 > **不做**:不动官方包(R2);语言切换实现仍是官方扩展点(`ctx.locale`),零官方改动。 --- ## 一、先澄清一件事(这轮排查的最大结论) **「用户设置」是我们自己的 bundle 提供的,不是官方分区**: | 项 | 值 | |---|---| | section id | `user-settings` | | order | **103**(排在 能力管理 101 / 系统管理 102 之后) | | label | `"\u7528\u6237\u8bbe\u7f6e"` ← **转义 unicode 存的** | | 提供者 | `@dsh-local/portal-entry`(client 面 `lib/client.js`) | | 源码 | **仓内 `poc/portal-entry/`**(← 本轮补入,之前**不在仓里**) | ⚠️ **因为它是转义形态**,我按 UTF-8 字面量 `grep 用户设置` 在**全域**(官方树 / 平台代码 / 文档库 / profile) 搜了十几轮全部落空,最后是"在活着的 profile 里 `grep -l settings.section @dsh-local/*/lib/client.js`"才定位到。 ⇒ 已把这条写进 **`07-实例UI分区登记表.md §二`**(含"同时搜 UTF-8 与 `\uXXXX` 两种形态")。 --- ## 二、改动 ### 2.1 `poc/portal-entry` 0.5.2 → **0.5.3**:语言行搬入「用户设置」 - **位置**:`UserManagementSection` 里,插在「账号/角色」(仅 admin 显示)与「退出登录」**之间**。 - **实现**(官方扩展点,零官方改动): - `inject`:`["slots"]` → **`["slots", "locale"]`**; - 读 `ctx.locale.getLocale()`,切 `ctx.locale.setLocale(id)`,语言项 **`en` / `zh`**(= 官方 `LOCALE_IDS`;`en` 是官方 FALLBACK,即默认英语); - 本行自己的双语文案走 `ctx.locale.register(PE_NS, {zh,en})` + `ctx.locale.bind(PE_NS)`(与本仓 `business-plugins` 同一套姿势); - 切换后 `setTick()` 强制重渲染本行(官方 locale 的通知不会打到本组件); - 语言 id 读不到时退回 `en`,`ctx.locale` 缺失时只告警、不整区崩。 - ⚠️ **颜色沿用本文件硬编码**(`#f9fafb` / `#43464d` / `#c9cdd4`)—— 本区渲染在 **DARK 主题**, v0.4.2/v0.4.3 的结论是**这里不能依赖 `--dsw-*`**(其 fallback 会让文字与底色同色而看不见)。 - **源码入仓**:仓内此前**没有** `poc/portal-entry/`(只有服务器上的 tgz)⇒ 本轮把**部署中的 0.5.2 源码** 取出来放进 `poc/portal-entry/`,再在其上改。**约定:自研 bundle 的产物只能从仓内源码构建。** ### 2.2 `poc/business-plugins` 0.3.22 → **0.3.23**:撤除「偏好设置」 撤掉三样东西(**只改用户可见面,不留残迹**): 1. `PreferencesSection` 组件(整段,含其 doc 注释); 2. `ctx.slots.inject("settings.section")` 里 **id `preferences` order 99** 的注册; 3. zh/en 各 3 条 `pref.*` 词条(`pref.label` / `pref.lang` / `pref.hint`)。 ⇒ 本 bundle 现在只注册 **2 个分区**:`model-settings`(100) + `business-plugins`(101)(admin 另加 `platform-admin` 102)。 **语言切换一个字都没丢** —— 原实现就是 `ctx.locale.getLocale()/setLocale()`,搬过去后是同一套 API。 ### 2.3 断言同步(防回退) | 脚本 | 改动 | |---|---| | `scripts/verify-platform-admin-section.mjs` | 「非 admin 注册 3 个分区」→ **2 个**;「admin 注册四个」→ **三个**;新增 **「preferences 分区已撤除(不再注册)」** 断言;原「偏好设置」功能断言整块移除 | | `scripts/verify-portal-entry.mjs` | **新增**(22 条):源码在仓内 / **host 半边 `lib/index.js` 在**(v0.5.1 事故防线)/ id·order·label(**按转义形态**断言)/ `inject` 含 `locale` / get·setLocale 调用 / 双语词典 / en·zh 两项 / 语言行**确实 push 进 rows** / 颜色硬编码 / **R3:无 `exports.default` 赋值** | | 仓根 `package.json` | `npm run verify` 链尾接上 `verify-portal-entry.mjs` | --- ## 三、验收 | # | 口径 | 结果 | |---|---|---| | A | `npm run verify`(build + 单测 + 10 个 verify 脚本) | ✅ **全绿**(含两个新/改脚本) | | B | 产物 | ✅ `business-plugins-0.3.23.tgz`(72,037 B)· `portal-entry-0.5.3.tgz`(8,201 B,4 文件=两个半边都在)均已投放,与本地同名 | | C | 投放 | ✅ `ensure-biz-plugins.cjs --all --restart` + `ensure-portal-entry.cjs --all --restart` ⇒ admin/guest **bundles 7 / 6 完好**(未被 `pruneBrokenFileDeps` 摘依赖) | | D | 磁盘层(06 §7.3 ①) | ✅ 两实例:`business-plugins` **0.3.23** 且 `id: "preferences"` **命中 0**、能力管理/tab 仍在;`portal-entry` **0.5.3** 且 `key: "lang"` 命中 1、`inject` 含 `locale` | | E | 实例侧 / 浏览器实测 | ⚠️ **未做**,原因见 §四 | --- ## 四、未完成:实例侧与浏览器实测(原因 + 回头条件) **阻塞点(客观)**:平台已切集群 ⇒ **新用户按容量落 Worker `w-106`**,而 **47 → 106 无 SSH 路由** (106 经反向隧道连到 47)⇒ 本机脚本走不通"建临时用户 → 进实例 → 抓壳页 → 点 UI"这条链; R4 又不允许用真实账号(guest/admin)登录做实测。 **已做到的**:磁盘层(§三 D)确认两个实例 profile 里就是新代码,而**实例壳页的 combo 是对 `profile/node_modules/**/client.js` 的拼接** ⇒ "服务端会返回新内容"由 D 强推,但**未经端到端实测**。 **回头解决的条件**:拿到「新用户落哪台 Worker + 到那台机的通道」后,用档案 100 §8.4 那套 (`poc-ui-user.cjs` + `agent-browser`)补齐三段式与视觉验收(重点看:语言行是否在「用户设置」里、 切到 English 后**本行文案与官方 UI 是否立即变**、以及 `settings.yaml` 的持久化行为见下)。 ### ⚠️ 一条要如实告知用户的行为边界 官方 `dsh-client-locale` README 原文:语言选择**立即生效**;**loopback 页面**会持久化到 `$DSH_HOME/settings.yaml`,**非 loopback 页面只为当前进程保留**。平台是"浏览器经域名访问远程服务器" ⇒ **属非 loopback** ⇒ **切换在新开页面/重启后不保证保持**。这不是本次改动引入的(原「偏好设置」用的是同一套 API), 但**用户会说"我切了怎么又变回去"** ⇒ 已在 §五 记为待观察项。 --- ## 五、待观察 / 技术债 - **持久化**:见 §四末条。若要"记住选择",得走平台侧写 `settings.yaml`(属新需求,不是本单)。 - ⚠️ **`scripts/ensure-portal-entry.cjs` / `ensure-biz-plugins.cjs` 仍用 `better-sqlite3` 读 `/var/lib/dshs/dshs.db` 枚举用户** —— 而集群模式下**权威库是 PG**(`DSHS_DB_URL`)。 本轮两脚本对新用户/存量用户都工作正常(说明过渡期 SQLite 仍在被维护),但**这是一条隐患**, 属集群化的收尾项(**不是本单 lane**)⇒ 只报告,未动手。 - **`.pnpm` 里的历史副本**(`@dsh-local+business-plugins@0.2.3/0.3.4/0.3.8/0.3.11` 等)与文档库 `04-调整方案/poc/portal-entry`(0.5.1 快照)都属"同名旧副本",排查时别当现行(已写进 `07 §二`)。 --- ## 六、语言偏好**持久化**(2026-09-15 追加,用户:「A B 都按最优方案处理」) **问题(A)与需求(B)其实不冲突** —— 最优解是**替官方把它的设置写进它自己的文件**: ### 6.1 做法(零官方改动、零新增平台状态) | 层 | 改动 | |---|---| | 平台(新)`src/web/locale-pref.ts` | `reconcileLocalePreference(text, 'en'\|'zh')` —— **按行对账**,只在 `settings.yaml` 顶层 `locale: → preference:` 那两行上动手(不整份 YAML 解析,注释/顺序/别人的块一律不动);幂等(值相同 ⇒ `changed:false`) | | 平台(新)`src/web/home-files.ts` | 把原先内联在 `server.ts` 闭包里的 `readTextOrEmpty` / `writeHomeFile` **抽出来共用**(`writeHomeFile` = 备份到平台目录 + 写 + **chown 给 home 属主**,漏最后一步实例读不了 —— R10 同族)。`server.ts` 改为 import(行为不变) | | 平台(新路由)`POST /api/me/locale` | `requireAuth`;`{locale}` → 写 `/settings.yaml`;非法值 400 `invalid_locale` | | 客户端 `portal-entry` **0.5.3 → 0.5.5** | `pickLocale()` 在 `setLocale()` 之后**额外调** `POST /api/me/locale`(`credentials:'include'`,跨子域);**持久化失败不影响本次切换**(就地已生效) | **官方键名出处**:`dsh-client-locale` 的 **host 半边 settings schema** 用的就是 `locale` / `preference` (⛔ 不要凭猜 —— 首版我按 `locale.preference` 写是**核对过**的)。 ### 6.2 为什么这是"最优"而不是"另造一套" - **A(守官方语义)**:写的正是官方设置文件的官方键 ⇒ 实例启动时官方运行时读自己的文件即生效; - **B(记住选择)**:用户的选择跨页面 / 跨重启保留; - **单一来源**:平台**不新增**"语言状态"(没有新表 / 新列),唯一事实仍是 `settings.yaml`; - **失败可降级**:平台路由挂了 / CORS 拦了 ⇒ 只是"记不住",**不影响本次切换**(catch 兜底)。 ### 6.3 验收 | # | 口径 | 结果 | |---|---|---| | A | 单测(新增 `test/locale-pref.test.mjs`,9 例) | ✅ 全过:建块 / 插行 / 换值 / **幂等** / **注释与其它块不动** / **CRLF 保持** / 行数不增 | | B | `npm run verify` | ✅ 全绿(`test` 45 例;含新增打包防线与持久化断言) | | C | 平台路由上线 | ✅ 送 4 个**编译产物**(`lib/web/{home-files,locale-pref,server}.js`、`lib/web/routes/auth.js`)+ `restart dshs`;冒烟 `POST /api/me/locale` → **401**(有鉴权 = 路由存在) | | D | 投放 | ✅ `portal-entry-0.5.5.tgz` 两实例 `bundles` 7/6 完好;磁盘层 **0.5.5**、`api/me/locale` 命中 1、包内无残留 tgz | | E | 浏览器实测 | ⚠️ 仍未做(同 §四:新用户落 `w-106`、47→106 无路由) | ### 6.4 ⚠️ 部署方式的一个**实测坑**(重要,别踩) 打算按常规 `cd /opt/dshs && npm run build && systemctl restart dshs` 部署时发现: **服务器 `/opt/dshs/src` 是"集群化之前"的旧源码**(`server.ts` 里还是 `K8sSpawner`,git 停在 `8390eb3 初始提交`), 而**线上 `lib/` 却是集群版编译产物** —— 源码与产物**不一致**。 ⇒ **在那边直接 build 会把线上编译回旧版**(危险)。本次改为**只送编译产物**(4 个文件), 并已核对"线上 `lib/` 与我本地编译版**只差本次改动**"再动手。 ⚠️ 这条属**集群化收尾项**(另一 lane):`/opt/dshs` 的 src 需要与 git 基线对齐,否则**任何一次服务器侧 build 都是事故**。