**背景**:这些改动原属本工作区另外几个会话(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 提交边界:用户说"提交",未说"推送")。
21 KiB
100 · 实例内「我的技能」—— 并入「功能管理」的分组方案与实现
- 日期:2026-09-15
- 触发:用户「规划一个产品方案(界面布局美观 方便操作)然后实现这个功能」=落地
交接单/T01(档案 16 阶段 3/4) - 对象:
poc/business-plugins(@dsh-local/business-plugins)client bundle 的「功能管理」section - 状态:✅ 已实施并投放(
business-plugins0.3.21,两实例;方案见 §二,验收见 §8.2/§8.4)- ⚠️ 后续:该分区已改名「能力管理」并改为页内 tab 分页(档案 101);本档案正文保留当时的形态描述(档案只增不改)
TL;DR|结论:在既有「功能管理」section 内新增「我的技能」分组(不新开 section),用户在同一页看完并管理「功能插件 + 我的技能」两类功能;技能行支持 上传 / 启用 / 停用 / 删除,平台共享技能显示为锁定只读。 关键:入口层合并、机制层分离(技能 watch 即时生效 vs 插件需重启;API / 落盘各自独立)—— 依据见
交接单/T01 §九。 不做:不改服务端(6 个/api/skills/mine*路由早已就绪,档案 41);不新造包;不碰官方包。
一、目标与完成判定
目标:普通用户在实例内自助管理个人技能,且平台投放/用户自传两类"功能"在同一页、同一套交互语汇下呈现。
完成判定(可被第三方复现):
- guest 实例 → 设置 →「功能管理」页内出现「我的技能」分组,且与「功能插件」同页可见;
- 上传一个合法
.zip技能 → 行内新增且状态「已启用」;$DSH_HOME/skills/<name>存在; - 停用 → 文件移到
home/skills-library/<name>;启用 → 回到skills/;删除 → 两处均无; - 与共享技能同名上传 / 对锁定行操作 → 后端 409,界面以行内错误如实呈现;
- 全程无需重启实例(技能是 watch 驱动)。
二、产品方案(界面布局)
2.1 页内结构(自上而下,同页滚动)
┌─ 功能管理 ───────────────────────────────────────────────────────┐
│ │
│ ▍功能插件 ← 既有分组(不动) │
│ ├─ 内存配额状态条(既有) │
│ ├─ 工具行:搜索 / 全选 / 清空 / 已选 N 项(既有) │
│ └─ 插件卡片网格(既有,勾选式) │
│ │
│ ─────────────── 1px 分隔线 ─────────────── │
│ │
│ ▍我的技能 [3] [+ 上传技能] ← 新增分组(本单) │
│ ├─ 说明行:上传 .zip 技能包;启用/停用**立即生效,无需重启** │
│ ├─ 上传面板(默认收起,点「上传技能」展开 / 支持拖入) │
│ └─ 技能行列表 │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 账号诊断 [共享]🔒只读 12 文件 · 136 KB │ │
│ │ 我的图表工具 [我的]●已启用 8 文件 · 24 KB [停用][删除]│
│ │ 旧版脚本 [我的]○已停用 5 文件 · 12 KB [启用][删除]│
│ └──────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
为什么是这个结构:用户在「功能管理」里的心智是"我有哪些功能、开没开"。两类功能放同一页、同一行卡片语汇,一次滚动看完;差异(要不要重启)由行内事实文案承担,而不是靠分成两页去解释。
2.2 分组头(.bp-group-h)
| 元素 | 规格 |
|---|---|
| 标题 | 15px / 600 / T.text,前缀 3px 竖条(T.primary,圆角 2px)—— 与「功能插件」分组头同一形态 |
| 计数徽章 | 13px,T.field 底 + T.sub 字,2px 8px r10(规范 §4.6 .badge 量级) |
| 右侧主按钮 | 「+ 上传技能」,白底 + T.border 边 + T.primary 字(规范 §4.1 .btn-view 量级),点击展开/收起上传面板(aria-expanded) |
| 与上方分组的分隔 | 1px solid T.border + margin-top: 22px; padding-top: 18px(规范 §2.4 间距序列) |
2.3 技能行(.bp-skill-row)—— 单行不换行
| 区 | 内容 | 规格 |
|---|---|---|
| 左 | 技能名(name) |
14px / 500 / nowrap + ellipsis + title(规范 §5 长文本截断三件套);min-width:0 允许收缩 |
| 中 | 来源徽章 | 共享 = T.primary 12% 底 + T.primary 字;我的 = T.field 底 + T.sub 字。13px / r10 |
| 中 | 状态 | ● 点(6px 圆点)+ 文字:已启用 = T.success;已停用 = T.dim。13px |
| 中 | 事实(右对齐) | 12 文件 · 136 KB,13px + font-variant-numeric: tabular-nums(规范 §5「数值一律右对齐 + 等宽数字」) |
| 右 | 动作区 | margin-left:auto + nowrap;启用/停用 = 白底 border 按钮(.btn-sm 量级);删除 = T.danger 文字按钮。busy 时该行按钮统一 disabled + opacity .5 |
| 整行 | 容器 | padding: 12px 14px,border: 1px solid T.border,r12,白底(--dsw-alias-bg-layer-1),hover 变 T.field 底 |
锁定行(locked: true,平台共享技能):来源徽章后追加 🔒 + 「只读」,不渲染任何动作按钮;title 说明「平台共享技能,全员只读,不可停用/删除/覆盖」。→ 与后端 assertNotShared() 的 409 双向一致(前端不给出会失败的按钮,后端仍然兜底)。
2.4 上传面板(.bp-upload)
| 元素 | 交互 |
|---|---|
| 虚线投放区 | border: 1px dashed T.border,r10,padding: 18px,居中;文案「把 .zip 拖到这里」+ 次行「技能包需含 SKILL.md(name / description)」 |
| 「选择文件」 | input[type=file][accept=".zip"] 隐藏 + <label> 触发(点击即开系统文件框);aria-label 齐备 |
| 选中态 | 显示文件名 + 体积;主按钮「上传」+ 次按钮「取消」;客户端先校:后缀非 .zip → 行内报错;> 150MB → 行内报错(与门户一致) |
| 拖拽 | onDragOver 阻止默认 + 高亮边框;onDrop 取 dataTransfer.files[0];非文件拖入静默忽略 |
| 上传中 | 区禁用 + 按钮文案「上传中…」;完成后 load() 重拉列表 |
| 错误 | 行内(.bp-upload-err,T.danger,13px)显示后端返回的中文错误原文(如「「x」是平台共享技能…请改用其他技能名」),不弹窗 |
2.5 两个页内确认弹窗(沿用 v0.2.8/v0.3.15 的既有弹窗语汇)
| 弹窗 | 触发 | 内容 | 按钮 |
|---|---|---|---|
| 同名替换 | 上传返回 conflict: true |
标题「检测到同名技能「X」」;正文:⚠️替换将删除旧技能的全部文件 —— 新包中已移除的文件也会一并删除,不可恢复;事实行:旧 12 文件 · 136 KB → 新 8 文件(tabular-nums) |
取消(白底)/ 确认替换(T.danger 实心) |
| 删除技能 | 行内「删除」 | 标题「删除技能「X」?」;正文:该技能将从你的实例移除,不可恢复(若是停用状态,库中的文件也一并删除) | 取消 / 删除(T.danger 实心) |
两者均:遮罩 rgba(0,0,0,.35) + 面板 r12 + 标题行 ✕ + 点遮罩关闭 + stopPropagation(与既有 __confirm 同款,规范 §4.7 提到 .modal-mask[hidden] 的坑 → 这里用条件渲染,不存在该问题)。
2.6 文案(zh / en 双语,键前缀 msk.)
新增词典键全部落在客户端既有 zh / en 两个对象里,同批加、键数一致(由 scripts/verify-models-dict.mjs 全词典断言守住:键数一致 + 被引用必已声明)。
措辞三原则:
- 如实说生效方式:技能「启用 / 停用立即生效,无需重启」;插件侧文案仍保留「需重启」——两者并存,用户看得到区别。
- 如实说后果:替换/删除弹窗写明"不可恢复""旧文件一并删除"。
- 不暴露内部标识:只说"技能 / 技能包 / .zip",不出现路径、
skills-library、rank、"共享层"等内部词。
2.7 视觉同源(为什么"看起来是一家")
复用 v0.3.14 起确立的官方卡片行语汇:min-width:0 + overflow/ellipsis/nowrap 三件套、动作区 margin-left:auto + nowrap、颜色全走 --dsw-* token(跟随 dsh 主题,亮/暗自动适配)。结构上不可能换行,也就不会再犯 v0.3.14 那次"按钮被挤换行"的缺陷。
三、为什么是「分组」而不是「新 section」
见 交接单/T01 §九(本次追加)。一句话:用户口径「不要分开管理」+ 该 section 已由档案 60 命名为「功能管理」⇒ 另立「我的技能」等于在"功能"之外再造平行概念。
⛔ 仅入口层合并:两条后端 API、两套落盘位置、两种生效方式都不合并(档案 16 §2.1 决策 2「技能与插件启用语义不同」)。
四、接口契约(后端已就绪,本单不改服务端)
src/web/routes/skills.ts(档案 11 v2 + 档案 41,均 requireAuth):
| 方法 | 路径 | 请求 | 响应 |
|---|---|---|---|
| GET | /api/skills/mine |
— | { skills: [{ dir,name,description,size,files,mtimeMs, source:'shared'|'user', enabled, locked }] }(三类合并:共享 → user 已启用 → user 已停用) |
| POST | /api/skills/mine |
{ file: base64, filename } |
无同名 → { ok, skill };有同名 → { ok, conflict:true, stagedId, skill:{name,description,files}, existing:{name,size,files,mtimeMs} };错误 → 4xx { error } |
| POST | /api/skills/mine/apply |
{ stagedId } |
{ ok, skill }(全量替换:旧目录整删,新包缺失的文件随之消失) |
| POST | /api/skills/mine/:name/enable |
— | { ok, skill };404 not_found;409 already_enabled |
| POST | /api/skills/mine/:name/disable |
— | { ok };404 not_found |
| DELETE | /api/skills/mine/:name |
— | { ok };404 not_found;409(与共享技能同名) |
- 与共享技能同名 → 上传/启用/停用/删除一律 409,错误文案为中文原文(前端直接展示)。
- 跨子域调用:沿用
fetch(portalHost() + …)+credentials: 'include'(门户 CORS 白名单含 GET/POST/DELETE,与/api/plugins/mine同机制)。
五、实现落点
| 文件 | 改动 |
|---|---|
poc/business-plugins/lib/client.js |
① zh/en 各加 msk.* 词条;② BusinessPluginsSection 内新增 skills 状态 + loadSkills/upload/applyReplace/toggle/remove 动作;③ 渲染「我的技能」分组(分组头 + 上传面板 + 技能行 + 空态);④ 两个页内弹窗;⑤ 注入 CSS(.bp-skill-row 等) |
poc/business-plugins/package.json |
版本 0.3.20 → 0.3.21;description 头部追加本轮条目 |
scripts/verify-my-skills.mjs |
新增:词典键 / 版式不换行 / 危险操作二次确认 / 锁定行无动作按钮 的机械断言(防回退) |
package.json(仓根) |
npm run verify 串联新脚本 |
明确不动:服务端 src/**(无改动)· 官方包(R2)· 现有「功能插件」分组的行为与文案 · web/** 静态页。
六、验收
| # | 口径 | 命令 / 动作 | 期望 |
|---|---|---|---|
| A | 本地 | npm run verify |
退出码 0(build + 单测 + 全部 verify 脚本) |
| B | 磁盘(06 §7.3 ①) | 在实例 profile 的插件里 grep 新特征串 | ≥ 1 |
| C | 实例已加载(06 §7.3 ②) | 抓实例页 HTML,比 URL 里的 rev |
与改前不同 |
| D | 服务端返回(06 §7.3 ③) | 用 HTML 里完整 bundle URL 请求 | 200 + 含新特征串 |
| E | 端到端(R4 模板:临时用户,用完即删) | 上传 → 停用 → 启用 → 删除 | 四步皆 200;列表状态随之变化 |
| F | 守卫 | 与共享技能同名上传;对锁定行调 enable/disable/delete | 全部 409 |
| G | 界面 | 浏览器(agent-browser,唯一许可)打开 guest 实例「功能管理」 |
「我的技能」分组与「功能插件」同页;锁定行无可点动作 |
生效链路提醒(06 §7.1):改 client bundle 必须走 npm pack → 铺 profile → 重启实例 → 浏览器侧由 rev 自动更新(平台已加 no-cache)。
七、回滚
- 插件回退:把
/opt/dsh/artifacts/business-plugins-0.3.20.tgz放回并按ensure-biz-plugins.cjs --all --restart重铺(版本号不同即可,pnpm 不会命中缓存)。 - 服务端:本单零改动,无需回滚。
- 残留检查:实例 profile 的
@dsh-local/business-plugins版本号回落到 0.3.20 即成功。
八、实现与验证记录
8.1 改动清单
| 文件 | 改动 |
|---|---|
poc/business-plugins/lib/client.js |
① zh/en 各 +46 条 msk.* 词条;② BusinessPluginsSection 内新增「我的技能」状态机 + loadSkills/submitUpload/applyReplace/toggleSkill/removeSkill + 参数化弹窗 skillDialog();③ 渲染分组(分组头 + 说明行 + 拖拽上传面板 + 行内结果 + 技能行列表);④ 两个页内确认弹窗;⑤ 注入 .bp-* CSS(含 hover / 拖拽高亮 / prefers-reduced-motion) |
poc/business-plugins/package.json |
版本 0.3.20 → 0.3.21;description 头部追加本轮条目 |
scripts/verify-my-skills.mjs |
新增:34 条机械断言(结构 / 危险操作 / 锁定行 / 版式不换行 / 措辞 / R3) |
package.json(仓根) |
npm run verify 串联新脚本(末位) |
零服务端改动(后端 6 路由早已就绪);未动官方包(R2);未动现有「功能插件」分组行为。
8.2 验收结果(全绿)
| # | 口径 | 结果 |
|---|---|---|
| A | 本地 npm run verify(build + 3 组单测 + 全部 verify 脚本) |
✅ 通过;词典 zh/en 各 366 键、引用键 343 个全部已声明 |
| B | 磁盘(06 §7.3 ①) | ✅ 两实例 profile 均 0.3.21,msk.group 命中 3 / bp-skillRow 命中 4 |
| C | 实例已加载(06 §7.3 ②) | ✅ 壳页 combo 出现 @dsh-local/business-plugins;壳页 58,126 B → 58,724 B;rev=533537bbc02b |
| D | 服务端返回(06 §7.3 ③) | ✅ bundle HTTP 200,11,363,655 B,含 msk.group / msk.replaceWarn / bp-skillRow / 「我的技能」 |
| E | 端到端(R4 一次性用户) | ✅ 19/19:上传 → 列表 enabled → 同名 conflict+stagedId → apply 全量替换 → 停用(文件落 skills-library 且不在 skills)→ 启用(回到 skills)→ 删除(两处皆无、列表消失) |
| F | 守卫 | ✅ 对共享技能 platform-capabilities 的 disable / delete 均 409;非 .zip 400「仅支持 .zip 文件」 |
| G | 浏览器视觉 | 见 §8.4 |
8.3 部署记录
# 打包(先删 lib/*.bak;npm pack 前核断言锚点)
dsh-local-business-plugins-0.3.21.tgz 70,758 B 4 files sha256 77429d2c…
# 投放
scp → /opt/dsh/artifacts/business-plugins-0.3.21.tgz (哈希与本地一致)
ssh bt-server 'cd /opt/dshs && node scripts/ensure-biz-plugins.cjs --all --restart'
admin: 版本落后(0.3.20 → 0.3.21),升级中… ✓ bundles=7(含 @dsh-local/business-plugins: true)
guest: 版本落后(0.3.20 → 0.3.21),升级中… ✓ bundles=6(含 @dsh-local/business-plugins: true)
已停 0 个实例 scope(下次访问自动拉起,新 bundle 才生效)
✅ bundles 未被 pruneBrokenFileDeps() 摘掉依赖(这是换产物时最需要核对的一项)。
8.4 浏览器视觉验收(✅ 全绿,agent-browser + 一次性用户)
R4 一次性用户 pocui* → 门户登录 → 进实例 → 设置 →「功能管理」。全程零缺陷:
| 步骤 | 观察到的结果 |
|---|---|
| 实例页壳 | 浏览器实际请求的 combo URL 含 @dsh-local/business-plugins/client.js、rev=533537bbc02b(= 06 §7.3 ② 的浏览器侧复核) |
| 设置 → 功能管理 | 分区列表 = 通用设置 / 模型 / 插件 / Agent 预设 / 偏好设置 / 模型设置 / 功能管理 / 用户设置;「功能管理」页 = 内存条 + 工具行 + 插件卡片 + 「我的技能」分组(同页,分隔线之上是「功能插件」) |
| 分组头 | ❙ 我的技能 + 计数徽章「1 个技能」+ 右侧「+ 上传技能」;下方说明行「上传你自己的技能包(.zip)。启用 / 停用立即生效,无需重启实例。」 |
| 共享技能行 | platform-capabilities|共享|🔒 只读|已启用|1 个文件 · 2.9 KB —— 没有任何动作按钮 ✓ |
| 上传面板 | 点「+ 上传技能」展开 → 按钮变「收起」;面板 = 虚线框 +「把 .zip 拖到这里,或 [选择文件]」+ SKILL.md 要求说明 |
| 选文件 | 选 poc-skill-demo.zip → 行内出现 poc-skill-demo.zip|843 B|[上传][取消](客户端校验通过) |
| 点「上传」 | POST /api/skills/mine → 200;页面出现「✓ 已上传技能「poc-skill-demo」」;列表新增 poc-skill-demo|我的|已启用|3 个文件 · 575 B|[停用][删除] |
| 点「删除」 | 弹窗 = 标题「删除技能「poc-skill-demo」?」+ ✕ + 「该技能将从你的实例移除,不可恢复;若它当前处于停用状态,保留的文件也会一并删除。」+ [取消][删除] |
| 弹窗确认「删除」 | DELETE /api/skills/mine/poc-skill-demo → 200;页面出现「✓ 已删除「poc-skill-demo」」;列表回到只剩共享技能行 |
截图:_tmp_t01/ui-myskills2.png(分组 + 展开的上传面板)、ui-uploaded.png、ui-del-dialog.png。
⚠️ 一处值得记的机制:用 agent-browser 的 click @ref 时,ref 会随每次 snapshot 重排;连续操作间必须重新 snapshot 取 ref,或用 eval 按按钮文本定位后再 click(本轮最终用后者才稳定跑通,前者出现过"点了没反应 / 弹窗消失"的假象 —— 不是产品缺陷)。
8.5 踩坑记录(全部是可复用判据,后续同类验收直接照用)
| # | 现象 | 根因 / 正确做法 |
|---|---|---|
| 1 | 抓实例页 HTML 只拿到 10.7 KB 的登录页 | 实例子域代理按会话判归属 ⇒ curl 必须带上门户 sid cookie(只有 launch token 不够)。正确姿势:303 → set-cookie: dsh-auth-* → 200,壳页 ≈58 KB |
| 2 | /plugins/??… 请求 404(2702 B) |
两点都要对:① 站点必须是实例子域(/plugins/ 是 dsh 自己的路由,打门户域只会得到门户 404);② 同样要带 sid(否则被当成"无租户"请求 → 假 404,与"fetch 不能设 Host"同族) |
| 3 | 新用户壳页 combo 里没有 @dsh-local/business-plugins |
新用户 profile 是首次进实例时才创建的 ⇒ ensure-biz-plugins 对它直接"无 profile → 跳过"(实测输出)。正确顺序:先 enter 起一次实例(生成 profile)→ 再铺 bundle → POST /api/dsh/restart(client bundle 只在实例启动时加载)→ 再 enter |
| 4 | 直改 DB 审批后登录失败 / CHECK 报错 | 角色取值是 active(约束 `admin |
| 5 | POST /api/dsh/restart 返回 400 |
restartSchema 要求 command 是必填 string(handoff 停写后恒传空串),必须发 {"command":""} + JSON 头 |
| 6 | verify-my-skills.mjs 首跑"R3 断言"假红 |
断言 exports.default 别用 includes() —— 本文件头部注释里就有这句话("绝不 exports.default")⇒ 只断言赋值形态 exports.default = |
8.6 回滚演练要点
装回 /opt/dsh/artifacts/business-plugins-0.3.20.tgz(版本号不同即可,pnpm 不会命中缓存)→
ensure-biz-plugins.cjs --all --restart → 磁盘层版本回落 0.3.20 即成功。服务端零改动、无需回滚。