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 ✓(本会话复核已跑通)
This commit is contained in:
admin committed 2026-09-15 18:54:00 +08:00
1 parent 5ad755116e
commit 39a1f2e31b
4 files changed
+868 -4

No files matched your search

+1 -1
View File
@@ -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",
+767 -1
View File
@@ -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(
{
+2 -2
View File
@@ -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 个…」且**勾选框「含仅兼容更旧版本的」是显式逃生口** —— 绝不让条目静默消失;④ 判定结果按 `<npm>@<版本>` 落盘缓存 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模型设置和状态 一个名称换行了,另一个两个按钮换Line truncated
"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版本 和 超过当前版本的插件(超过的要Line truncated
"type": "module",
"main": "lib/index.js",
"exports": {
+98
View File
@@ -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)