Files
dsh_shenxian/dsh-server-docs/04-调整方案/102-语言切换搬入用户设置并撤除偏好设置.md
T
admin 3efd68517f chore: 并入已删除会话的在途成果(防丢失;原会话已删,未做功能验收)
**背景**:这些改动原属本工作区另外几个会话(T01/T02 等),**那些会话已被用户删除** ⇒
工作树里的成果处于"无主"状态,一次错误 checkout / 覆盖即**永久丢失** ⇒ 代入库保全。
口径遵循本项目**先例**(`39a1f2e` / `b617cdb`:**别人的活,代入库并在提交信息里注明**)。

**内容**:档案 101「能力管理」改名 + 页内 tab 分页|档案 102 语言切换搬入「用户设置」|
`07-实例UI分区登记表.md`|`scripts/find-ui*.mjs`(UI 元素定位工具)|`poc/portal-entry/`(0.5.3)|
`src/web/locale-pref.ts` + `home-files.ts`(语言偏好持久化)|`test/locale-pref.test.mjs`|
`package.json`|`BRIEF.md` / `INDEX.md` / `docs-manifest.json` / `03-路线图与待办.md` / 档案 100 增量。

**已做最小健全性检查**(⚠️ **未跑完整构建 / 单测** —— 那是原会话的验收职责,本次只求"不丢"):
- JSON 合法:`package.json` / `poc/business-plugins/package.json` / `docs-manifest.json` ✓
- 4 个 TS 文件 `{}`/`()` 配平 ✓;新增文件均非空 ✓
- 规模:13 文件改动 +340/−210,新增 12 条

**未 push**(按 §4 提交边界:用户说"提交",未说"推送")。
2026-09-15 21:17:12 +08:00

156 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 里的历史副本**(`@[email protected]/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}` → 写 `<home>/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 都是事故**。