From 39a1f2e31ba24de245c371ab51f0fc279fc63e4f Mon Sep 17 00:00:00 2001 From: maogeigei Date: Tue, 15 Sep 2026 18:54:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugin):=20=E5=AE=9E=E4=BE=8B=E5=86=85?= =?UTF-8?q?=E3=80=8C=E6=88=91=E7=9A=84=E6=8A=80=E8=83=BD=E3=80=8D=E5=88=86?= =?UTF-8?q?=E7=BB=84=EF=BC=88business-plugins=200.3.20=E2=86=920.3.21?= =?UTF-8?q?=EF=BC=8CT01=20/=20=E6=A1=A3=E6=A1=88=20100=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ 本提交是**另一执行会话**(台账记录的 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 ✓(本会话复核已跑通) --- package.json | 2 +- poc/business-plugins/lib/client.js | 768 ++++++++++++++++++++++++++++- poc/business-plugins/package.json | 4 +- scripts/verify-my-skills.mjs | 98 ++++ 4 files changed, 868 insertions(+), 4 deletions(-) create mode 100644 scripts/verify-my-skills.mjs diff --git a/package.json b/package.json index 98c06aa..b4160f4 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "prepare": "npm run build", "dev": "node lib/cli.js", "typecheck": "tsc -p tsconfig.json --noEmit", - "verify": "npm run build && node --test test/db.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js && node scripts/verify-static.mjs && node scripts/verify-platform-admin-section.mjs && node scripts/verify-mem-model.mjs && node scripts/verify-model-landing.mjs && node scripts/verify-dsh-install.mjs && node scripts/verify-models-dict.mjs && node scripts/verify-models-render.cjs && node scripts/verify-dsh-compat.mjs", + "verify": "npm run build && node --test test/db.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js && node scripts/verify-static.mjs && node scripts/verify-platform-admin-section.mjs && node scripts/verify-mem-model.mjs && node scripts/verify-model-landing.mjs && node scripts/verify-dsh-install.mjs && node scripts/verify-models-dict.mjs && node scripts/verify-models-render.cjs && node scripts/verify-dsh-compat.mjs && node scripts/verify-my-skills.mjs", "test": "npm run build && node --test test/db.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js", "smoke": "node scripts/smoke.mjs", "smoke:admin": "node scripts/smoke-admin.mjs", diff --git a/poc/business-plugins/lib/client.js b/poc/business-plugins/lib/client.js index d071970..206d8bc 100644 --- a/poc/business-plugins/lib/client.js +++ b/poc/business-plugins/lib/client.js @@ -148,6 +148,57 @@ window.__ModuleLoader__.load({ /** 简体中文字典(key 集的事实来源)。 */ var zh = { "section.label": "功能管理", + + // ── 「偏好设置」section(档案 81 · R5:语言切换)──────────────── + // ⚠️ 语言 id 必须与官方 `dsh-client-locale` 的 `LOCALE_IDS` 一致:`en` / `zh`。 + // 官方 `FALLBACK_LOCALE = "en"` ⇒ **默认英语**(全球化口径,2026-09-15 用户定)。 + "pref.label": "偏好设置", + "pref.lang": "界面语言", + "pref.hint": "设置本实例界面的显示语言。默认英语;切换后立即生效。", + + // ── 「我的技能」分组(档案 100 · 2026-09-15)───────────────────── + // 键前缀 `msk.` = my skills。措辞三原则(档案 100 §2.6): + // ① 如实说生效方式(技能 vs 插件的差异要在界面上看得见) + // ② 如实说后果(替换/删除「不可恢复」)③ 不暴露内部标识(不说路径 / skills-library / rank) + "msk.group": "我的技能", + "msk.hint": "上传你自己的技能包(.zip)。启用 / 停用立即生效,无需重启实例。", + "msk.upload": "+ 上传技能", + "msk.collapse": "收起", + "msk.dropTitle": "把 .zip 拖到这里,或", + "msk.pick": "选择文件", + "msk.dropSub": "技能包内需有 SKILL.md:name 用「小写字母与连字符」,且必须写 description", + "msk.submit": "上传", + "msk.uploading": "上传中…", + "msk.cancel": "取消", + "msk.onlyZip": "仅支持 .zip 文件", + "msk.tooBig": "文件过大(超过 150MB)", + "msk.empty": "还没有个人技能 —— 上传一个 .zip 技能包即可使用", + "msk.sourceShared": "共享", + "msk.sourceMine": "我的", + "msk.locked": "只读", + "msk.lockedTip": "平台共享技能:全员只读,不可停用 / 删除 / 覆盖", + "msk.on": "已启用", + "msk.off": "已停用", + "msk.enable": "启用", + "msk.disable": "停用", + "msk.del": "删除", + "msk.fact": "{files} 个文件 · {size}", + "msk.count": "{n} 个技能", + "msk.loadFailed": "技能列表加载失败", + "msk.reqFailed": "请求失败", + "msk.uploaded": "✓ 已上传技能「{name}」", + "msk.enabled": "✓ 已启用「{name}」", + "msk.disabled": "✓ 已停用「{name}」(文件保留,可再次启用)", + "msk.deleted": "✓ 已删除「{name}」", + "msk.replaceTitle": "检测到同名技能「{name}」", + "msk.replaceBody": "确认整体替换这个技能?", + "msk.replaceWarn": "替换将删除旧技能的全部文件 —— 新包中已移除的文件也会一并删除,不可恢复。", + "msk.replaceFacts": "旧:{oldFiles} 个文件 · {oldSize} → 新:{newFiles} 个文件", + "msk.replaceOk": "确认替换", + "msk.replaceDone": "✓ 已整体替换技能「{name}」", + "msk.delTitle": "删除技能「{name}」?", + "msk.delBody": "该技能将从你的实例移除,不可恢复;若它当前处于停用状态,保留的文件也会一并删除。", + "msk.delOk": "删除", "loading": "加载中…", "loadingDesc": "正在从门户读取候选池…", "emptyTitle": "暂无功能插件", @@ -493,6 +544,57 @@ window.__ModuleLoader__.load({ /** English dictionary, key-set complete against zh. */ var en = { "section.label": "Feature management", + + // ── 「Preferences」section (spec 81 · R5: language switch) ────── + // ⚠️ Language ids must match the official `dsh-client-locale` `LOCALE_IDS`: `en` / `zh`. + // The official `FALLBACK_LOCALE = "en"` ⇒ **English by default** (global-product rule, 2026-09-15). + "pref.label": "Preferences", + "pref.lang": "Interface language", + "pref.hint": "Choose the display language for this instance. English is the default; the change applies immediately.", + + // ── "My skills" group (spec 100 · 2026-09-15) ────────────────── + // Key prefix `msk.` = my skills. Same three wording rules as the zh side: + // ① state the activation model truthfully (skills vs plugins differ) + // ② state consequences ("cannot be undone") ③ no internal identifiers. + "msk.group": "My skills", + "msk.hint": "Upload your own skill packages (.zip). Enabling / disabling takes effect immediately — no instance restart.", + "msk.upload": "+ Upload skill", + "msk.collapse": "Close", + "msk.dropTitle": "Drop a .zip here, or", + "msk.pick": "Choose file", + "msk.dropSub": "The package must contain SKILL.md: `name` in lowercase-with-hyphens, plus a `description`", + "msk.submit": "Upload", + "msk.uploading": "Uploading…", + "msk.cancel": "Cancel", + "msk.onlyZip": "Only .zip files are supported", + "msk.tooBig": "File is too large (over 150MB)", + "msk.empty": "No personal skills yet — upload a .zip skill package to get started", + "msk.sourceShared": "Shared", + "msk.sourceMine": "Mine", + "msk.locked": "Read-only", + "msk.lockedTip": "Platform shared skill: read-only for everyone; cannot be disabled / deleted / overwritten", + "msk.on": "Enabled", + "msk.off": "Disabled", + "msk.enable": "Enable", + "msk.disable": "Disable", + "msk.del": "Delete", + "msk.fact": "{files} files · {size}", + "msk.count": "{n} skills", + "msk.loadFailed": "Failed to load the skill list", + "msk.reqFailed": "Request failed", + "msk.uploaded": "✓ Uploaded skill \"{name}\"", + "msk.enabled": "✓ Enabled \"{name}\"", + "msk.disabled": "✓ Disabled \"{name}\" (files kept; you can enable it again)", + "msk.deleted": "✓ Deleted \"{name}\"", + "msk.replaceTitle": "A skill named \"{name}\" already exists", + "msk.replaceBody": "Replace it entirely?", + "msk.replaceWarn": "Replacing deletes every file of the old skill — files missing from the new package are removed too. This cannot be undone.", + "msk.replaceFacts": "Old: {oldFiles} files · {oldSize} → New: {newFiles} files", + "msk.replaceOk": "Replace", + "msk.replaceDone": "✓ Replaced skill \"{name}\"", + "msk.delTitle": "Delete skill \"{name}\"?", + "msk.delBody": "This removes the skill from your instance and cannot be undone. If it is currently disabled, the files kept for it are deleted as well.", + "msk.delOk": "Delete", "loading": "Loading…", "loadingDesc": "Reading the candidate pool from the portal…", "emptyTitle": "No feature plugins yet", @@ -1038,7 +1140,37 @@ window.__ModuleLoader__.load({ ".ms-chips{display:flex;flex-wrap:wrap;gap:6px;margin:0;padding:0;list-style:none}", ".ms-chip{border:.5px solid var(--dsw-alias-border-l2,#e3e6ea);border-radius:6px;padding:2px 7px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#6b7280)}", - "@media (prefers-reduced-motion: reduce){.pa-card,.pa-modal{animation:none;transition:none}.pa-card:hover{transform:none}.ms-summary::before{transition:none}}" + // ── 「我的技能」分组(档案 100 · 2026-09-15)───────────────────── + // 与「功能插件」同一套卡片行语汇:动作区 `margin-left:auto` + `nowrap`(v0.3.14 + // 的换行缺陷就是缺了这个),名称走「长文本截断三件套」,数值列 `tabular-nums`。 + // 内联 style 表达不了 `:hover`,故 hover / 拖拽高亮落在 CSS 里(同既有做法)。 + ".bp-groupHead{display:flex;align-items:center;gap:8px;margin:22px 0 12px;padding-top:18px;border-top:1px solid var(--dsw-alias-border-l2,#e3e6ea)}", + ".bp-groupTitle{display:flex;align-items:center;gap:8px;margin:0;font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary,#24292f)}", + ".bp-groupTitle::before{content:'';width:3px;height:14px;border-radius:2px;background:var(--dsw-alias-brand-primary,#2f6fed);flex-shrink:0}", + ".bp-groupCnt{font-size:13px;font-weight:500;color:var(--dsw-alias-label-secondary,#4e5969);background:var(--dsw-alias-bg-layer-3,#f5f6f8);border-radius:10px;padding:2px 8px;font-variant-numeric:tabular-nums}", + ".bp-groupAct{margin-left:auto;flex-shrink:0}", + ".bp-skillList{display:flex;flex-direction:column;gap:8px;margin:12px 0 0;padding:0;list-style:none}", + ".bp-skillRow{display:flex;align-items:center;gap:10px;min-width:0;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2,#e3e6ea);border-radius:12px;background:var(--dsw-alias-bg-layer-1,#fff);transition:background .18s cubic-bezier(.16,1,.3,1),border-color .18s cubic-bezier(.16,1,.3,1)}", + ".bp-skillRow:hover{background:var(--dsw-alias-bg-layer-3,#f5f6f8)}", + ".bp-skillName{min-width:0;font-size:14px;font-weight:500;color:var(--dsw-alias-label-primary,#24292f);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}", + ".bp-skillMeta{display:flex;align-items:center;gap:8px;flex-shrink:0;margin-left:auto;white-space:nowrap}", + ".bp-skillActions{display:flex;align-items:center;gap:6px;flex-shrink:0;white-space:nowrap}", + ".bp-badge{display:inline-flex;align-items:center;gap:4px;flex-shrink:0;padding:2px 8px;border-radius:10px;font-size:13px;line-height:18px}", + ".bp-badge.shared{background:rgba(47,111,237,.12);color:var(--dsw-alias-brand-primary,#2f6fed)}", + ".bp-badge.mine{background:var(--dsw-alias-bg-layer-3,#f5f6f8);color:var(--dsw-alias-label-secondary,#4e5969)}", + ".bp-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}", + ".bp-skillFact{font-size:13px;color:var(--dsw-alias-label-tertiary,#6b7280);font-variant-numeric:tabular-nums}", + ".bp-drop{display:flex;flex-direction:column;align-items:center;gap:6px;padding:18px;text-align:center;border:1px dashed var(--dsw-alias-border-l2,#e3e6ea);border-radius:10px;background:var(--dsw-alias-bg-layer-3,#f5f6f8)}", + ".bp-drop.over{border-color:var(--dsw-alias-brand-primary,#2f6fed);background:rgba(47,111,237,.06)}", + ".bp-dropTitle{display:flex;align-items:center;justify-content:center;gap:6px;flex-wrap:wrap;font-size:14px;font-weight:500;color:var(--dsw-alias-label-primary,#24292f)}", + ".bp-pick{display:inline-block;padding:5px 12px;border:1px solid var(--dsw-alias-brand-primary,#2f6fed);border-radius:8px;color:var(--dsw-alias-brand-primary,#2f6fed);font-size:13px;cursor:pointer}", + ".bp-pick:hover{background:rgba(47,111,237,.07)}", + ".bp-pick input[type=file]{display:none}", + ".bp-fileRow{display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;font-size:13px;color:var(--dsw-alias-label-secondary,#4e5969)}", + ".bp-err{font-size:13px;line-height:1.6;color:var(--dsw-alias-state-error-primary,#d93026)}", + ".bp-ok{font-size:13px;line-height:1.6;color:var(--dsw-alias-state-success-primary,#1a7f37)}", + + "@media (prefers-reduced-motion: reduce){.pa-card,.pa-modal,.bp-skillRow{animation:none;transition:none}.pa-card:hover{transform:none}.ms-summary::before{transition:none}}" ].join(""); document.head.appendChild(el); return function () { el.remove(); }; @@ -1103,6 +1235,256 @@ window.__ModuleLoader__.load({ } useEffect(function () { load(); }, []); + // ══ 「我的技能」状态(档案 100)═════════════════════════════════════════ + // **刻意与插件那套 state 分开**:两者接口不同、生效方式也不同(技能是 watch + // 驱动 ⇒ 启停立即生效;插件要重启实例)。共用的只有"页面位置"和视觉语汇, + // 不共用状态机与提交路径 —— 这正是「入口层合并、机制层分离」的落地形态。 + var skillsState = useState([]); + var skills = skillsState[0]; + var setSkills = skillsState[1]; + var skLoadingState = useState(true); + var skLoading = skLoadingState[0]; + var setSkLoading = skLoadingState[1]; + /** 正在操作的那一行(技能名);"" = 空闲。**只禁用该行**,不冻整页。 */ + var skBusyState = useState(""); + var skBusy = skBusyState[0]; + var setSkBusy = skBusyState[1]; + var skMsgState = useState(""); + var skMsg = skMsgState[0]; + var setSkMsg = skMsgState[1]; + var skErrState = useState(""); + var skErr = skErrState[0]; + var setSkErr = skErrState[1]; + var upOpenState = useState(false); + var upOpen = upOpenState[0]; + var setUpOpen = upOpenState[1]; + var upFileState = useState(null); + var upFile = upFileState[0]; + var setUpFile = upFileState[1]; + var upBusyState = useState(false); + var upBusy = upBusyState[0]; + var setUpBusy = upBusyState[1]; + var upErrState = useState(""); + var upErr = upErrState[0]; + var setUpErr = upErrState[1]; + var dropOverState = useState(false); + var dropOver = dropOverState[0]; + var setDropOver = dropOverState[1]; + /** 同名替换弹窗(第一阶段返回的 conflict 对象;null = 不显示)。 */ + var replState = useState(null); + var replaceTarget = replState[0]; + var setReplaceTarget = replState[1]; + /** 删除弹窗的目标技能(null = 不显示)。 */ + var delSkState = useState(null); + var delSkill = delSkState[0]; + var setDelSkill = delSkState[1]; + + function loadSkills() { + setSkLoading(true); + fetch(portalHost() + "/api/skills/mine", { credentials: "include" }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { setSkills((d && d.skills) || []); setSkLoading(false); }) + .catch(function () { setSkLoading(false); setSkErr(t("msk.loadFailed")); }); + } + useEffect(function () { loadSkills(); }, []); + + /** + * 后端错误文案**本身就是写给用户的**(如「「x」是平台共享技能(全员只读…)—— 请改用其他技能名」) + * ⇒ 直接展示,不自己造词、不把状态码裸露给用户。认不出时退回通用失败文案。 + */ + function skErrOf(d) { return (d && d.error) || t("msk.reqFailed"); } + + /** 选文件(点击与拖入共用):**客户端先校一遍**,省掉一次注定失败的往返。 */ + function pickFile(file) { + setUpErr(""); + setSkMsg(""); + if (!file) return; + if (!/\.zip$/i.test(String(file.name || ""))) { setUpErr(t("msk.onlyZip")); return; } + if (file.size > 150 * 1024 * 1024) { setUpErr(t("msk.tooBig")); return; } + setUpFile(file); + } + + /** 上传 = 两阶段的第一阶段。无同名 → 直接装好;有同名 → 只开弹窗,**此时不动旧技能**。 */ + function submitUpload() { + if (!upFile) return; + setUpBusy(true); + setUpErr(""); + setSkMsg(""); + pareadAsBase64(upFile) + .then(function (base64) { + return fetch(portalHost() + "/api/skills/mine", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ file: base64, filename: upFile.name }) + }); + }) + .then(function (r) { + return r.json().catch(function () { return {}; }).then(function (d) { return { ok: r.ok, d: d }; }); + }) + .then(function (res) { + setUpBusy(false); + if (!res.ok) { setUpErr(skErrOf(res.d)); return; } + if (res.d.conflict) { setReplaceTarget(res.d); return; } + setUpFile(null); + setSkMsg(t("msk.uploaded").replace("{name}", res.d.skill.name)); + loadSkills(); + }) + .catch(function () { setUpBusy(false); setUpErr(t("msk.reqFailed")); }); + } + + /** 替换 = 第二阶段:用户点「确认替换」后才提交 stagedId(后端会**整删旧目录**)。 */ + function applyReplace() { + var target = replaceTarget; + if (!target) return; + setSkBusy(target.skill.name); + fetch(portalHost() + "/api/skills/mine/apply", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ stagedId: target.stagedId }) + }) + .then(function (r) { + return r.json().catch(function () { return {}; }).then(function (d) { return { ok: r.ok, d: d }; }); + }) + .then(function (res) { + setSkBusy(""); + setReplaceTarget(null); + if (!res.ok) { setUpErr(skErrOf(res.d)); return; } + setUpFile(null); + setSkMsg(t("msk.replaceDone").replace("{name}", res.d.skill.name)); + loadSkills(); + }) + .catch(function () { setSkBusy(""); setReplaceTarget(null); setUpErr(t("msk.reqFailed")); }); + } + + /** 启用 / 停用:**可逆**且即时生效 ⇒ 不弹确认(与删除的处置相反)。 */ + function toggleSkill(s) { + setSkBusy(s.name); + setSkErr(""); + setSkMsg(""); + fetch( + portalHost() + "/api/skills/mine/" + encodeURIComponent(s.name) + (s.enabled ? "/disable" : "/enable"), + { method: "POST", credentials: "include" } + ) + .then(function (r) { + return r.json().catch(function () { return {}; }).then(function (d) { return { ok: r.ok, d: d }; }); + }) + .then(function (res) { + setSkBusy(""); + if (!res.ok) { setSkErr(skErrOf(res.d)); return; } + setSkMsg(t(s.enabled ? "msk.disabled" : "msk.enabled").replace("{name}", s.name)); + loadSkills(); + }) + .catch(function () { setSkBusy(""); setSkErr(t("msk.reqFailed")); }); + } + + /** 删除:不可逆 ⇒ 必须走页内确认弹窗(见下方 `delSkill` 渲染块)。 */ + function removeSkill() { + var s = delSkill; + if (!s) return; + setSkBusy(s.name); + fetch(portalHost() + "/api/skills/mine/" + encodeURIComponent(s.name), { method: "DELETE", credentials: "include" }) + .then(function (r) { + return r.json().catch(function () { return {}; }).then(function (d) { return { ok: r.ok, d: d }; }); + }) + .then(function (res) { + setSkBusy(""); + setDelSkill(null); + if (!res.ok) { setSkErr(skErrOf(res.d)); return; } + setSkMsg(t("msk.deleted").replace("{name}", s.name)); + loadSkills(); + }) + .catch(function () { setSkBusy(""); setDelSkill(null); setSkErr(t("msk.reqFailed")); }); + } + + // 行内按钮(规范 §4.1:次要动作白底 + border;删除走 danger 边 + danger 文字) + var skRowBtn = { + padding: "3px 10px", borderRadius: "6px", cursor: "pointer", + border: "1px solid " + T.border, background: "transparent", color: T.sub, fontSize: "13px" + }; + var skDelBtn = Object.assign({}, skRowBtn, { border: "1px solid " + T.danger, color: T.danger }); + + /** + * 页内确认弹窗 —— **结构与既有 `__confirm` 一致**(遮罩 + 面板 + ✕ + 点遮罩关闭 + + * `stopPropagation`),只把标题/正文/按钮参数化,避免第三、第四个弹窗各写 60 行。 + */ + function skillDialog(opts) { + return jsxRuntime.jsx("div", { + key: opts.dkey, + style: { + position: "fixed", inset: 0, background: "rgba(0,0,0,.35)", zIndex: 2000, + display: "flex", alignItems: "center", justifyContent: "center", padding: "24px" + }, + onClick: opts.onClose, + children: jsxRuntime.jsxs("div", { + style: { + background: "var(--dsw-alias-bg-layer-1, #ffffff)", + borderRadius: "12px", maxWidth: "480px", width: "100%", maxHeight: "82vh", + overflow: "auto", padding: "18px 22px", + boxShadow: "0 8px 30px rgba(0,0,0,.18)", + display: "flex", flexDirection: "column", gap: "10px" + }, + onClick: function (e) { e.stopPropagation(); }, + children: [ + jsxRuntime.jsxs("div", { + key: "t", + style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }, + children: [ + jsxRuntime.jsx("span", { + key: "s", + style: { fontSize: "15px", fontWeight: 500, color: T.text }, + children: opts.title + }), + jsxRuntime.jsx("button", { + key: "x", + type: "button", + title: t("msk.cancel"), + onClick: opts.onClose, + style: { + border: "none", background: "none", fontSize: "18px", lineHeight: 1, + cursor: "pointer", color: T.dim, padding: "2px 4px" + }, + children: "✕" + }) + ] + }), + opts.body, + jsxRuntime.jsxs("div", { + key: "f", + style: { + display: "flex", justifyContent: "flex-end", alignItems: "center", gap: "8px", + paddingTop: "10px", borderTop: "1px solid " + T.border + }, + children: [ + jsxRuntime.jsx("button", { + key: "c", + type: "button", + className: "bp-btn", + onClick: opts.onClose, + style: ghostBtnStyle, + children: t("msk.cancel") + }), + jsxRuntime.jsx("button", { + key: "k", + type: "button", + onClick: opts.onOk, + disabled: opts.busy, + style: { + padding: "7px 16px", borderRadius: "8px", + cursor: opts.busy ? "default" : "pointer", + border: "1px solid " + T.danger, background: T.danger, color: "#ffffff", + fontSize: "14px", opacity: opts.busy ? 0.5 : 1 + }, + children: opts.ok + }) + ] + }) + ] + }) + }); + } + function toggle(id) { setPlugins(plugins.map(function (p) { if (p.id === id) return Object.assign({}, p, { enabled: !p.enabled }); @@ -1557,6 +1939,248 @@ window.__ModuleLoader__.load({ ] })); + // ══ 「我的技能」分组(档案 100)═════════════════════════════════════════ + // 与「功能插件」**同页**呈现(用户口径「不要分开管理」=入口层合并),但机制各自 + // 独立:技能是 watch 驱动 ⇒ 启用/停用**立即生效、无需重启**,行内文案如实说明; + // 平台共享技能(`locked:true`)**不渲染任何动作按钮** —— 与后端 `assertNotShared()` + // 的 409 双向一致:前端不给出注定失败的按钮,后端仍然兜底。 + var skillRows = []; + if (skLoading) { + skillRows.push(jsxRuntime.jsx("div", { + key: "__skload", + style: { padding: "16px 0", textAlign: "center", color: T.dim, fontSize: "13px" }, + children: t("loading") + })); + } else if (skills.length === 0) { + skillRows.push(jsxRuntime.jsx("div", { + key: "__skempty", + style: { padding: "20px 0", textAlign: "center", color: T.dim, fontSize: "13px" }, + children: t("msk.empty") + })); + } else { + for (var k = 0; k < skills.length; k++) { + (function (s) { + var rowBusy = skBusy === s.name; + skillRows.push(jsxRuntime.jsxs("li", { + key: s.name, + className: "bp-skillRow", + children: [ + // 左:技能名(长文本截断三件套 + title,规范 §5) + jsxRuntime.jsx("span", { + key: "n", + className: "bp-skillName", + title: s.name, + children: s.name + }), + // 右:来源 + 状态 + 事实 + 动作(整块 `margin-left:auto`,全部 nowrap) + jsxRuntime.jsxs("span", { + key: "m", + className: "bp-skillMeta", + children: [ + jsxRuntime.jsx("span", { + key: "src", + className: "bp-badge " + (s.locked ? "shared" : "mine"), + children: s.locked ? t("msk.sourceShared") : t("msk.sourceMine") + }), + s.locked + ? jsxRuntime.jsx("span", { + key: "lk", + className: "bp-badge mine", + title: t("msk.lockedTip"), + children: "🔒 " + t("msk.locked") + }) + : null, + jsxRuntime.jsxs("span", { + key: "st", + style: { + display: "inline-flex", alignItems: "center", gap: "5px", + fontSize: "13px", color: s.enabled ? T.success : T.dim + }, + children: [ + jsxRuntime.jsx("span", { + key: "d", + className: "bp-dot", + style: { background: s.enabled ? T.success : T.dim } + }), + s.enabled ? t("msk.on") : t("msk.off") + ] + }), + jsxRuntime.jsx("span", { + key: "f", + className: "bp-skillFact", + children: t("msk.fact") + .replace("{files}", String(s.files)) + .replace("{size}", pafmtSize(s.size)) + }), + s.locked + ? null + : jsxRuntime.jsxs("span", { + key: "a", + className: "bp-skillActions", + children: [ + jsxRuntime.jsx("button", { + key: "tg", + type: "button", + className: "bp-btn", + disabled: rowBusy, + onClick: function () { toggleSkill(s); }, + style: Object.assign({}, skRowBtn, { opacity: rowBusy ? 0.5 : 1 }), + children: s.enabled ? t("msk.disable") : t("msk.enable") + }), + jsxRuntime.jsx("button", { + key: "dl", + type: "button", + className: "bp-btn", + disabled: rowBusy, + onClick: function () { setSkErr(""); setDelSkill(s); }, + style: Object.assign({}, skDelBtn, { opacity: rowBusy ? 0.5 : 1 }), + children: t("msk.del") + }) + ] + }) + ] + }) + ] + }, s.name)); + })(skills[k]); + } + } + + rows.push(jsxRuntime.jsxs("div", { + key: "__skills", + children: [ + // 分组头:竖条标题 + 计数 + 右侧主按钮(形态与「功能插件」分组头同源) + jsxRuntime.jsxs("div", { + key: "head", + className: "bp-groupHead", + children: [ + jsxRuntime.jsx("h4", { key: "t", className: "bp-groupTitle", children: t("msk.group") }), + jsxRuntime.jsx("span", { + key: "c", + className: "bp-groupCnt", + children: t("msk.count").replace("{n}", String(skills.length)) + }), + jsxRuntime.jsx("span", { + key: "act", + className: "bp-groupAct", + children: jsxRuntime.jsx("button", { + type: "button", + className: "bp-btn", + "aria-expanded": upOpen ? "true" : "false", + onClick: function () { setUpOpen(!upOpen); setUpErr(""); }, + style: { + padding: "5px 12px", borderRadius: "8px", cursor: "pointer", + border: "1px solid " + T.primary, background: "transparent", + color: T.primary, fontSize: "13px" + }, + children: upOpen ? t("msk.collapse") : t("msk.upload") + }) + }) + ] + }), + // 说明行(如实说生效方式 —— 与「功能插件」需重启形成对照) + jsxRuntime.jsx("p", { + key: "hint", + style: { margin: "0 0 4px", fontSize: "12px", lineHeight: "18px", color: T.dim }, + children: t("msk.hint") + }), + // 上传面板(默认收起;支持点击选文件与拖入) + upOpen + ? jsxRuntime.jsxs("div", { + key: "up", + className: "bp-drop" + (dropOver ? " over" : ""), + onDragOver: function (e) { e.preventDefault(); if (!dropOver) setDropOver(true); }, + onDragLeave: function () { setDropOver(false); }, + onDrop: function (e) { + e.preventDefault(); + setDropOver(false); + var dt = e.dataTransfer; + pickFile(dt && dt.files && dt.files[0]); + }, + children: [ + jsxRuntime.jsxs("div", { + key: "t", + className: "bp-dropTitle", + children: [ + jsxRuntime.jsx("span", { key: "s", children: t("msk.dropTitle") }), + // label 包着隐藏 input ⇒ 点「选择文件」即开系统文件框(无需 ref) + jsxRuntime.jsxs("label", { + key: "l", + className: "bp-pick", + children: [ + t("msk.pick"), + jsxRuntime.jsx("input", { + key: "i", + type: "file", + accept: ".zip", + "aria-label": t("msk.pick"), + onChange: function (e) { + pickFile(e.target.files && e.target.files[0]); + e.target.value = ""; + } + }) + ] + }) + ] + }), + jsxRuntime.jsx("div", { + key: "sub", + style: { fontSize: "12px", lineHeight: "18px", color: T.dim }, + children: t("msk.dropSub") + }), + upErr + ? jsxRuntime.jsx("div", { key: "e", className: "bp-err", children: upErr }) + : null, + upFile + ? jsxRuntime.jsxs("div", { + key: "f", + className: "bp-fileRow", + children: [ + jsxRuntime.jsx("span", { + key: "n", + style: { fontWeight: 500, color: T.text }, + children: upFile.name + }), + jsxRuntime.jsx("span", { + key: "z", + style: { fontVariantNumeric: "tabular-nums" }, + children: pafmtSize(upFile.size) + }), + jsxRuntime.jsx("button", { + key: "up", + type: "button", + onClick: submitUpload, + disabled: upBusy, + style: { + padding: "5px 12px", borderRadius: "8px", border: "none", + cursor: upBusy ? "default" : "pointer", + background: T.primary, color: "#ffffff", fontSize: "13px", + opacity: upBusy ? 0.6 : 1 + }, + children: upBusy ? t("msk.uploading") : t("msk.submit") + }), + jsxRuntime.jsx("button", { + key: "c", + type: "button", + className: "bp-btn", + disabled: upBusy, + onClick: function () { setUpFile(null); setUpErr(""); }, + style: ghostBtnStyle, + children: t("msk.cancel") + }) + ] + }) + : null + ] + }) + : null, + // 行内结果(成功绿字 / 失败红字 —— 不弹窗,避免打断) + skMsg ? jsxRuntime.jsx("div", { key: "ok", className: "bp-ok", children: skMsg }) : null, + skErr ? jsxRuntime.jsx("div", { key: "err", className: "bp-err", children: skErr }) : null, + jsxRuntime.jsx("ul", { key: "list", className: "bp-skillList", children: skillRows }) + ] + })); + if (rejected.length > 0) { // impeccable craft-floor 明令禁止「卡片/列表项/提示上 >1px 的彩色 border-left/right」 // —— 改为整块浅底 + 圆角,危险语义由标题文字色承担(不靠侧边色条)。 @@ -1698,6 +2322,67 @@ window.__ModuleLoader__.load({ }) })); } + // ── 「我的技能」的两个页内确认弹窗(档案 100 §2.5)────────────────────── + // 共同点:**只点名 + 讲清后果**。替换额外给出「旧 vs 新」的事实行(tabular-nums), + // 因为"删掉几个文件"是用户真正关心的量;删除与替换同样不可逆,故同样必须过弹窗。 + if (replaceTarget) { + rows.push(skillDialog({ + dkey: "__repl", + title: t("msk.replaceTitle").replace("{name}", replaceTarget.skill.name), + body: jsxRuntime.jsxs("div", { + style: { display: "flex", flexDirection: "column", gap: "8px" }, + children: [ + jsxRuntime.jsx("div", { + key: "b", + style: { fontSize: "13px", lineHeight: "1.6", color: T.sub }, + children: t("msk.replaceBody") + }), + jsxRuntime.jsxs("div", { + key: "w", + style: { + padding: "10px 12px", borderRadius: "10px", + background: "rgba(217,48,38,.06)", border: "1px solid " + T.danger, + display: "flex", flexDirection: "column", gap: "4px" + }, + children: [ + jsxRuntime.jsx("div", { + key: "h", + style: { fontSize: "13px", fontWeight: 500, color: T.danger, lineHeight: "1.6" }, + children: "⚠ " + t("msk.replaceWarn") + }), + jsxRuntime.jsx("div", { + key: "f", + style: { fontSize: "13px", color: T.sub, fontVariantNumeric: "tabular-nums" }, + children: t("msk.replaceFacts") + .replace("{oldFiles}", String(replaceTarget.existing.files)) + .replace("{oldSize}", pafmtSize(replaceTarget.existing.size)) + .replace("{newFiles}", String(replaceTarget.skill.files)) + }) + ] + }) + ] + }), + ok: t("msk.replaceOk"), + busy: skBusy === replaceTarget.skill.name, + onOk: applyReplace, + onClose: function () { setReplaceTarget(null); } + })); + } + if (delSkill) { + rows.push(skillDialog({ + dkey: "__delsk", + title: t("msk.delTitle").replace("{name}", delSkill.name), + body: jsxRuntime.jsx("div", { + style: { fontSize: "13px", lineHeight: "1.6", color: T.sub }, + children: t("msk.delBody") + }), + ok: t("msk.delOk"), + busy: skBusy === delSkill.name, + onOk: removeSkill, + onClose: function () { setDelSkill(null); } + })); + } + return jsxRuntime.jsx("div", { style: wrap, children: rows }); } @@ -2215,6 +2900,87 @@ window.__ModuleLoader__.load({ ] }); } + /** + * 「偏好设置」section(**用户可见**)—— 档案 81 · R5:**语言切换**。 + * + * 走官方扩展点(**零官方改动**):`ctx.locale.getLocale()` 读当前语言、`ctx.locale.setLocale(id)` 切换; + * 语言目录 = 官方随包发布的 `LOCALE_IDS`(`en` / `zh`)⇒ **不另造词条运行时**。 + * + * ⚠️ **默认英语**:官方 `FALLBACK_LOCALE = "en"`(全球化项目口径,用户 2026-09-15 定)。 + * 切换后的持久化由**官方 locale** 写入宿主 `settings.yaml` 的 `locale.preference` —— 本插件不碰文件。 + * ⚠️ 切换后只 bump 本 section 的本地 state(本栏立即用新语言重渲染);官方 UI 与其它 section + * 由各自的订阅重渲染,无需我们干预。 + */ + function PreferencesSection() { + var useState = React.useState; + var tickState = useState(0); + var setTick = tickState[1]; + var LANGS = [{ id: "en", name: "English" }, { id: "zh", name: "中文" }]; + function currentLocale() { + try { + if (ctx.locale && typeof ctx.locale.getLocale === "function") { + var id = ctx.locale.getLocale(); + if (id) return String(id).split("-")[0]; + } + } catch (err) { /* 官方 API 若变动,本栏降级为只读默认值,不至于整栏崩掉 */ } + return "en"; + } + var cur = currentLocale(); + function pick(id) { + if (id === cur) return; + try { + if (ctx.locale && typeof ctx.locale.setLocale === "function") ctx.locale.setLocale(id); + else console.warn("[business-plugins] ctx.locale.setLocale 不可用,语言未切换"); + } catch (err) { + console.warn("[business-plugins] setLocale 失败:", err); + } + setTick(function (n) { return n + 1; }); + } + return jsxRuntime.jsx("div", { + className: "bp-pref", + children: [ + jsxRuntime.jsx("p", { + className: "ms-hint", + style: { marginBottom: 12 }, + children: t("pref.hint") + }, "hint"), + jsxRuntime.jsx("div", { + style: { display: "flex", alignItems: "center", gap: 10 }, + children: [ + jsxRuntime.jsx("span", { children: t("pref.lang") }, "label"), + jsxRuntime.jsx("select", { + "aria-label": t("pref.lang"), + value: cur, + onChange: function (e) { pick(e.target.value); }, + style: { + minWidth: 140, padding: "6px 8px", borderRadius: 6, cursor: "pointer", + border: "1px solid var(--dsw-alias-border-secondary, #d9d9d9)", + background: "var(--dsw-alias-bg-base, transparent)", + color: "var(--dsw-alias-label-primary, inherit)" + }, + children: LANGS.map(function (l) { + return jsxRuntime.jsx("option", { value: l.id, children: l.name }, l.id); + }) + }, "select") + ] + }, "row") + ] + }); + } + + // 「偏好设置」= 语言切换(**用户可见**;order 99 ⇒ 排在「模型设置 / 功能管理」之前) + ctx.slots.inject("settings.section", function () { + return ctx.slots.register( + { + name: "settings.section", + id: "preferences", + order: 99, + label: function () { return t("pref.label"); } + }, + PreferencesSection + ); + }); + ctx.slots.inject("settings.section", function () { return ctx.slots.register( { diff --git a/poc/business-plugins/package.json b/poc/business-plugins/package.json index dee7f09..df4a921 100644 --- a/poc/business-plugins/package.json +++ b/poc/business-plugins/package.json @@ -1,7 +1,7 @@ { "name": "@dsh-local/business-plugins", - "version": "0.3.19", - "description": "功能管理(原「功能插件」)section for dsh web profile — v0.3.19(2026-09-14):**内存口径最终版 —— 基础 MIN、最多浮动到 MAX**(用户:「改成基础 min 最大可以浮动到 max」)。平台侧 = cgroup **两个参数**:`MemoryHigh=MIN`(448,软限/基础,超过即回收·限速)+ `MemoryMax=MAX`(1024,硬限/上界,越界 OOM);**与插件开关无关**(不读 profile 的 bundles)。客户端:`quotaOf()` 兜底值改为**硬顶上界**(进度条 100% 基准与「超限」判据同它),事实行改为显示「**基础 448 MiB → 最多 1024 MiB** · V8 堆 256」;`/api/dsh/status` 的 `quota` 增加 `baseMb`。|v0.3.18(2026-09-14):**内存口径修正 —— 实例配额与「插件开关」解耦**(用户:「实例内存不要受插件开关影响,只受 min 和 max 值影响」)。① 编排器 `instanceMemMb()` 改为只受 MIN/MAX 决定:`min(MIN, MAX)` = **MIN 448 MiB**(删掉 `PLUGIN_MEM_MB` 与 `BASE_MEM_MB`,不再读 profile 的 bundles);② 客户端保留 `MEM_TABLE`,但**只用于给用户看的预估**(`quotaOf()` 不再 clamp 预估值),显示「实际配额」仍优先取 `/api/dsh/status` 的真值;③ 进度条 100% 基准与确认弹窗的「超限」判据都改为**实际配额**(原为硬顶 MAX),文案改为「预估已超过实例配额(配额固定,不会因为多装插件而增加)」;④ 理由(实测):用「估计表」定「硬上限」时估偏低就真 OOM —— admin 带 mcn 后**峰值 409 MiB** 而当时上限 384 ⇒ 一分钟内被 cgroup OOM 杀 4 次、打出 crash 熔断;且配额随开关跳动会让界面「预估」与真实上限绑死。`verify-mem-model.mjs` 已换向断言(反回退:编排器不得再用插件集合算配额)。|v0.3.17(2026-09-14):**内存口径定案**(BASE 448 / MIN 448 / MAX 1024,插件成本表 univer 512、mcn 128)—— 与编排器 `instanceMemMb()` 逐值对齐(`verify-mem-model.mjs` 会红着提醒)。① BASE 160→448:0.1.5 基座**实测 312 MiB**,旧值按 0.1.2 的 106~145 估的,严重低估 ⇒ 384 的上限里只剩 72 MiB 给插件,这正是宿主 swap 被吃掉的原因;② **MIN 必须 ≥ BASE**(否则 `clamp(raw, MIN, MAX)` 的下限失去意义)⇒ MIN 384→448;③ **MAX 保持 1024**(不是偏好:编排器头部已写「上限 1024M 是单实例硬顶(宿主 1870M)」这一不变量,在途草稿的 1536 与宿主容量前提自相矛盾,已驳回);④ mcn 套件**保持 128**:无可支撑抬高的实测,反向有证据(装了它的用户在旧 384 上限下一直跑得住 ⇒ 需求 ≤384),而抬高会直接吃宿主余量(1870M / 可用 ≈761M / swap 已用 452M)。|v0.3.16(2026-09-14):**官方推荐插件列表按 dsh 版本过滤**(用户:「插件管理中的官方推荐插件列表 只能显示匹配当前dsh版本 和 超过当前版本的插件(超过的要明确标注)」)。① 官方目录(`awesome-dsh-plugin.com/plugins.json`)**不带 dsh 版本字段** ⇒ 平台侧逐包查 npm 精简 packument 的 `peerDependencies / engines` 与**平台真实版本**比对,分类 `match / none / newer / older / unknown`(判定在 `src/web/plugin-dsh-compat.ts`);② **默认隐藏 `older`**(只声明了更旧版本),`newer`(要求更高的 dsh)**橙色显著标注**并在 tooltip 给出「需 ≥ X,当前 Y」;③ 说明行给出「已按 dsh X 过滤,隐藏 N 个…」且**勾选框「含仅兼容更旧版本的」是显式逃生口** —— 绝不让条目静默消失;④ 判定结果按 `@<版本>` 落盘缓存 7 天,首次进页后台预热全量,请求内只给 6s 预算、超预算判 `unknown` 并**照常显示**(网络抖动不该等同「不兼容」);⑤ ⚠️ 语义:判 `satisfies` **必须带 `includePrerelease: true`** —— 平台版本是 prerelease,默认语义下 `^0.1.2` 甚至 `*` 都不满足,实测 TOP300 会把 `dsh-univer-office`/`dshmarket` 这类**在跑的**插件误判为 older(`match 97/older 139` → 容忍后 `match 214/older 22`);且**伞包 `@deepseek-ai/dsh` 必须单独解析**(它不在自己的 node_modules 里,而那 3 条 `newer` 全部只写在伞包上)。|v0.3.15(2026-09-14):**删除条目补二次确认**(官方 `deleteDialog` 的等价物)。原「删除」紧挨着「停用」且**无任何确认** ⇒ 一次误点就连已存储的 API 密钥一起删掉(`DELETE /api/me/keys/:id` 不可撤销,用户只能重新粘贴密钥);现改为弹窗**点名该提供方**(`ms.delTitle` / `ms.delDesc` / `ms.delConfirm`)后才执行,遮罩点击或「取消」可关。|v0.3.14(2026-09-14):**「模型设置」页按官方 `dsh-client-ui-settings-models` 复刻交互**(用户:「admin模型设置和状态 一个名称换行了,另一个两个按钮换行了,交互体验需要优化…新增模型的操作交互和官方原始新增模型的交互差距很大,建议仔细参考 复刻官方的交互」)。① **换行根因**:原「我已添加的厂家」是 4 列表格(`pa-tbl` 自动列宽)⇒「内置 DeepSeek」与「停用/删除」被挤到换行;改为**官方卡片行**(`ms-rowCard`/`ms-rowHead`/`ms-rowIdentity`/`ms-rowActions`,名称与按钮两侧均 `nowrap`)⇒ **结构上不可能换行**,状态文字下沉到独占一行的副标题。② **新增流程改为官方的两步式**:默认只露**两个虚线按钮**(「+ 添加提供方」/「+ 添加自定义提供方」,`flex:1 1 0;min-width:180px;height:44px`),点击才展开卡片 —— 旧版是「常开表单 + 厂家下拉里混一项『自定义厂家…』」。③ 新增卡片**主字段是单独一个「API 密钥」输入框**(官方原文:页面从不问环境变量名),显示名挪进收起的「自定义设置」`
`;自定义卡片按官方字段序(Provider ID → 显示名称 → API 地址 → API 协议 → API 密钥 → 模型目录),模型目录由**一行一个的文本框**改成官方的**行式编辑器**(`+ 添加模型` / 行尾 ✕ 删除)。④ CSS 数值**逐值照抄**官方 CSS 模块(圆角 16/18/14/12/10/8/6/4、字号 14/12/11、行高 22/18/16、按钮高 36/28/44px),颜色走同一批 `--dsw-*` token。⑤ 顺带修两处文案缺陷:admin 自己看共享模型卡片时原写「由 admin 配置」(后端早已给 `ownerIsMe`)⇒ 改「由你配置」;「内置 DeepSeek 密钥留空则回落平台共享密钥」是**错的**(`keyAddSchema` 里 `apiKey` 是 `required + minLength:1`,留空必得 400)⇒ 改为「输入你的 DeepSeek API 密钥」。⑥ 新增 `scripts/verify-models-dict.mjs` 断言 zh/en 键集一致 + 代码引用的键都已声明(本轮就是靠它抓到 `ms.needUrl`/`ms.needModels` 被引用未声明)。|v0.3.10(2026-09-13):① **「服务管理」改名「实例管理」**、**「密钥管理」改名「模型管理」**(用户要求;门户导航/卡片同步改名);② **「实例管理」页可管任意用户**(用户要求「admin 要能管所有用户的服务」)—— 工具条新增「用户」下拉,切换后文件树/启停/打开全部针对所选用户,走新开的 `/api/admin/users/:id/{fs,dsh}`(requireAdmin)|v0.3.8(2026-09-13):**撤掉「我的密钥」分区** —— 模型配置统一走**官方「设置 → 模型」页**(用户要求「界面交互和官方一模一样」⇒ 不仿制、直接放开官方页,见档案 86)。平台侧同步两处:① `ensure-role-profile-patch.cjs` 不再禁用 `ui-settings-models`(实测官方页在本环境可用:`/api/session/modelCatalog` 返回 200);② `src/web/server.ts` 的 `resolveApiKey()` 改为「用户已自配 ⇒ **不注入共享 env**」——因为 dsh 凭据解析里 `inherited process environment` **优先级最高**,不这样做用户配的 key 会被静默盖掉。|v0.3.7(2026-09-13 · 原拟 0.3.6,因并行会话已用同号 0.3.6 铺发过「内存条」版本 ⇒ 提升为 0.3.7):① ~~新增「我的密钥」分区~~(**已于 0.3.8 撤除**,原因是两套入口会互相干扰) —— 用户自助配置自己的模型密钥(自配自用),不配置就用「平台共享密钥」(管理员配置);页面分两段:我的密钥(添加 / 启用 / 删除)+ 当前生效(明示在用谁的 key,并提示改 key 只重启自己的实例、不影响他人)。配套后端:`/api/me/keys` 由 requireAdmin 放开为 requireAuth,`resolveApiKey(userId)` 改为「先取自己的 → 取不到回落管理员的共享 key」两级;门户「密钥管理」文案同步改为「平台共享密钥」。② **内存条改读平台真实配额,消灭「388 MiB 误报」**(用户问「实例内存大小是不是调整过了,为什么功能设置中还是显示 388 多」)。根因:本插件自建了**第二套**内存模型(基线 285 / univer 64 / mcn 39,并把 clamp 下限 384 当硬上限判超限),而平台编排器自 R1 起为「按插件集合推导」(base 160 / univer 512 / mcn 128 / clamp 384–1024),两处从未对齐 ⇒ 全勾显示 388 并报「⚠ 将超出上限」,真值却是 672(guest)/ 384(admin)。本轮:① 常量与规则逐项对齐编排器,并新增 `scripts/verify-mem-model.mjs` 在 `npm run verify` 里交叉断言(漂了就构建失败);② 优先读 `/api/dsh/status` 新增的 `quota{memMb,heapMb}`(平台**实际使用**的值,与 spawn 同源)直接显示「实际配额 · V8 堆」;③ 超限判断改为「原始需求 > 硬顶 1024」;④ 未进成本表的插件不再显示 ≈0 MiB 徽章。|v0.3.5(2026-09-13):**插件管理 →「官方推荐插件」列表高度拉高**(用户要求「高度可以增加,距离底部 100px 就行」)—— 该表 max-height 由固定 420px 改为 `max(280px, min(calc(92vh - 470px), 580px))`(类 `.pa-plist`),按弹窗高度反推、使列表底部**距弹窗底部约 100px**;下限 280px 防小屏压扁,上限 580px 对应弹窗触顶 1040px。|v0.3.4(2026-09-13):**「系统管理」全面对齐门户 web/portal.html 的 6 个功能页**(用户要求「点开弹窗里面展示的内容,要和 portal 点击功能后那种详细的表格和功能一致」)—— 分区首页 = 门户 renderHome(「服务」「管理」两组 + .nav-card 三行卡片);点开每一项 = 门户**那一页的完整页面**:服务管理(文件树 + 启动/停止/打开 DSH + 新建文件夹/上传)、密钥管理(全局 API 密钥增删启用)、用户管理(审批/禁用/恢复/删除,需输入用户名二次确认)、技能管理(.zip 上传/替换/删除)、插件管理(官方推荐插件 + 手动添加/管理双页签、搜索/分类/只看可导入/重新拉取/批量导入、.tgz 投放含 P0 扫描与显式信任)、运行环境(版本表 + 漂移提示 + 目录/体积 + 折叠说明);表格列 / 按钮 / 文案逐字一致(`PA_PAGES` 逐函数移植 portal 的 renderXxx/initXxx,只改 3 处:局部 `$` 查询器、跨子域 `paReq`、去掉 hash 路由);弹窗宽度对齐门户功能页 min(1440px,96vw)、高 92vh。写操作就地调平台 admin API(跨子域 + credentials:include,CORS 白名单已含 GET/POST/DELETE)。**已删除**旧版自造的 5 项只读摘要(用户管理 / 候选池 / 运行时 / 存储 / 当前实例)。⚠️ 顺带修掉门户 `readAsBase64()` 缺 await 导致上传恒传空数据的缺陷(弹窗侧已修正,门户侧待同修)。|v0.3.3(2026-09-13):「系统管理」视觉层照抄门户组件(.nav-card / .pg-cnt / .table-wrap + table.tbl / .badge / .btn-sm / .page-title)。|v0.2.8(2026-09-13):所有弹窗改页内弹窗(弃用 window.confirm)。|v0.2.7(档案 75):卡片加内存预估徽章 + 列表上方内存预估状态条。|v0.2.4(档案 67):对齐 06-工作台UI规范(信息层次反转 / 字号阶梯 / 行 hover)。|v0.2.2:分区名由「功能插件」改为「功能管理」。|v0.2.0:配色改用官方 --dsw-* design token(跟随 dsh 主题);文案走官方 locale(zh/en)。", + "version": "0.3.21", + "description": "功能管理(原「功能插件」)section for dsh web profile — v0.3.21(2026-09-15):**实例内「我的技能」分组**(档案 100 / 交接单 T01)—— 用户在同一页管理「功能插件 + 我的技能」两类功能。① **形态**:并入既有「功能管理」section 内**分组**,**不新开 section**(依据用户口径「不要分开管理」+ 档案 60 的分区命名);**仅入口层合并、机制层独立** —— 技能是 watch 驱动(启停**立即生效、无需重启**)而插件要重启实例,两条 API(`/api/skills/mine*` vs `/api/plugins/mine*`)与两套落盘位置一律不动(档案 16 §2.1 决策 2)。② **技能行**:技能名(截断三件套)+ 来源徽章(共享/我的)+ 状态(● 已启用 / ○ 已停用)+ 事实(文件数 · 体积,`tabular-nums`)+ 动作(启用/停用、删除);沿用官方卡片行语汇(动作区 `margin-left:auto` + `nowrap`)⇒ **结构上不可能换行**。③ **平台共享技能**(`source:'shared'`、`locked:true`)显示 🔒只读并**不渲染任何动作按钮** —— 与后端 `assertNotShared()` 的 409 双向一致(前端不给注定失败的按钮,后端仍兜底)。④ **上传**:虚线拖拽区 + 点击选文件(`label` 包隐藏 `input`,无需 ref),客户端先校 `.zip` / ≤150MB;**同名走两阶段**(先暂存 → 页内弹窗确认,写明「替换将删除旧技能全部文件、不可恢复」+ 旧/新文件数对照 → `apply`),与门户同语义。⑤ **删除**同样必须过页内确认(不可逆);启用/停用**可逆故不弹窗**。⑥ 失败一律**行内展示后端中文原文**(如「「x」是平台共享技能…请改用其他技能名」),不自己造词、不裸露状态码。⑦ zh/en 全量词条(`msk.*`,共 46 条/语言);新增 `scripts/verify-my-skills.mjs` 把「不换行 / 危险操作必须二次确认 / 锁定行无按钮」钉成机械断言并入 `npm run verify`。|v0.3.19(2026-09-14):**内存口径最终版 —— 基础 MIN、最多浮动到 MAX**(用户:「改成基础 min 最大可以浮动到 max」)。平台侧 = cgroup **两个参数**:`MemoryHigh=MIN`(448,软限/基础,超过即回收·限速)+ `MemoryMax=MAX`(1024,硬限/上界,越界 OOM);**与插件开关无关**(不读 profile 的 bundles)。客户端:`quotaOf()` 兜底值改为**硬顶上界**(进度条 100% 基准与「超限」判据同它),事实行改为显示「**基础 448 MiB → 最多 1024 MiB** · V8 堆 256」;`/api/dsh/status` 的 `quota` 增加 `baseMb`。|v0.3.18(2026-09-14):**内存口径修正 —— 实例配额与「插件开关」解耦**(用户:「实例内存不要受插件开关影响,只受 min 和 max 值影响」)。① 编排器 `instanceMemMb()` 改为只受 MIN/MAX 决定:`min(MIN, MAX)` = **MIN 448 MiB**(删掉 `PLUGIN_MEM_MB` 与 `BASE_MEM_MB`,不再读 profile 的 bundles);② 客户端保留 `MEM_TABLE`,但**只用于给用户看的预估**(`quotaOf()` 不再 clamp 预估值),显示「实际配额」仍优先取 `/api/dsh/status` 的真值;③ 进度条 100% 基准与确认弹窗的「超限」判据都改为**实际配额**(原为硬顶 MAX),文案改为「预估已超过实例配额(配额固定,不会因为多装插件而增加)」;④ 理由(实测):用「估计表」定「硬上限」时估偏低就真 OOM —— admin 带 mcn 后**峰值 409 MiB** 而当时上限 384 ⇒ 一分钟内被 cgroup OOM 杀 4 次、打出 crash 熔断;且配额随开关跳动会让界面「预估」与真实上限绑死。`verify-mem-model.mjs` 已换向断言(反回退:编排器不得再用插件集合算配额)。|v0.3.17(2026-09-14):**内存口径定案**(BASE 448 / MIN 448 / MAX 1024,插件成本表 univer 512、mcn 128)—— 与编排器 `instanceMemMb()` 逐值对齐(`verify-mem-model.mjs` 会红着提醒)。① BASE 160→448:0.1.5 基座**实测 312 MiB**,旧值按 0.1.2 的 106~145 估的,严重低估 ⇒ 384 的上限里只剩 72 MiB 给插件,这正是宿主 swap 被吃掉的原因;② **MIN 必须 ≥ BASE**(否则 `clamp(raw, MIN, MAX)` 的下限失去意义)⇒ MIN 384→448;③ **MAX 保持 1024**(不是偏好:编排器头部已写「上限 1024M 是单实例硬顶(宿主 1870M)」这一不变量,在途草稿的 1536 与宿主容量前提自相矛盾,已驳回);④ mcn 套件**保持 128**:无可支撑抬高的实测,反向有证据(装了它的用户在旧 384 上限下一直跑得住 ⇒ 需求 ≤384),而抬高会直接吃宿主余量(1870M / 可用 ≈761M / swap 已用 452M)。|v0.3.16(2026-09-14):**官方推荐插件列表按 dsh 版本过滤**(用户:「插件管理中的官方推荐插件列表 只能显示匹配当前dsh版本 和 超过当前版本的插件(超过的要明确标注)」)。① 官方目录(`awesome-dsh-plugin.com/plugins.json`)**不带 dsh 版本字段** ⇒ 平台侧逐包查 npm 精简 packument 的 `peerDependencies / engines` 与**平台真实版本**比对,分类 `match / none / newer / older / unknown`(判定在 `src/web/plugin-dsh-compat.ts`);② **默认隐藏 `older`**(只声明了更旧版本),`newer`(要求更高的 dsh)**橙色显著标注**并在 tooltip 给出「需 ≥ X,当前 Y」;③ 说明行给出「已按 dsh X 过滤,隐藏 N 个…」且**勾选框「含仅兼容更旧版本的」是显式逃生口** —— 绝不让条目静默消失;④ 判定结果按 `@<版本>` 落盘缓存 7 天,首次进页后台预热全量,请求内只给 6s 预算、超预算判 `unknown` 并**照常显示**(网络抖动不该等同「不兼容」);⑤ ⚠️ 语义:判 `satisfies` **必须带 `includePrerelease: true`** —— 平台版本是 prerelease,默认语义下 `^0.1.2` 甚至 `*` 都不满足,实测 TOP300 会把 `dsh-univer-office`/`dshmarket` 这类**在跑的**插件误判为 older(`match 97/older 139` → 容忍后 `match 214/older 22`);且**伞包 `@deepseek-ai/dsh` 必须单独解析**(它不在自己的 node_modules 里,而那 3 条 `newer` 全部只写在伞包上)。|v0.3.15(2026-09-14):**删除条目补二次确认**(官方 `deleteDialog` 的等价物)。原「删除」紧挨着「停用」且**无任何确认** ⇒ 一次误点就连已存储的 API 密钥一起删掉(`DELETE /api/me/keys/:id` 不可撤销,用户只能重新粘贴密钥);现改为弹窗**点名该提供方**(`ms.delTitle` / `ms.delDesc` / `ms.delConfirm`)后才执行,遮罩点击或「取消」可关。|v0.3.14(2026-09-14):**「模型设置」页按官方 `dsh-client-ui-settings-models` 复刻交互**(用户:「admin模型设置和状态 一个名称换行了,另一个两个按钮换行了,交互体验需要优化…新增模型的操作交互和官方原始新增模型的交互差距很大,建议仔细参考 复刻官方的交互」)。① **换行根因**:原「我已添加的厂家」是 4 列表格(`pa-tbl` 自动列宽)⇒「内置 DeepSeek」与「停用/删除」被挤到换行;改为**官方卡片行**(`ms-rowCard`/`ms-rowHead`/`ms-rowIdentity`/`ms-rowActions`,名称与按钮两侧均 `nowrap`)⇒ **结构上不可能换行**,状态文字下沉到独占一行的副标题。② **新增流程改为官方的两步式**:默认只露**两个虚线按钮**(「+ 添加提供方」/「+ 添加自定义提供方」,`flex:1 1 0;min-width:180px;height:44px`),点击才展开卡片 —— 旧版是「常开表单 + 厂家下拉里混一项『自定义厂家…』」。③ 新增卡片**主字段是单独一个「API 密钥」输入框**(官方原文:页面从不问环境变量名),显示名挪进收起的「自定义设置」`
`;自定义卡片按官方字段序(Provider ID → 显示名称 → API 地址 → API 协议 → API 密钥 → 模型目录),模型目录由**一行一个的文本框**改成官方的**行式编辑器**(`+ 添加模型` / 行尾 ✕ 删除)。④ CSS 数值**逐值照抄**官方 CSS 模块(圆角 16/18/14/12/10/8/6/4、字号 14/12/11、行高 22/18/16、按钮高 36/28/44px),颜色走同一批 `--dsw-*` token。⑤ 顺带修两处文案缺陷:admin 自己看共享模型卡片时原写「由 admin 配置」(后端早已给 `ownerIsMe`)⇒ 改「由你配置」;「内置 DeepSeek 密钥留空则回落平台共享密钥」是**错的**(`keyAddSchema` 里 `apiKey` 是 `required + minLength:1`,留空必得 400)⇒ 改为「输入你的 DeepSeek API 密钥」。⑥ 新增 `scripts/verify-models-dict.mjs` 断言 zh/en 键集一致 + 代码引用的键都已声明(本轮就是靠它抓到 `ms.needUrl`/`ms.needModels` 被引用未声明)。|v0.3.10(2026-09-13):① **「服务管理」改名「实例管理」**、**「密钥管理」改名「模型管理」**(用户要求;门户导航/卡片同步改名);② **「实例管理」页可管任意用户**(用户要求「admin 要能管所有用户的服务」)—— 工具条新增「用户」下拉,切换后文件树/启停/打开全部针对所选用户,走新开的 `/api/admin/users/:id/{fs,dsh}`(requireAdmin)|v0.3.8(2026-09-13):**撤掉「我的密钥」分区** —— 模型配置统一走**官方「设置 → 模型」页**(用户要求「界面交互和官方一模一样」⇒ 不仿制、直接放开官方页,见档案 86)。平台侧同步两处:① `ensure-role-profile-patch.cjs` 不再禁用 `ui-settings-models`(实测官方页在本环境可用:`/api/session/modelCatalog` 返回 200);② `src/web/server.ts` 的 `resolveApiKey()` 改为「用户已自配 ⇒ **不注入共享 env**」——因为 dsh 凭据解析里 `inherited process environment` **优先级最高**,不这样做用户配的 key 会被静默盖掉。|v0.3.7(2026-09-13 · 原拟 0.3.6,因并行会话已用同号 0.3.6 铺发过「内存条」版本 ⇒ 提升为 0.3.7):① ~~新增「我的密钥」分区~~(**已于 0.3.8 撤除**,原因是两套入口会互相干扰) —— 用户自助配置自己的模型密钥(自配自用),不配置就用「平台共享密钥」(管理员配置);页面分两段:我的密钥(添加 / 启用 / 删除)+ 当前生效(明示在用谁的 key,并提示改 key 只重启自己的实例、不影响他人)。配套后端:`/api/me/keys` 由 requireAdmin 放开为 requireAuth,`resolveApiKey(userId)` 改为「先取自己的 → 取不到回落管理员的共享 key」两级;门户「密钥管理」文案同步改为「平台共享密钥」。② **内存条改读平台真实配额,消灭「388 MiB 误报」**(用户问「实例内存大小是不是调整过了,为什么功能设置中还是显示 388 多」)。根因:本插件自建了**第二套**内存模型(基线 285 / univer 64 / mcn 39,并把 clamp 下限 384 当硬上限判超限),而平台编排器自 R1 起为「按插件集合推导」(base 160 / univer 512 / mcn 128 / clamp 384–1024),两处从未对齐 ⇒ 全勾显示 388 并报「⚠ 将超出上限」,真值却是 672(guest)/ 384(admin)。本轮:① 常量与规则逐项对齐编排器,并新增 `scripts/verify-mem-model.mjs` 在 `npm run verify` 里交叉断言(漂了就构建失败);② 优先读 `/api/dsh/status` 新增的 `quota{memMb,heapMb}`(平台**实际使用**的值,与 spawn 同源)直接显示「实际配额 · V8 堆」;③ 超限判断改为「原始需求 > 硬顶 1024」;④ 未进成本表的插件不再显示 ≈0 MiB 徽章。|v0.3.5(2026-09-13):**插件管理 →「官方推荐插件」列表高度拉高**(用户要求「高度可以增加,距离底部 100px 就行」)—— 该表 max-height 由固定 420px 改为 `max(280px, min(calc(92vh - 470px), 580px))`(类 `.pa-plist`),按弹窗高度反推、使列表底部**距弹窗底部约 100px**;下限 280px 防小屏压扁,上限 580px 对应弹窗触顶 1040px。|v0.3.4(2026-09-13):**「系统管理」全面对齐门户 web/portal.html 的 6 个功能页**(用户要求「点开弹窗里面展示的内容,要和 portal 点击功能后那种详细的表格和功能一致」)—— 分区首页 = 门户 renderHome(「服务」「管理」两组 + .nav-card 三行卡片);点开每一项 = 门户**那一页的完整页面**:服务管理(文件树 + 启动/停止/打开 DSH + 新建文件夹/上传)、密钥管理(全局 API 密钥增删启用)、用户管理(审批/禁用/恢复/删除,需输入用户名二次确认)、技能管理(.zip 上传/替换/删除)、插件管理(官方推荐插件 + 手动添加/管理双页签、搜索/分类/只看可导入/重新拉取/批量导入、.tgz 投放含 P0 扫描与显式信任)、运行环境(版本表 + 漂移提示 + 目录/体积 + 折叠说明);表格列 / 按钮 / 文案逐字一致(`PA_PAGES` 逐函数移植 portal 的 renderXxx/initXxx,只改 3 处:局部 `$` 查询器、跨子域 `paReq`、去掉 hash 路由);弹窗宽度对齐门户功能页 min(1440px,96vw)、高 92vh。写操作就地调平台 admin API(跨子域 + credentials:include,CORS 白名单已含 GET/POST/DELETE)。**已删除**旧版自造的 5 项只读摘要(用户管理 / 候选池 / 运行时 / 存储 / 当前实例)。⚠️ 顺带修掉门户 `readAsBase64()` 缺 await 导致上传恒传空数据的缺陷(弹窗侧已修正,门户侧待同修)。|v0.3.3(2026-09-13):「系统管理」视觉层照抄门户组件(.nav-card / .pg-cnt / .table-wrap + table.tbl / .badge / .btn-sm / .page-title)。|v0.2.8(2026-09-13):所有弹窗改页内弹窗(弃用 window.confirm)。|v0.2.7(档案 75):卡片加内存预估徽章 + 列表上方内存预估状态条。|v0.2.4(档案 67):对齐 06-工作台UI规范(信息层次反转 / 字号阶梯 / 行 hover)。|v0.2.2:分区名由「功能插件」改为「功能管理」。|v0.2.0:配色改用官方 --dsw-* design token(跟随 dsh 主题);文案走官方 locale(zh/en)。", "type": "module", "main": "lib/index.js", "exports": { diff --git a/scripts/verify-my-skills.mjs b/scripts/verify-my-skills.mjs new file mode 100644 index 0000000..f0f3372 --- /dev/null +++ b/scripts/verify-my-skills.mjs @@ -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)