Files
dsh_ai1net_server/dsh-server-docs/04-调整方案/81-目标架构与命名规范-重构总纲.md
T
admin 5ad755116e chore(docs): 文档库并入代码仓(R4 选 a)+ 索引/台账跟进
1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
   保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
   工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
   必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
   + ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
   ⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
   验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
2026-09-15 18:47:13 +08:00

500 lines
44 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.
# 81 · 目标架构与命名规范(重构总纲)
- 日期:2026-09-13
- 状态:🚧 **执行中**(R0 已完成;R1–R4 分期见 §四)
- 触发:用户「按照方案改进,要求改造后是一个新的更好的架构包括命名方式等细节」(承接档案 80 的 9 项优化)
- 原则:**保留全部现有功能**;只做**收口、显式化、命名统一**;不做推倒重来
> **TL;DR**|① 定下**一套命名**:对外叫「**工作台**」、内部统一前缀 **`dshs`**(替换 `dshs`);
> ② 定下**六层架构**:静态层 / 注入层 / 控制面 / 数据面 / 管理面 / 可观测;
> ③ 分 **R0–R4** 五期落地,每期自带验收与回滚;④ 内部重命名走「**双名期 → 切换期**」,**绝不硬切**(hooks / 自动化 / 脚本里写死了绝对路径)。
---
## 一、命名规范(新)
### 1.1 对外(用户可见)—— 统一为「工作台」
| 位置 | 现在 | 目标 | 状态 |
|---|---|---|---|
| 工作台首页 `/` | `工作台` | `工作台` | ✅ 已改 |
| 登录 / 注册 | `登录` / `注册` | 同 | ✅ 已改 |
| 管理门户 | `管理门户`(副标题曾含平台名) | `管理门户`,副标题「**平台管理**」 | ✅ 已改 |
| 管理台 | `管理台` | 同 | ✅ 已改 |
| 启动过渡页 | `正在启动工作区` | 同 | ✅ 已改 |
| 英文短名(对外文档/品牌) | — | **DSH Workspace** | 待用 |
> ⛔ 由 `scripts/verify-static.mjs` **守住**:任何 `web/**.{html,css,js}` 出现 `dshs` 即校验失败(已接入 `npm run verify`)。
### 1.2 内部(工程标识)—— 统一前缀 `dshs`
| 资产 | 现在 | 目标 | 迁移方式 |
|---|---|---|---|
| systemd 单元 | `dshs.service` | **`dshs.service`** | 双名期:单元做 **symlink + `Alias=`** |
| 源码目录 | `/opt/dshs` | **`/opt/dshs`** | 双名期:`/opt/dshs` → symlink |
| 数据目录 | `/var/lib/dshs` | **`/var/lib/dshs`** | 双名期:symlink(**先只加链接,不搬数据**) |
| DB 文件 | `dshs.db` | **`dshs.db`** | 切换期:停服 → `mv` → 起服(原子+回滚) |
| env 前缀 | `DSHS_*` | **`DSHS_*`** | **同时读两个名**(新名优先),旧名保留一版 |
| npm 包名 | `dshs`、`@dsh-local/*` | **`dshs`**、**`@dshs/*`** | 切换期批量改(>10 文件,先出清单) |
| 注入脚本 | `SESSION_*_JS` 内联在 `proxy.ts` | **`assets/inject/{recovery,assist}.js`** | R1(见 §四) |
| 注入全局标记 | `__dshRecover` / `__dshAssist` | **`__dshsRecover` / `__dshsAssist`** | R1,**保留旧名别名一版** |
| 注入 CSS 类 | `.__dsh-*`、`.wk-*` | **`.dshs-*`**(浮层与过渡页统一) | R1/R2 |
| 管理面路由 | `portal.html` + `desktop/plugins/skills.html` 三个桩页 | **`/admin` 路由族**(桩页删除) | R2 |
| 业务技能前缀 | `mcn-*` / `douyin-*` | **不动**(业务域命名,与平台无关) | — |
**兼容策略(硬要求)**:内部改名一律两期 —— **双名期**(新名为主 + 旧名仍可用)→ **切换期**(删旧名)。
理由:`~/.workbuddy/settings.json` 的 hooks、`/etc/cron.d/*`、平台脚本、备份脚本、文档、自动化任务里**都写死了绝对路径**;今天已因路径失配被"全机写操作被拒"上了一课。
---
## 二、目标架构(六层)
```
① 静态/呈现层 web/*.html(9 → 6 页,删 3 个跳转桩)+ design.css(统一 .dshs-* 前缀)
② 注入层 assets/inject/{recovery,assist}.js —— 独立文件、构建期硬校验、运行时可读
③ 控制面 supervisor(实例生命周期)· orchestrator(spawn/reap/配额/熔断)· proxy(子域/鉴权/注入)
④ 数据面 SQLite + /var/lib/dshs/{users,state,artifacts,business-plugins}
⑤ 管理面 /admin 路由族(原 portal.html 拆分)+ admin API(**权限仍全在平台域**)
⑥ 可观测 /opt/dshs/state/*.json + GET /api/dsh/status(汇聚:实例/内存/熔断/插件)
```
**相对现状的 4 处关键改动**(都来自今天的事故):
| # | 改动 | 消灭的问题 |
|---|---|---|
| 1 | **注入脚本出模板字面量,落成独立文件** | 模板转义把整段脚本写崩(今天 2 次:`.join('\n')` → SyntaxError → 浮层/自愈/助手全废) |
| 2 | **自愈链路收口成一份状态机**(探活 → 判据 → 恢复 → 冷却 → 告警) | 档案 50→51→72→77→78 **5 代补丁**叠加,改一处要读五处 |
| 3 | **实例配额跟随插件集合**(按已启用插件的内存预估定 `MemoryMax`,V8 堆跟随) | 「启用某插件 = 必然被 OOM 杀」(今天:384MB 上限 < univer gateway 390MB) |
| 4 | **有界恢复**(连续 N 次失败停在明确失败态 + 手动重试) | 「恢复→起来→又被杀」无限循环(今天 guest 每 ~35s 一次) |
---
## 三、目标 vs 现状(逐项对照)
| 维度 | 现状 | 目标 |
|---|---|---|
| 用户可见命名 | 6 个页面挂内部平台名 | **全清**,统一「工作台」;CI 守住 |
| 注入脚本 | 2 段内联 TS 模板字面量(~27KB) | 2 个独立 `.js` 文件 + `verify-inject` 强校验 |
| 自愈 | proxy 注入脚本 + orchestrator 各自为政 | 一份状态机(前端只负责"探针 + 呈现") |
| 配额 | 硬编码 384MB / V8 160MB | **按插件集合自适应** |
| 恢复 | 无限重试 | **有界**(N 次后停) |
| 校验 | 单测 + 临时补的 verify-inject | **`npm run verify`** 一条命令(build+单测+注入+静态页) |
| 管理面 | 整页跳 `portal.html`(+3 桩页) | `/admin` 路由族 + 实例内**弹窗 iframe**(R5 评估后) |
| 可观测 | stderr / 采样文件 / 新加的 breaker | 统一 `state/*.json` + 一个 status 接口 |
| 内部标识 | `dshs` 遍布 | 统一 `dshs`(双名期过渡) |
---
## 四、分期落地(R0–R4)
| 期 | 内容 | 重启 | 验收 | 回滚 |
|---|---|---|---|---|
| **R0** ✅ **本轮已完成** | ① 静态页去痕迹(7 文件,已上线)② `npm run verify` 统一校验 + `verify-static.mjs` 静态不变量(含"去痕迹"防回归) | 否 | 线上 `<title>` 全绿;`npm run verify` rc=0 | `git checkout -- web/… scripts/… package.json`(未 commit) |
| **R1** | ① 注入脚本外置(`assets/inject/*.js` + 运行时可读 + 缺文件 fail-fast)② 自愈收口状态机 ③ **有界恢复** ④ 配额自适应 | **一次重启** | 真 Chrome 三态(切回/断流/无操作自愈)+ 内存采样不再贴顶 + `verify` 全绿 | 备份 `lib/` + 回滚源文件 + 重启 |
| **R2** | 管理面就地化:`/admin` 路由族 + 实例内**弹窗 iframe**;删 3 个跳转桩页 | **一次重启** | admin 在实例内完成全部分区操作;普通用户看不到入口 | 恢复 `portal.html` 跳转 |
| **R3** | 内部标识迁移 `dshs` → `dshs`:**双名期**(symlink + 新旧 env 双读)→ **切换期**(改单元名/路径/DB) | **两次窗口** | `systemctl status dshs` 正常;hooks/自动化/脚本路径全部改完并验证 | 保留旧名 symlink 与 DB 备份,随时切回 |
| **R4** | 文档与代码同仓(或明确单向导出),去掉"对账/推送/幽灵文件"仪式 | 否 | 文档改动随代码 PR 一起走 | — |
| **R5** | **多语言(i18n)**:平台 6 页 + 注入脚本文案(词条表 + `data-i18n` + 极小运行时);**不动**官方 dsh UI 与业务插件 | 否 | 见 §十 §10.4 | — |
**R1 是价值最高的一期**(今天 4 类事故里的 3 类都在它刀下);**R3 风险最高**(写死的绝对路径),故排在最后且必须原子化。
---
## 五、与红线的边界(先讲清)
| 线 | 与本次的关系 |
|---|---|
| **R2** 不改官方 dsh | 全程只动平台侧(supervisor/proxy/web/scripts),`@deepseek-ai/dsh` 零改动 |
| **R3** client bundle 禁 `exports.default` | 业务插件改动需守(含 R1 若动插件侧) |
| **R5** 权限只准收窄 | **R2 的"管理面就地化"属扩大可见面** ⇒ 先出「权限影响评估」+ 用户确认;采用 **iframe 内嵌**(代码/数据仍在平台域、agent 读不到)而非能力下沉 |
| **R7** >10 文件先出清单 | R3 的包名/路径批量改**先出受影响清单**再动 |
| **R8** 中断在线用户 | R1/R2/R3 各需窗口,**全部提前知会**;能合并的合并(R1+R2 建议同一窗口) |
---
## 六、待用户
1. **R1 是否现在做**(一次重启窗口,断在线用户 2–5 秒)?我建议 **R1 + R2 同一窗口**,一次断线办完。
2. **R3 的"双名期"是否赞成**(先加 symlink/双读,稳定后再删旧名)?这是把"命名彻底换掉"的风险压到最低的走法。
3. 是否需要我把 **R1** 落成 8 段式交接单交执行会话(遵循"规划与执行分离")。
---
## 七、进度追加(2026-09-13 11:0x)
### 7.1 R0 追加:静态校验再加一条「内联脚本必须能解析」
`scripts/verify-static.mjs` 现在除了「去痕迹 + title + wake.html 五个 id」之外,还会把每个页面里的**内联 `<script>`** 抽出来跑 `node --check`。
理由:今天两次事故都是**脚本语法错误 = 静默失效**(`.join('\n')` 那次),而当时所有"验证"都验不出这一点。现 10 段内联脚本全部通过。
### 7.2 R2 提前做掉一项:**登录 / 注册页的提交忙碌态**(用户报障)
**报障**:「点击登录后停几秒,按钮不能交互、也没有加载动画」。
**根因**:`web/login.html` 提交处理里**没有任何忙碌态** —— `/api/auth/login` 走 bcrypt 要数百毫秒~数秒,`/api/dsh/enter` 还要拉起实例(可达 20 s),期间按钮可点、无反馈。
**改法**(对齐 `06-工作台UI规范` 的按钮/圆角/主色 Token,只加不删):
- 新增页面局部样式:`.btn[disabled]`(透明度 .72 + not-allowed)、`.btn.is-busy`(inline-flex + gap)、`.btn .sp`(14px 环形转圈,`currentColor` 系)、`prefers-reduced-motion` 关闭动画;
- 新增通用 `setBusy(busy, label)`:进入忙碌态时**记住原 innerHTML**、禁用按钮、插入 spinner + 文案;退出时**原样恢复**;
- **两段式文案**:`登录中…` → 登录成功后切 `正在进入工作台…`(这一句覆盖"停几秒"的那段,最容易被误认为卡死);
- 失败路径(用户名/密码错、进入工作台失败、网络错误)统一 `setBusy(false)`;`register.html` 同构改造,成功后保持禁用避免重复注册。
**验证**:线上取回页面含 `setBusy` / `is-busy` / `.sp` / 两段文案;**真 Chrome 实测** —— 点击后 **220 ms** 按钮已 `disabled=true`、`class=btn primary is-busy`、内容为「spinner + 登录中…」;失败后按钮恢复「登 录」并显示「用户名或密码错误」。截图 `_patch77/shots/login-busy.png`。
**部署**:静态页 + 校验脚本 scp(**免重启**)。回滚:`git checkout -- web/login.html web/register.html`。
---
## 八、R1 完成记录(2026-09-13 11:0x–11:2x)
### 8.1 四项全部落地
| # | 内容 | 落点 |
|---|---|---|
| ① | **注入脚本外置** | 新增 `assets/inject/recovery.js` + `assist.js`(纯 JS,可 `node --check`);`proxy.ts` 改为 `loadInject('recovery.js')` 运行时读取(**fail-fast**:读不到即启动失败,宁可不发页面也不静默失效)。原 27 KB 内联模板字面量**删除** ⇒ 「转义写崩」这一类事故**从根上消失** |
| ② | **自愈收口成单一状态机** | 在 `recovery.js` 头部写明**唯一策略表**(5 类触发面 × 判定 × 动作):切回页面 / 请求 401·not_running / 可见时 25 s 心跳 / EventSource·WebSocket 断开 / 有界保护。改这一个文件即可,不再需要在 proxy.ts 与注入脚本间来回找 |
| ③ | **有界恢复** | 窗口 10 min 内恢复 > **3** 次 ⇒ 停止自动恢复,停在明确失败态 + **「重试」按钮**(手动点击重置预算)。治「恢复→起来→又被杀」的无限循环 |
| ④ | **配额随插件集合推导** | `orchestrator.ts` 新增 `PLUGIN_MEM_MB` 预估表 + `instanceMemMb()` + `heapMbFor()` + `withHeap()`:`MemoryMax = 160 + Σ(插件预估)`(下限 384 / 上限 1024);**V8 堆由配额独占**(`配额-96`,封顶 256)——原先写死的 `MemoryMax=384M` 与 env 里的 `--max-old-space-size=160` 都作废 |
**校验**:`npm run verify` 全绿(构建 + 单测 + 注入脚本 `node --check` + 内联回退检查 + 静态页不变量 + 10 段内联脚本语法)。
### 8.2 部署与验收(一次重启窗口)
| 项 | 实测 |
|---|---|
| 服务 | `active`,PID 424413 → **435201**,门户 200 |
| **guest 实例配额** | `dsh-100002-*.scope` → **MemoryMax = 544 MB**(160 基座 + 384 univer)✅ |
| **guest 实例 V8 堆** | 进程 env `NODE_OPTIONS=--max-old-space-size=256` ✅ |
| **注入脚本** | guest 实例页含 `overRecoverBudget` / `__dshRetryBtn` / 「状态机(单一来源」/ `HEARTBEAT_MS` / 新视觉 `conic-gradient` ✅ |
| 备份 | `/opt/dsh/backups/pre-r1-20260913-110923`(`lib-pre-r1` + proxy/orchestrator 源文件) |
### 8.3 ⚠️ 部署中踩的两个坑(如实记录)
1. **`__dirname` 在 ESM 里不存在** ⇒ 平台**启动即失败**(`ReferenceError: __dirname is not defined in ES module scope`),systemd 自动重启 3 次(约 1 分钟不可用)。本项目 `package.json` 是 `"type": "module"`,**必须用 `dirname(fileURLToPath(import.meta.url))`**。已修 + 重新部署恢复。**教训:本地 `npm run build` 通过 ≠ 能跑起来 —— ESM/CJS 的运行时差异编译期查不出,所以这类改动必须"部署后立刻 curl 自证"。**
2. **`DSH_INSTANCE_NODE_OPTIONS=--max-old-space-size=160` 改不动**:它既不在 systemd unit、也不在 manager env(`systemctl unset-environment` 后进程 env 里仍在)⇒ **靠改配置够不到**。故改为**代码侧独占堆上限**(`withHeap()`:把既有 NODE_OPTIONS 里的 `--max-old-space-size` 摘掉再按配额补回),env 只保留承载其它选项的能力;如需强行指定堆,用 `DSHS_HEAP_MB_OVERRIDE`。
### 8.4 仍未做(R2–R4)
| 期 | 状态 |
|---|---|
| **R2** 管理面就地化(`/admin` 路由族 + 实例内弹窗 iframe;删 3 个跳转桩页) | ⏸ **待 R5 权限影响评估**(扩大可见面)→ 评估后再动;且它落在 `@dsh-local/business-plugins` 插件侧,改完需重建插件 + 实例重启 |
| **R3** 内部标识迁移(`dshs` → `dshs`) | ⏸ 未动(须「双名期 → 切换期」,两次窗口) |
| **R4** 文档/代码同仓 | ⏸ 未动(工程仪式向) |
---
## 九、R3 双名期(第一步已做)+ R2 的 R5 权限影响评估(待确认)
### 9.1 R3 · 双名期第一步:**加 symlink,不动运行中的服务**(2026-09-13 11:4x)
| 目标名 | 形式 | 实测 |
|---|---|---|
| `/opt/dshs` | → `/opt/dshs` | ✓ `/opt/dshs/lib/supervisor/proxy.js` 可达 |
| `/var/lib/dshs` | → `/var/lib/dshs` | ✓ `/var/lib/dshs/users` 可达 |
| `dshs.service` | → `dshs.service` + `daemon-reload` | ✓ `systemctl is-active dshs` = active,**两名字同 PID(435201)** |
- **零中断**:只加符号链接 + `daemon-reload`,**没有重启任何服务**。
- **可回滚(一条口令)**:`rm -f /opt/dshs /var/lib/dshs /etc/systemd/system/dshs.service && systemctl daemon-reload`
- 意义:**「双名期」成立** —— 新旧两个名字同时可用,后续把引用的绝对路径从旧名改成新名时**不会出现"改到一半全断"**。
**R3 剩余步骤(未做)**:① 代码侧 env 双读(`DSHS_*` 优先、`DSHS_*` 兜底);② 生效脚本 / hooks / 自动化 / 云备份任务里的绝对路径逐条改新名;③ **切换期**:改 systemd 单元真名、停服迁 DB(`dshs.db` → `dshs.db`)、删旧符号链接。**两次窗口,且必须原子化。**
### 9.2 R2 · R5 权限影响评估(**按 R5 要求先出评估,再等用户确认**)
R2 = 「实例内『设置 → 平台管理』弹窗内嵌 `portal.html`,不再整页跳门户」。
| 维度 | 结论 |
|---|---|
| **扩大点(R5 关注)** | 实例页里出现**可操作平台管理的入口**(原设计是从实例整页跳到平台域)。**仅对 admin 可见**;普通用户看不到 |
| 技术形态 | `<iframe src="https://alotbuy.com/portal.html?embed=1">`:**代码与数据全部仍在平台域**;实例域(`<user>.alotbuy.com`)与平台域**跨子域 → 同源策略隔离** ⇒ **实例内的 agent 读不到 iframe 内容**(这与档案 05 PoC-2 ① 当年否掉的「把 admin 能力装进实例」有本质区别) |
| 实测可行性 | `curl -I https://alotbuy.com/portal.html` → **无 `X-Frame-Options`、无 CSP `frame-ancestors`** ⇒ 同站子域 iframe 可直接嵌,**无需改安全头** |
| 风险 ① | 管理动作在实例内完成 ⇒ **误操作面**。**缓解:先"只读优先"**(iframe 内仅展示,写操作仍回跳平台域完成) |
| 风险 ② | agent 被诱导去点管理面?跨域读不到内容 ⇒ **实际不可行**;再加"仅 admin 可见"兜底 |
| 风险 ③ | 删掉 `desktop/plugins/skills.html` 三个跳转桩页会影响既有书签 ⇒ 保留 301 到 `/admin` 更稳 |
| **我的建议** | ~~**批准 R2 的"只读优先"形态**(iframe 内嵌 + 仅 admin + 写操作回跳平台域)~~ |
> ✅ **用户已拍板(2026-09-13,见 §9.2b)**:**批准"只读优先",但形态改为「原生弹窗」而非 iframe。**
**R2 工程量提示**:它落在 `@dsh-local/business-plugins` 插件侧(设置面板的 section 由该插件注册)⇒ 改完要**重建插件 tgz + 投放候选池 + 实例启用**,比 R1 长。
### 9.2b 修正(2026-09-13)· R2 形态改为**原生弹窗**(用户拍板)
> 用户原话:「**为什么要 iframe,原生弹窗体验更好**」——质疑成立,原方案是**为了省工程量**牺牲了体验。
**iframe 当初为什么被选**(如实记录,不是辩护):弹窗里要展示的是**平台自己的管理面**(`portal.html`,跑在 `alotbuy.com`)。用 iframe 只是把它**整页嵌进来** ⇒ **零重写**;代价是:外观与实例内 dsh UI **不一致**(两套字体/间距/配色)、门户是带自己导航的 SPA 塞进小窗里、跨域也不方便共享状态。原生弹窗要把这块 UI **在插件里重新渲染**,所以当初没选。
**改定后的形态**:
| 维度 | 原(iframe) | **改定(原生弹窗)** |
|---|---|---|
| 渲染 | `<iframe src="portal.html?embed=1">` | 插件内**原生渲染**(复用 `06-工作台UI规范.md` 的 Token) |
| 数据来源 | 门户页面自己取 | 插件直接调平台 API(共享会话 Cookie:`/api/dsh/status`、`/api/admin/runtime`、`/api/plugins/business` …) |
| 覆盖范围 | 门户**全量**管理面(免费白拿) | **只读优先的子集**(状态 / 实例 / 候选池列表),写操作仍回跳平台域 —— 与"只读优先"的批准口径一致 |
| 体验 | 两套 UI 拼接感 | 与实例内 UI 同源;无跨域嵌套 |
| 代价 | 0 重写 | **需要把只读子集重写一遍**;门户新增区块时**要同步**(双源风险,但仅限 UI 层,API 仍是单一来源) |
⚠️ **不能两头都要**:若日后要求"弹窗里能完成全部管理动作",那就等于**把整个门户重写进插件**(双源维护 + 与档案 39「收窄执行面」方向相反)⇒ 届时应回头把**门户收敛成一个 API 客户端**,而不是继续复制 UI。
### 9.3 R4(文档/代码同仓)· 仍待用户定 —— **先讲清它在解决什么**
> ⚠️ 用户 2026-09-13 反馈「**没看懂 R4 是要做什么**」⇒ 原描述直接跳到选项,缺了动机。补在这里。
**现在是什么样(事实)**:改造文档与平台代码**分在两个 Git 仓库**——
| | 代码 | 文档 |
|---|---|---|
| 本机 | `D:\github\dsh_shenxian` | `E:\ProgramData\AI技能\aliyun-dsh-server\dsh-server-docs` |
| 远端 | `dsh_shenxian.git` | `dsh_shenxian_doc.git` |
| 生产 | `/opt/dshs`(scp + build + 重启) | `/opt/dsh/docs`(**无 `.git`,纯 scp 镜像**) |
**这带来三个真实摩擦**(都是本项目实际踩过的,不是假想):
1. **同一个改动要分两次走**:改功能往往同时要改档案/UI 规范 ⇒ 一次交付要 **2 个 commit + 2 次 push + 1 次 scp 对账**;漏一边就出现"代码说 A、文档说 B"。
2. **版本对不上**:文档里写的"现状"绑的是**某一个代码 commit**,但两库各走各的 ⇒ 复现历史时**无法用一次 checkout 同时拿到"当时的码 + 当时的文档"**(开源导出正因此需要单独冻结版本)。
3. **scp 镜像靠仪式维持**:`/opt/dsh/docs` 没有版本控制 ⇒ 只能靠 `docs-sync-check.sh` 对账 + 人肉纪律;今天的"5 处内容不一致"就是这么暴露出来的。
**两条路(选一个即可)**:
- **(a) 文档并入代码仓**:一个仓库装 `src/` + `docs/`。**彻底去掉对账仪式**,PR 里码与文档一起评审;代价 = 要动 Gitea 仓库结构、迁移历史、改 hooks 与所有脚本里的路径假设。
- **(b) 保持双库,但明确"单向导出"**:承认两库并存,把流向写死成**一个方向**(如"文档是权威源 → 随代码 PR 单向导出到代码仓的 `docs/` 供开源用"),**取消反向同步**;代价 = 仍是两套 commit,只是不再有"谁覆盖谁"的歧义。
**为什么它排在 R2/R3 之后**:纯仓库结构决策,**不影响运行时**;不选也能继续开发,只是每次交付多两道手续。
---
## 十、R5 · 多语言(i18n)· **排最后处理**(用户 2026-09-13 新增需求)
> 用户原话:「加个需求 相关页面能否 支持多语言,可以最后处理」
### 10.1 范围(先划清,避免越界)
| 层 | 是否做 | 说明 |
|---|---|---|
| **平台静态页**(`/`、`login`、`register`、`portal`、`admin`、`wake`) | ✅ **做** | 文案全在 `web/*.html` 里,改动可控 |
| **注入脚本用户可见文案**(`assets/inject/*.js`:浮层「工作区正在恢复…」、「重试」、提示条) | ✅ **做** | 只改文案字符串,逻辑不动 |
| 平台 API 的错误文案(`{error:'pending_review'}` → 中文句子) | ✅ 做(**改为错误码 + 前端映射**) | 现在前端自己把 error 码翻成中文,属"半成品 i18n";统一到一张表 |
| **实例内·官方 UI 文案**(侧栏「新会话/工作区/设置」、对话区「描述你想要构建的内容…」、模型名、标题 `DeepSeek Harness`) | ⚠️ **平台/插件都改不了它本身**(只能「覆盖」,见 §10.5 路径 A/B) | **实地查证(2026-09-13)**:这些文案**硬编码在官方包里** —— `/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-client-ui-*/lib/client.js`(sidebar / settings-plugins / agent-preset …)。官方**没有 UI i18n 机制**:唯一的 `README.i18n.yaml` 是**文档翻译**、不是界面语言包;全树无 locale / 语言切换 API。⇒ 要换语言只能「覆盖」,不是「写个插件就能干净做到」 |
| **业务插件/技能**(`dsh-plugin-mcn-suite` 的界面与 36 个 `SKILL.md`) | ✅ **能做**(**是我们的代码**,只是要另排一期) | 上一版写「不做」是**界线划错了**:它的界面与技能都是**我们自研插件**的内容,改它不动官方包。唯一成本在发布链路:改 → 重打包 tgz → 投放候选池 → 用户重新启用(见 §10.5 路径 C) |
### 10.2 方案(不引框架,最小实现)
1. **词条表**:`assets/i18n/zh-CN.json` + `en.json`(扁平 key,如 `login.submit`、`wake.starting`)。
2. **极小运行时**:`assets/i18n.js`(约 60 行,放 `web/` 供静态页用;注入脚本用同一份词条):
- 选语言优先级:`?lang=` → cookie `dshs_lang` → `navigator.language` → 兜底 `zh-CN`;
- 页面用 `data-i18n="key"`(文本)/ `data-i18n-attr="placeholder:key"`(属性)标注,运行时就地替换;
- **缺 key 时回退到中文原文并 warn**(不出现空文案)。
3. **切换入口**:登录页与门户页页脚一个语言下拉(`?lang=en` + 写 cookie),**不新增页面**。
4. **抽文案纪律**:所有用户可见中文**必须是词条 key**;`verify-static.mjs` 加一条防回归判据(静态页里不得出现"裸中文文案",白名单放 `data-i18n` 与注释)。
### 10.3 为什么排最后(三条硬理由)
1. **它会横穿几乎所有页面** —— 在 R2(门户管理就地化)落地前抽文案,R2 一改就等于白抽一遍;
2. **R3 的内部改名还没走完** —— 词条文件的目录(`assets/i18n/`)与将来 `dshs` 命名体系要对齐,先改完再放词条更省事;
3. **它是纯体验增强、不修任何事故** —— 而今天已确认的 4 类事故(脚本写崩 / OOM / 无限恢复 / 假绿校验)已全部闭环,优先级自然排在后面。
### 10.4 验收口径(先写下来,免得漏)
- 6 个平台页 + 注入脚本文案在 `zh-CN` / `en` 下**无空文案、无 key 泄漏**(页面不得出现 `login.submit` 这种裸 key);
- 语言切换**刷新后保持**(cookie);既有中文行为**零回归**(默认仍是 zh-CN);
- `npm run verify` 通过(含新增的"裸中文"防回归判据);
- **不动** `@deepseek-ai/dsh` 与业务插件(R2 红线)。
### 10.5 两个"可选纳入项"(**要不要做,你一句话定**)
| 选项 | 内容 | 代价 / 风险 | 我的建议 |
|---|---|---|---|
| **A · 注入层覆盖官方 UI 文案**(让实例内也能换语言) | 在注入脚本里按语言字典**替换 dsh 官方界面上的可见文案**(侧栏/菜单/输入框 placeholder 等) | ① 官方**升级或改版即失效**(脆弱,需长期跟)② 属"改别人 UI",可能出现文案与官方功能不匹配 ③ 只能覆盖 DOM 可见文本,覆盖不全(提示/报错/动态内容) | ⚠️ **折中可行但不推荐**;若只为"给外部客户看",建议**只覆盖 3–5 个高频入口**(如「新会话 / 设置 / 发送」),不做全量 |
| **B · 插件自带 i18n**(mcn-suite 界面与技能文案) | 词条表打进插件 tgz;插件 UI 与 `SKILL.md` 按语言选词 | 改建 → 重打包 → 投放候选池 → 用户重新启用(链路长);技能文案还牵动 agent 的提示词 | 📋 **可另立一期**(等 R5 主体做完再评估) |
| **C · 只做平台页 + 注入脚本文案**(原方案) | 6 个平台页(登录/注册/门户/管理台/过渡页/首页)+ 我们注入的浮层与提示条 | 最小、零回归 | ✅ **默认按 C 做**:平台自己写的界面 100% 覆盖;实例内官方 UI 保持原样 |
**一句话结论**:**C 是必做项**(平台自己的界面),**A / B 是可选扩展**;你说「加 A」「加 B」或「只做 C」,我照办。
> 📌 **补充(2026-09-13,见 §10.7)**:查证发现官方自带 locale 扩展点后,**多出第 4 个选项 `D · 走官方 locale 体系`**(自研插件与浮层文案跟实例内语言开关联动,**不触 R2**)—— 它可视为 **C 的加强版**(同一批词条,换一套消费方式)。选型时把 D 一并考虑。
> ✅ **用户已拍板(2026-09-13)**:**R5 = 走官方方案**(用户原话:「R5 肯定是匹配 dsh 官方方案,什么会有疑问呢」)⇒ **按 `D` 实施**:平台页 / 注入层 / 自研插件一律走官方 `ctx.locale.addLanguage()` + `ctx.locale.register(ns, lang, {...})` 扩展点,**官方代码零改动**。
> ⚠️ **唯一的边界**(不是疑问,是事实,需在验收口径里写明):官方**自己只把 2 个 UI 包迁到了 locale**,仍有 **20+ 个包硬编码中文**(`trajectory`/`chat`/`settings-models`/`workspace`/`settings-plugins`/`agent-preset` …,清单见 §10.7 ⑤)。⇒ **切英文后,那些官方包仍显示中文**。这属**官方未完成项**,不在我们可改范围(改它们 = 触 R2)。若日后确有需要,只能另开一期走「注入层改 DOM」(脆弱、覆盖不全)。
> ⚠️ **2026-09-15 更正**:本条只适用于 **0.1.2-rc.1**。**0.1.5-rc.1 实测**:官方客户端 **55 个包里已有 36 个走官方 locale API** ⇒「切英文后仍大面积中文」**已基本不成立**,详见文末 **§10.8**。
> 因此 **A/B/C 三项不再作为主方案**:A 降级为"若官方长期不迁则考虑"的备选;B 被 D 取代(用官方体系即可覆盖自研插件,不必自造词条运行时);C 被 D 完全包含(D 的做法同样覆盖平台页,只是消费端换成官方 `t` 座位)。
### 10.6 ⚠️ 界线更正(用户 2026-09-13 质疑后**实地查证**)
> 用户原话:「现在做的不都是插件吗,怎么会影响 dsh 原项目呢」——**质疑成立,我上一版把界线划错了。**
**真正的分界不是「是不是插件」,而是「这段文案的代码是谁的」**:
| 代码归属 | 例子 | i18n 能不能做 |
|---|---|---|
| **我们写的**·平台静态页 | 登录 / 注册 / 门户 / 管理台 / 过渡页 / 首页 | ✅ 直接做 |
| **我们写的**·平台注入层 | 恢复浮层「工作区正在恢复…」、顶部提示条、`📁 我的文件` / `🧭 能力` 面板 | ✅ 直接做(改 `assets/inject/*.js`) |
| **我们写的**·自研插件 | `@dsh-local/business-plugins`(功能管理分区)、`portal-entry`(平台管理卡片)、`workspace-scoped-picker`、`dsh-plugin-mcn-suite`(MCN 工作台)、`dsh-univer-office` | ✅ 能做(改插件 → 重打包 → 投放 → 启用) |
| **官方写的**·`@deepseek-ai/dsh*` 内的 UI | 侧栏「新会话」、对话区 placeholder、设置面板骨架、模型名 | ⚠️ **改不了它本身**(R2 红线:官方包零改动)。**要分两种**:① **已迁到官方 locale 的包** → 用户切语言即跟随(零改动,白拿);② **仍硬编码中文的包**(约 20+ 个,见 §10.7)→ 只能**覆盖**(§10.5 路径 A / 改 DOM) |
**为什么插件不能「改」官方那部分**:dsh 的插件机制是**扩展(extend)** —— 插件注册自己的 UI / 能力;它**不提供「替换官方内置文案」的扩展点**。想动官方**已硬编码**的那部分只有两条路:**注入层改 DOM**(可行但脆弱)或 **替换官方同名包**(= 改官方产物,**触 R2,不做**)。
> ⚠️ **本节曾被写错、已于同日更正**:原文写「官方 UI 包…**无语言包、无 locale API**」是**错的** —— 官方**有** `@deepseek-ai/dsh-client-locale` 与完整的语言扩展 API。查证证据与结论见 **§10.7**。
### 10.7 修正(2026-09-13)· 官方 i18n 能力实地查证
> 触发:用户追问「**有哪里是需要 替换官方包 / 改官方代码的**」。核查时发现 §10.6 的结论建立在**一次不完整的抽查**上(当时只看到 locale 包里有一个 `README.i18n.yaml`,就当成"只是文档翻译")——**这个判断是错的**,本节省略推测、只留实测。
**实测对象**(服务器 `bt-server`,只读):
```
/usr/local/bin/dsh -> /usr/local/lib/node_modules/@deepseek-ai/dsh/lib/bin.js
/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-client-locale/
package.json README.md README.zh.md README.i18n.yaml LICENSE
lib/index.js (1.3 KB, host 侧) lib/client.js (55.5 KB, 含内置词典)
```
**① 官方确实有一套 i18n,而且是有文档的扩展点**(`package.json` description 原文):
> "Locale plugin: Host-backed preference, **extensible language catalog**, browser fallback, and typed built-in dictionaries"
**② host 侧只做一件事** —— 在 settings 里注册语言偏好命名空间(`lib/index.js` 全文关键常量):
| 常量 | 值 | 含义 |
|---|---|---|
| `LOCALE_SETTINGS_NAMESPACE` | `"locale"` | 设置命名空间 |
| `LOCALE_PREFERENCE_FIELD` | `"preference"` | 存 `$DSH_HOME/settings.yaml` 的字段 |
| `LOCALE_IDS` | `["zh", "en"]` | **随包发布的**语言(仅两种) |
**③ client 侧导出**:`LocaleRuntime` · `COMMON_NS` · `FALLBACK_LOCALE` · `SETTINGS_NS` · `apply` · `inject`。内置 `common` 词典是通用词(`"ok":"确定" / "cancel":"取消" / "close":"关闭" / "copied":"复制成功" / "retry" / "loading" …`)。
**④ 官方文档给出的扩展 API(关键 —— 这条路不碰官方代码)**:
```js
export const inject = ['locale']
export function apply(ctx) {
// 加一门语言(外部 id = BCP 47 风格;fallback 必须已注册且链最终终止于 en)
ctx.effect(() => ctx.locale.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' }), 'my-locale: language')
// 注册某命名空间的该语言词典
ctx.effect(() => ctx.locale.register('common', 'ja', { cancel: 'キャンセル', close: '閉じる' }), 'my-locale: common dictionary')
}
```
- 消费方式:`ctx.locale.bind(ns)` 或框架注入的 `t` 座位;
- 设置入口:**Settings → General**(用户自己选语言);
- 官方原话:**"The package ships `zh` and `en`, while external client plugins can add languages and their namespace dictionaries."**
> ### ⚠️ 本条已被更正(2026-09-15 实测 dsh **0.1.5-rc.1**,服务器 `bt-server` 只读)
>
> 下面这段结论**只适用于 0.1.2-rc.1**。**当前版本实测**:客户端包 **55** 个中,
> **36 个已走官方 locale API**(`locale.register` / `locale.bind` / `addLanguage` / `LOCALE_IDS`),
> 其中 **35 个同时含中文** —— 那是它们**自带的 `zh` 词条**,**不是**硬编码;
> **不用 API 却仍含中文的只剩 1 个**(`dsh-client-connection`)。
>
> ⇒ **官方已大面积补齐客户端 i18n**,「切英文后大量官方包仍显示中文」这个担心**在当前版本已基本不成立**。
> ⇒ 但**这不改变 R5 的结论**:平台自己的 6 个静态页 + 注入浮层文案**仍然要做**(那段代码是我们的)。
>
> 复现命令(对 `<dsh 包根>/node_modules/@deepseek-ai/*/lib/client.js` 逐包):
> `grep -cE "locale\.(register|bind)|addLanguage|LOCALE_IDS"` 与 `grep -c '[一-龥]'`。
**⑤(0.1.2-rc.1 时期)但官方自己没铺开** —— 实际引用了 locale 的官方 UI 包**只有 2 个**(`dsh-client-ui-conversation`、`dsh-client-ui-directory-picker-browse`),其余大量包**把中文硬编码在 `lib/client.js`**(按含中文行数,前 15):
| 包 | 中文行数 | 包 | 中文行数 |
|---|---|---|---|
| `dsh-client-ui-trajectory` | 167 | `dsh-client-ui-cordis` | 46 |
| `dsh-client-ui-conversation` | 138 | `dsh-client-ui-settings-plugin-inventory` | 36 |
| `dsh-client-ui-chat` | 99 | `dsh-client-ui-subagent` | 34 |
| `dsh-client-ui-settings-models` | 98 | `dsh-client-ui-permission-presets` | 20 |
| `dsh-client-ui-workspace` | 64 | `dsh-client-ui-schedule` | 18 |
| `dsh-client-ui-settings-plugins` | 50 | `dsh-client-ui-workflow-run` | 17 |
| `dsh-client-ui-agent-preset` | 50 | `dsh-client-ui-model-selection` | 17 |
**⑥ 结论 —— 回答「有哪里需要替换官方包 / 改官方代码」:**
| 范围 | 要不要替换官方包 / 改官方代码 | 用什么 |
|---|---|---|
| 平台 6 个静态页 + 过渡页 | ❌ 不需要 | 自己的 HTML,按 §10.4 抽词条 |
| 平台注入层(浮层 / 提示条 / 文件·能力面板) | ❌ 不需要 | 改 `assets/inject/*.js` |
| 我们自研 / 投放的插件(`business-plugins`、`portal-entry`、`mcn-suite`…) | ❌ 不需要 | **官方扩展点** `ctx.locale.register()` / `addLanguage()`(第 ④ 条) |
| 官方 UI · **已迁到 locale 的包**(2 个) | ❌ 不需要 | **用户切语言即自动跟随**(零改动白拿) |
| 官方 UI · **仍硬编码中文的包**(20+) | ⚠️ 官方自己没迁完。不改官方代码就只能**注入层改 DOM**(脆弱、覆盖不全,§10.5 路径 A);**替换官方同名包 = 改官方产物 ⇒ 触 R2 ⇒ 不做** | — |
⇒ **对 R5(多语言)的净影响:可选面变大了。** 原先只有 `C(平台页+注入层)` / `A(覆盖官方 DOM)` / `B(插件自带 i18n)` 三项;**现在多出一项**:
`D · 走官方 locale 体系` —— 用 `addLanguage` + `register` 让我们自研插件与浮层文案**跟着实例内的语言开关走**,与官方 `zh/en` 同一套机制、用户在一个下拉里切换。它**不触 R2**,是 §10.4 方案 C 的**加强版**(同一批词条,换一套消费方式)。
⚠️ 代价:`register(ns)` 要求**同时提供 `zh` 与 `en`**(编译期校验),且新增语言(如 `ja`)的 fallback 链必须终止于 `en`。
**⑦ 遗留待查(尚未查,不写结论)**:`addLanguage` 是否要求目标语言的语言**定义**先行注册(README 说"definitions 与 dictionaries 可任意顺序",但"fallback 必须已注册")——真做 D 方案时需在实例里跑一次验证。
**⚠️ 教训**:上一轮的错在**用「一个文件名」代替「读一读那个包」**就下了"官方没有能力"的结论。凡结论会影响方案取舍(尤其"做不了"这类),**必须落到实测证据**,不能靠抽样直觉。
---
### 10.8 修正(2026-09-15)· 官方 locale 覆盖率**已大幅补齐**(实测 dsh **0.1.5-rc.1**)
> 触发:用户问「项目支持多语言吗(英文/中文)」。顺手复核 §10.7 ⑤,**发现它已过时**。
**实测**(服务器 `bt-server`,**只读**;对象 `<dsh 包根>/node_modules/@deepseek-ai/*/lib/client.js`):
| 指标 | 实测值 |
|---|---|
| 客户端包总数 | **55** |
| **已走官方 locale API**(`locale.register` / `locale.bind` / `addLanguage` / `LOCALE_IDS`) | **36** (其中 35 个**同时含中文** —— 那是它们**自带的 `zh` 词条**,不是硬编码) |
| **不用 API 却仍含中文** | **1**(`dsh-client-connection`) |
| 既不用 API 也不含中文 | 18(多为没有 UI 文案的逻辑包) |
复现命令(逐包两条计数):
```sh
f=<pkg>/lib/client.js
grep -cE "locale\.(register|bind)|addLanguage|LOCALE_IDS" "$f"
grep -c '[一-龥]' "$f"
```
**结论更正**
- §10.7 ⑤ 的「官方只迁了 **2** 个包、**20+** 个包硬编码中文」**只适用于 0.1.2-rc.1**;在 **0.1.5-rc.1** 上官方**已大面积补齐** ⇒ §10.5 那句「切英文后那些官方包仍显示中文」**在当前版本已基本不成立**。
- ✅ **R5 的结论不变**:**平台自己的 6 个静态页**(`web/*.html`,现全为 `<html lang="zh-CN">` + 硬编码中文)**与注入浮层文案仍是待做项** —— 那段代码是我们的,与官方迁不迁无关。
- 📌 **选型不变**:走官方 locale 体系(`D`:`addLanguage` + `register`),**零官方改动**。
- ⚠️ **另一处仍未做**(与「官方有没有迁 locale」无关):平台静态页目前**没有任何 i18n 运行时** —— 无词条表、无 `?lang=`/cookie 选择、无界面切换入口;API 错误文案仍是「前端把 error 码翻成中文」的半成品(R5 计划统一到一张表)。
---
### 10.9 追加口径(2026-09-15 · 用户定)· **默认语言 = 英语**;插件一律沿用官方 locale
> 用户原话:「**开发的插件需要沿用 dsh 官方项目的多语言方案,这个项目是全球化的项目,默认选中英语**」
**① 自研插件:现状已合规(无需改造)**
| 检查项 | 实测(源仓 `poc/business-plugins/lib/client.js`) |
|---|---|
| 走官方扩展点 | ✅ `locale.register(NS, { zh, en })` + `locale.bind(NS)` |
| 声明依赖 | ✅ `inject = ["slots", "locale"]` |
| 双语完整 | ✅ `zh` / `en` **两套同时提供**(`register` 的编译期要求) |
⇒ **纪律(对后续所有自研插件生效)**:⛔ **不自造词条运行时**;一律 `ctx.locale.register(ns, lang, {...})` + `ctx.locale.bind(ns)`,且 `inject` 必须含 `"locale"`。
**② 「默认英语」在官方侧本来成立(实测 dsh 0.1.5-rc.1)**
| 事实 | 值 |
|---|---|
| `FALLBACK_LOCALE` | **`"en"`** |
| `LOCALE_IDS` | `["zh", "en"]` |
| 偏好存放 | `settings.yaml` 的 `locale.preference`(`LOCALE_SETTINGS_NAMESPACE="locale"` / `LOCALE_PREFERENCE_FIELD="preference"` / `SETTINGS_NS="settings.locale"`) |
| 解析顺序 | 宿主偏好 → … → `navigator.language`(**排在后面**)→ 兜底 `FALLBACK_LOCALE` |
⇒ **平台不需要为「默认英语」改官方代码或打补丁**(官方兜底即英语)。
⚠️ **唯一例外**:`navigator.language` 仍参与解析 ⇒ **中文浏览器且无偏好时可能自动落到 `zh`**。若要「**一律默认英语、忽略浏览器**」,就在**铺实例时把 `locale.preference: en` 写进该用户的 `settings.yaml`** —— 这是**平台侧配置**(不触官方代码);用户之后仍可在 Settings → General 自选,且选择会保持。
**③ 平台静态页(R5 范围):兜底语言 `zh-CN` → `en`(覆盖 §10.2 第 2 条)**
新的选择优先级:`?lang=` → cookie(`dshs_lang`)→ `navigator.language`(**仅命中支持列表才用**)→ **兜底 `en`**。
> 📌 保留 `navigator` 一步是**全球化产品的常规做法**(浏览器是中文就用中文);若你要的是「**忽略浏览器、首次一律英文**」,去掉这一步即可。
> ⚠️ 这**一步的取舍是我的取值(可推翻)** —— 你的口径是「默认英语」,我按「兜底英语 + 浏览器命中则跟随」实现。
**验收口径增补**:6 个平台页 + 注入文案在 **`en`** 下**无空文案、无裸 key**;**无 cookie 首次访问应为英文**;切到 `zh` 后刷新保持;既有中文行为零回归。
**④ 尚未落地(现状差距)**
| 项 | 现状 | 归属 |
|---|---|---|
| 平台 6 个静态页 + 注入浮层文案 | **仍是纯中文**(`<html lang="zh-CN">` + 硬编码文案,无 i18n 运行时) | R5 待做(排最后,见 §10.3) |
| 实例内官方 UI | **已默认英语**(`FALLBACK_LOCALE=en`) | 官方已给 ✅ |
| 自研插件(`business-plugins` 等) | **已双语**(跟随实例语言) | ✅ 已完成(档案 37b / 60 / 67) |
| 开源仓库文档 | **英文为主 + 中文副本** | ✅ 已完成(R-O13) |
**⑤ 落地进展(2026-09-15 · 用户要求「优化插件代码 + 登录窗口与设置中加语言切换」)**
| 项 | 状态 |
|---|---|
| 平台 i18n 运行时 | ✅ 新增 `web/i18n.js`:词条表(`en` / `zh` 两套,扁平 key)+ 极小运行时(`data-i18n` / `data-i18n-attr` 就地替换)+ 语言下拉组件(容器标 `data-i18n-switch`)。**默认 `en`**;语言 id **与官方 `LOCALE_IDS` 对齐**(`en` / `zh`,便于日后透传给实例) |
| **登录窗口**语言切换 | ✅ `web/login.html` **全量词条化** + 卡内语言下拉;缺 key 回退英文并 warn(不出现空文案/裸 key) |
| **设置中用户设置栏**语言切换 | ✅ 插件 `@dsh-local/business-plugins` **0.3.20** 新增**用户可见** section「**偏好设置**」(id `preferences`,order **99**,排在模型设置之前),内含「界面语言」下拉,走官方 `ctx.locale.getLocale()` / `ctx.locale.setLocale(id)`(**零官方改动**)。已铺发开发服务器:**admin bundles=7 / guest bundles=6,均含该插件**(未被静默摘除)。**pnpm/`npm pack` 产物 md5 本地=服务器** |
| 断言(防回归) | ✅ `scripts/verify-platform-admin-section.mjs`:section 数量断言 `3 → 4`、"非 admin 2 → 3 个分区",并**新增** `preferences` 的渲染断言(断言输出实测含「界面语言 | English | 中文」)。`npm run verify` **全绿** |
⚠️ **与 §10.2 的一处有意偏差**:cookie 名由 `dshs_lang` 改为 **`dsh_lang`** —— `scripts/verify-static.mjs` 有去痕迹约束 `BANNED`(**用户可见面不得出现平台内部名**),前端资产含该串会被判**不合格**(本次已实测被拦一次)。
⏳ **仍未做**:其余 5 个平台页(`index` / `register` / `portal` / `admin` / `wake`)与**注入浮层文案**的词条化(同一套运行时,按页推进即可);`verify-static.mjs` 的「静态页不得出现裸中文文案」防回归判据(待 6 页都词条化后再开,否则会误拦未迁移的页)。