Files
dsh_shenxian/scripts/verify-my-skills.mjs
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

127 lines
8.8 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]))
}
// ── ⑧ 分区改名 + 页内 tab 分页 + 卡片三行(v0.3.22 · 2026-09-15 用户三项要求)──
// ① 「功能管理」→「能力管理」② 按 tab 切插件/技能各自操作 ③ 插件卡片中文用途描述 3 行
ok('zh 分区名已改为「能力管理」', /"section\.label": "能力管理"/.test(src))
ok('en 分区名已改为 Capability management', /"section\.label": "Capability management"/.test(src))
ok('旧名「功能管理」不再是任何 label', /"section\.label": "功能管理"/.test(src) === false)
ok('tab 标签词条存在(cap.tabPlugins)', /"cap\.tabPlugins": "功能插件"/.test(src) && /"cap\.tabPlugins": "Feature plugins"/.test(src))
ok('顶部有 tab 条(复用 .pa-tabs + role=tablist)', /className: "pa-tabs",\s*\n\s*role: "tablist"/.test(src))
ok('两个页签(插件 / 技能)都渲染', /t\("cap\.tabPlugins"\)/.test(src) && /t\("msk\.group"\)/.test(src))
ok('页签带计数(.pa-tcnt)', /className: "pa-tcnt"/.test(src))
ok('页签有 aria-selected(可访问性)', /"aria-selected": isPlugins \? "true" : "false"/.test(src))
ok('两个页签各自可切换', /setTab\("plugins"\)/.test(src) && /setTab\("skills"\)/.test(src))
ok('插件块被 isPlugins 门控', /if \(isPlugins\) \{/.test(src))
ok('技能块被 !isPlugins 门控', /if \(!isPlugins\) \{/.test(src))
{
// 三个区段(插件主体 / 技能主体 / 插件专属弹窗与提示 / 技能专属弹窗)都要有开有闭
const opens = (src.match(/if \(isPlugins\) \{/g) || []).length
const notOpens = (src.match(/if \(!isPlugins\) \{/g) || []).length
ok('门控数量成对(插件 2 处 / 技能 2 处)', opens === 2 && notOpens === 2, `-> isPlugins ${opens} / !isPlugins ${notOpens}`)
}
ok('插件卡片用途描述用 .bp-plugDesc', /className: "bp-plugDesc"/.test(src))
ok('.bp-plugDesc 有 -webkit-line-clamp:3', cssHas('bp-plugDesc', '-webkit-line-clamp:3'))
ok('.bp-plugDesc 有显式 white-space:normal(否则继承单行截断)', cssHas('bp-plugDesc', 'white-space:normal'))
ok('.bp-plugDesc 有 overflow:hidden', cssHas('bp-plugDesc', 'overflow:hidden'))
ok('卡片描述仍挂 title(hover 看全文)', /className: "bp-plugDesc",[\s\S]{0,200}title: primary/.test(src))
ok('.bp-tabHead 存在(技能页工具行:左说明 / 右上传)', cssHas('bp-tabHead', 'justify-content:space-between'))
// ── ⑦ 不得引用官方包/不得写 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)