Files
dsh_shenxian/scripts/verify-my-skills.mjs
T
admin 39a1f2e31b 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 ✓(本会话复核已跑通)
2026-09-15 18:54:00 +08:00

99 lines
6.5 KiB
JavaScript
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.
#!/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)