#!/usr/bin/env node /** * verify-models-dict.mjs —— 「模型设置」页(`poc/business-plugins/lib/client.js` 的 ms.* 分区) * 的**词典完整性 + 版式防线**交叉校验(档案 91)。 * * 为什么需要(两个都真踩过): * ① **漏键是静默的**:2026-09-14 重写该页时把 `ms.needUrl` / `ms.needModels` 从词典里删掉、 * 却仍被 `submitCustom()` 引用 —— 页面只会在校验失败时把**键名原样**显示给用户, * 构建与语法检查**都不会报错**。zh/en 也容易只改一边。 * ② **换行是版式问题、不是逻辑问题**:用户截图报「内置 DeepSeek 换行 + 两个按钮换行」, * 根因是那 4 列表格用了**自动列宽**。改用官方卡片行后,只要 `rowName` / `rowActions` * 的 `white-space:nowrap` 与 `rowActions` 的 `margin-left:auto` 任一被删,缺陷就会复发 * —— 而肉眼很难在构建期发现。这里把它钉成断言。 * * 用法:node scripts/verify-models-dict.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') // ── ① 取出 zh / en 两个词典对象字面量(按花括号配平截取,不用 eval 全文件)── /** 从 `var = {` 起按花括号配平截出对象字面量文本。 */ function grabDict(name) { const at = src.indexOf('var ' + name + ' = {') if (at < 0) return null const start = src.indexOf('{', at) let depth = 0 for (let i = start; i < src.length; i++) { const c = src[i] if (c === '{') depth++ else if (c === '}') { depth-- if (depth === 0) return src.slice(start, i + 1) } } return null } const zhText = grabDict('zh') const enText = grabDict('en') ok('取到 zh 词典对象', zhText !== null) ok('取到 en 词典对象', enText !== null) if (zhText === null || enText === null) process.exit(1) /** 只取**顶层**键:按缩进深度 1 的行首 `"k":` 判定,避免把嵌套对象里的键也算进来。 */ function keysOf(text) { const keys = [] const dups = [] const seen = new Set() for (const m of text.matchAll(/^\t{3}"([^"]+)":/gm)) { if (seen.has(m[1])) dups.push(m[1]) seen.add(m[1]) keys.push(m[1]) } return { keys, dups } } const zh = keysOf(zhText) const en = keysOf(enText) ok('zh 无重复键(重复会让后者静默胜出)', zh.dups.length === 0, zh.dups.length ? '-> ' + zh.dups.join(', ') : '') ok('en 无重复键', en.dups.length === 0, en.dups.length ? '-> ' + en.dups.join(', ') : '') const zhSet = new Set(zh.keys) const enSet = new Set(en.keys) const onlyZh = zh.keys.filter((k) => !enSet.has(k)) const onlyEn = en.keys.filter((k) => !zhSet.has(k)) ok('zh / en 键数一致', zh.keys.length === en.keys.length, `-> zh ${zh.keys.length} / en ${en.keys.length}`) ok('zh ⊆ en', onlyZh.length === 0, onlyZh.length ? '-> 仅 zh 有: ' + onlyZh.join(', ') : '') ok('en ⊆ zh', onlyEn.length === 0, onlyEn.length ? '-> 仅 en 有: ' + onlyEn.join(', ') : '') // ── ② 代码里引用的键必须都声明了(本轮真漏过)── // ⚠️ 2026-09-14 扩到**全词典**(原来只查 `ms.*`):`pa.*` 那批一样会漏, // 而漏键在页面上只表现为"把键名原样显示给用户",构建与语法检查都不报错。 const used = new Set() for (const m of src.matchAll(/\bt\("([A-Za-z0-9_.]+)"\)/g)) used.add(m[1]) // MS_ERR 的值是把后端错误码映射成的词典键 { const block = /var MS_ERR = \{([\s\S]*?)\n\t{3}\};/.exec(src) if (block) for (const m of block[1].matchAll(/"(ms\.[A-Za-z0-9_.]+)"/g)) used.add(m[1]) } ok('词典引用点非空', used.size > 100, `-> 引用键 ${used.size} 个`) const missing = [...used].filter((k) => !zhSet.has(k)) ok('被引用的词典键全部已声明', missing.length === 0, missing.length ? '-> 缺失: ' + missing.join(', ') : '') // ── ③ 版式防线:卡片行的「不换行」三件套(少任何一个,用户报的换行就会复发)── const cssHas = (selector, decl) => { const re = new RegExp('\\.' + selector + '\\{[^}]*' + decl + '[^}]*\\}') return re.test(src) } ok('.ms-rowName 有 white-space:nowrap', cssHas('ms-rowName', 'white-space:nowrap')) ok('.ms-rowActions 有 white-space:nowrap', cssHas('ms-rowActions', 'white-space:nowrap')) ok('.ms-rowActions 有 margin-left:auto(动作右对齐,官方 rowActions 同)', cssHas('ms-rowActions', 'margin-left:auto')) ok('.ms-rowHead 是 flex 且 align-items:center', cssHas('ms-rowHead', 'align-items:center')) ok('.ms-addActions 可换行(两个虚线按钮窄屏时上下堆叠)', cssHas('ms-addActions', 'flex-wrap:wrap')) ok('.ms-addBtn 有 flex:1 1 0 + min-width:180px(官方 addButton 同)', cssHas('ms-addBtn', 'min-width:180px')) // ── ④ 新的新增流程确实在(两个虚线入口 + 已无 4 列表格)── ok('默认态有「添加提供方」入口', src.includes('ms.addProvider')) ok('默认态有「添加自定义提供方」入口', src.includes('ms.addCustom')) // 描述字符串很长,最容易混进未转义的 `"` 把 JSON 写坏(2026-09-14 实际踩过一次) { let pkgsOk = true for (const p of ['package.json', 'poc/business-plugins/package.json']) { try { JSON.parse(read(p)) } catch { pkgsOk = false } } ok('package.json 均为合法 JSON', pkgsOk) } ok('「我已添加的提供方」已从表格改为 ul.ms-rows', /className: "ms-rows"/.test(src)) { // 「模型设置」分区内不应再出现表格(其它页面仍可用 pa-tbl) const from = src.indexOf('function ModelSettingsSection()') const to = src.indexOf('ctx.slots.inject("settings.section"') const body = from >= 0 && to > from ? src.slice(from, to) : '' ok('「模型设置」分区内无 pa-tbl 残留', body.includes('pa-tbl') === false) } console.log(failed === 0 ? '\n全部通过(模型设置:词典一致性 + 版式防线)' : `\n${failed} 项失败`) process.exit(failed === 0 ? 0 : 1)