feat(plugin): 实例内「我的技能」分组(business-plugins 0.3.20→0.3.21,T01 / 档案 100)

⚠️ 本提交是**另一执行会话**(台账记录的 T01 执行方)已完成、且**已上线**的源码侧改动,
   由本会话代为入库(台账 T01 已标「已完成并归档」;线上插件 0.3.21 已投放两实例)。

- 形态:并入既有「功能管理」section 内的**分组**,**不新开 section**(用户口径「不要分开管理」);
  **仅入口层合并、机制层独立** —— 技能是 watch 驱动(启停立即生效、无需重启),插件要重启实例,
  两条 API(/api/skills/mine* vs /api/plugins/mine*)与两套落盘位置一律不动。
- 行式交互:名称(截断三件套)+ 来源徽章(共享/我的)+ 状态 + 事实(文件数·体积,tabular-nums)+ 动作;
  沿用官方卡片行语汇(动作区 margin-left:auto + nowrap)⇒ **结构上不可能换行**。
- 平台共享技能(source:'shared'、locked)显示 🔒 且**不渲染任何动作按钮**,
  与后端 assertNotShared() 的 409 双向一致(前端不给注定失败的按钮,后端仍兜底)。
- 上传/删除:同名走**两阶段替换**(先暂存 → 页内确认,写明「不可恢复」+ 旧/新文件数对照);
  删除必须过页内确认;启用/停用可逆故不弹窗。失败一律行内展示后端中文原文(不自己造词)。
- zh/en 全量词条(msk.*,46 条/语言)。
- 新增 scripts/verify-my-skills.mjs(把「不换行 / 危险操作必须二次确认 / 锁定行无按钮」钉成机械断言),
  并接入根 package.json 的 npm run verify。

验证:node scripts/verify-my-skills.mjs ✓(本会话复核已跑通)
This commit is contained in:
admin committed 2026-09-15 18:54:00 +08:00
1 parent 5ad755116e
commit 39a1f2e31b
4 files changed
+868 -4

No files matched your search

+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env node
/**
* verify-my-skills.mjs —— 「我的技能」分组(`poc/business-plugins/lib/client.js`,档案 100)
* 的**结构性防线**:版式不换行 / 危险操作必须二次确认 / 锁定行不得渲染可点动作。
*
* 为什么需要(三个都真踩过或极易再犯):
* ① **换行是版式问题、不是逻辑问题**:v0.3.14 用户截图报「名称换行 + 按钮换行」,根因是自动列宽。
* 本分组沿用「卡片行 + 动作区 `margin-left:auto;white-space:nowrap`」,只要这三个声明任一被删,
* 缺陷就会静默复发 —— 构建与语法检查都不报错。
* ② **不可逆操作漏确认**:v0.3.15 同类 —— 「删除」紧挨「停用」且无确认,一次误点就永久删掉数据。
* 本分组的「替换」与「删除」都必须先弹页内确认;`removeSkill()` 只允许被弹窗的 `onOk` 调用。
* ③ **锁定行多出可点按钮**:平台共享技能后端会 409,前端若仍渲染「停用/删除」,
* 用户点下去只会拿到报错 —— 断言这些按钮只在 `s.locked` 为假的分支里。
*
* 用法:node scripts/verify-my-skills.mjs 退出码 0=全绿 / 1=有失败
*/
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const read = (p) => readFileSync(join(ROOT, p), 'utf8')
let failed = 0
const ok = (name, cond, extra = '') => {
console.log((cond ? ' ✓ ' : ' ✗ ') + name + (extra ? ' ' + extra : ''))
if (!cond) failed++
}
const src = read('poc/business-plugins/lib/client.js')
// ── ① 分组确实在(按内容不按注释:判据是"渲染代码里有这几样")──
ok('分组头标题走 msk.group', /t\("msk\.group"\)/.test(src))
ok('上传按钮存在(msk.upload)', /t\("msk\.upload"\)/.test(src))
ok('技能行容器类 bp-skillRow 被渲染', /className: "bp-skillRow"/.test(src))
ok('技能列表容器类 bp-skillList 被渲染', /className: "bp-skillList"/.test(src))
ok('拖拽投放区 bp-drop 被渲染', /className: "bp-drop"/.test(src))
// ── ② 三个后端端点 + 两阶段替换链路齐全(少一个就是功能缺一半)──
ok('GET/POST /api/skills/mine', /"\/api\/skills\/mine"/.test(src))
ok('POST /api/skills/mine/apply(第二阶段)', /"\/api\/skills\/mine\/apply"/.test(src))
ok('enable / disable 端点', /"\/enable"/.test(src) && /"\/disable"/.test(src))
ok('DELETE /api/skills/mine/:name', /method: "DELETE"/.test(src))
ok('处理 conflict 响应(不静默忽略同名)', /res\.d\.conflict/.test(src))
ok('提交 stagedId(替换确实走两阶段)', /stagedId: target\.stagedId/.test(src))
// ── ③ 危险操作必须二次确认 ──
// `removeSkill` 函数体的**唯一**调用点应当是 skillDialog 的 onOk。
{
const calls = [...src.matchAll(/onOk:\s*removeSkill/g)].length
ok('removeSkill 只被弹窗 onOk 引用一次', calls === 1, `-> ${calls} 处`)
ok('删除弹窗存在(if (delSkill) 渲染块)', /if \(delSkill\) \{/.test(src))
ok('删除弹窗讲清后果(msk.delBody)', /t\("msk\.delBody"\)/.test(src))
ok('替换弹窗存在(if (replaceTarget) 渲染块)', /if \(replaceTarget\) \{/.test(src))
ok('替换弹窗讲清"不可恢复"(msk.replaceWarn)', /t\("msk\.replaceWarn"\)/.test(src))
ok('替换弹窗给出旧/新文件数对照(msk.replaceFacts)', /t\("msk\.replaceFacts"\)/.test(src))
}
// ── ④ 锁定行(平台共享技能)不得渲染动作按钮 ──
// 代码形态:`s.locked ? null : jsxRuntime.jsxs("span", { key: "a", className: "bp-skillActions" …`
{
const gated = /s\.locked\s*\?\s*null\s*:\s*jsxRuntime\.jsxs\("span",\s*\{\s*\n?\s*key: "a",\s*\n?\s*className: "bp-skillActions"/.test(src)
ok('动作区被 s.locked 门控(锁定行不渲染按钮)', gated)
ok('锁定标记 + 说明(msk.locked / msk.lockedTip)', /t\("msk\.locked"\)/.test(src) && /t\("msk\.lockedTip"\)/.test(src))
}
// ── ⑤ 版式防线:不换行三件套(少任何一个,v0.3.14 的换行就会复发)──
const cssHas = (selector, decl) => new RegExp('\\.' + selector + '\\{[^}]*' + decl + '[^}]*\\}').test(src)
ok('.bp-skillMeta 有 white-space:nowrap', cssHas('bp-skillMeta', 'white-space:nowrap'))
ok('.bp-skillMeta 有 margin-left:auto(动作与事实右对齐)', cssHas('bp-skillMeta', 'margin-left:auto'))
ok('.bp-skillActions 有 white-space:nowrap', cssHas('bp-skillActions', 'white-space:nowrap'))
ok('.bp-skillName 有 white-space:nowrap', cssHas('bp-skillName', 'white-space:nowrap'))
ok('.bp-skillName 有 text-overflow:ellipsis', cssHas('bp-skillName', 'text-overflow:ellipsis'))
ok('.bp-skillName 有 overflow:hidden', cssHas('bp-skillName', 'overflow:hidden'))
ok('.bp-skillName 有 min-width:0(否则 flex 子项不会收缩)', cssHas('bp-skillName', 'min-width:0'))
ok('.bp-groupAct 有 margin-left:auto(上传按钮靠右)', cssHas('bp-groupAct', 'margin-left:auto'))
ok('.bp-skillFact 用 tabular-nums(数值不跳,规范 §5)', cssHas('bp-skillFact', 'tabular-nums'))
ok('.bp-skillRow 的 hover 反馈在(规范 §4.3)', /\.bp-skillRow:hover\{/.test(src))
ok('prefers-reduced-motion 覆盖了 .bp-skillRow', /prefers-reduced-motion[^}]*\{[^}]*\.bp-skillRow/.test(src))
// ── ⑥ 措辞防线:机制差异必须写在界面上(技能即时生效 vs 插件需重启)──
{
const zh = /"msk\.hint":\s*"([^"]+)"/.exec(src)
ok('zh 的 msk.hint 存在', zh !== null)
ok('zh 的 msk.hint 写明"无需重启"', zh !== null && zh[1].includes('无需重启'), zh ? '-> ' + zh[1] : '')
const en = /"msk\.hint":\s*"([^"]+)"/g
const all = [...src.matchAll(en)].map((m) => m[1])
ok('msk.hint 在 zh/en 各有一条(键数一致由 verify-models-dict 兜底)', all.length === 2, `-> ${all.length} 条`)
ok('en 的 msk.hint 写明 no instance restart', all.length === 2 && /no instance restart/i.test(all[1]))
}
// ── ⑦ 不得引用官方包/不得写 exports.default(R2 / R3 的机械兜底)──
// ⚠️ 只断言**赋值形态**:本文件头部注释里就写着「绝不 exports.default」这句话,
// 用 `includes()` 会被自己的注释判红(首跑实测踩到)。
ok('未出现 exports.default 赋值(R3)', /(?:^|\n)\s*(?:module\.)?exports\.default\s*=/.test(src) === false)
console.log(failed === 0 ? '\n全部通过(我的技能:结构 + 版式 + 危险操作防线)' : `\n${failed} 项失败`)
process.exit(failed === 0 ? 0 : 1)