**背景**:这些改动原属本工作区另外几个会话(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 提交边界:用户说"提交",未说"推送")。
127 lines
8.8 KiB
JavaScript
127 lines
8.8 KiB
JavaScript
#!/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)
|