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

+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(
{