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:
1 parent
5ad755116e
commit
39a1f2e31b
4 files changed
+868
-4
No files matched your search
@@ -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(
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user