Files
dsh_shenxian/poc/business-plugins/lib/client.js
T
admin 3efd68517f chore: 并入已删除会话的在途成果(防丢失;原会话已删,未做功能验收)
**背景**:这些改动原属本工作区另外几个会话(T01/T02 等),**那些会话已被用户删除** ⇒
工作树里的成果处于"无主"状态,一次错误 checkout / 覆盖即**永久丢失** ⇒ 代入库保全。
口径遵循本项目**先例**(`39a1f2e` / `b617cdb`:**别人的活,代入库并在提交信息里注明**)。

**内容**:档案 101「能力管理」改名 + 页内 tab 分页|档案 102 语言切换搬入「用户设置」|
`07-实例UI分区登记表.md`|`scripts/find-ui*.mjs`(UI 元素定位工具)|`poc/portal-entry/`(0.5.3)|
`src/web/locale-pref.ts` + `home-files.ts`(语言偏好持久化)|`test/locale-pref.test.mjs`|
`package.json`|`BRIEF.md` / `INDEX.md` / `docs-manifest.json` / `03-路线图与待办.md` / 档案 100 增量。

**已做最小健全性检查**(⚠️ **未跑完整构建 / 单测** —— 那是原会话的验收职责,本次只求"不丢"):
- JSON 合法:`package.json` / `poc/business-plugins/package.json` / `docs-manifest.json` ✓
- 4 个 TS 文件 `{}`/`()` 配平 ✓;新增文件均非空 ✓
- 规模:13 文件改动 +340/−210,新增 12 条

**未 push**(按 §4 提交边界:用户说"提交",未说"推送")。
2026-09-15 21:17:12 +08:00

3997 lines
209 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @dsh-local/business-plugins — client half (dsh 0.1.2-rc.1, v0.2.4)
//
// dsh client bundles 以普通脚本形式执行,向 window.__ModuleLoader__ 注册 factory;
// 首次 import 时惰性物化 factory(require) -> exports。require 解析平台 seed 词
// (react/jsx-runtime)与图行(其他 dsh.client 包)。**严格镜像官方 client bundle
// 的 CJS-style factory 形态,只导出 apply + inject,绝不 exports.default**(R3:
// exports.default 会让 loader 的 ESM/CJS interop 把 bare apply FUNCTION 当作插件体,
// 无 inject 声明点 → ctx.slots 抛 cannot get property "slots" without inject)。
//
// v0.2.0(档案 37 · 批次 3):
// · 配色全部改用官方 design token `--dsw-*`(原来硬编码深色)→ **自动跟随 dsh 主题**
// (亮/暗/第三方主题都适配),不再需要自己实现主题切换。
// · 文案走官方 locale:ctx.locale.register(NS,{zh,en}) + ctx.locale.bind(NS) → 中英双语。
// · 交互:搜索过滤 + 每行状态徽章(已启用/未启用)+ **被自动禁用插件的行内提示与重试**
// + 空态/加载态。
// · 应用成功且实例重启后,**自动探活并刷新页面**(重启会换端口,刷新即可接上新实例)。
//
// v0.2.4(档案 67 · 对齐 06-工作台UI规范.md):
// · **信息层次反转**:主视觉改为插件的**用途说明**(候选池入库时已优先取官方目录的中文),
// 插件名 / 版本退为副行小字 —— 与门户 plugins 页同一决策(「一眼知道这插件干什么」)。
// · 字号对齐规范 §2.2 阶梯(section 语境取 14 / 13 / 12,不照搬页面级 16px 基准);
// 徽章改 `2px 8px` r10、按钮改 `.btn-sm` 量级、行距与内边距按规范 §2.4 序列。
// · 空态/加载态按规范 §4.9 改为「标题 + 说明」层次,不再是一行灰字。
// · 新增注入式 CSS 提供行 hover 反馈与按钮 hover(规范 §4.3 / §5);disposer 随 fiber 移除。
//
// v0.2.5(档案 67 补 · 按 `impeccable` 的 craft-floor 复核后修正;检测器未随技能包提供,人工过检):
// · **去掉 rejected 提示的彩色 `border-left`** —— craft-floor 明令:卡片/列表项/提示上
// 不得出现 >1px 的彩色 border-left/right。改为整块浅底 + 圆角,危险语义由标题文字色承担。
// · **对比度**:所有必读文字从 `--dsw-alias-label-tertiary` 提到 `secondary`
// (tertiary 在亮色下约 3.5:1,低于 craft-floor 的 4.5:1 底线)。
// · **层次**:主视觉加 `fontWeight: 500`,与 12px 副行拉开 weight 台阶(原先只差 2px 字号,
// 属于 craft-floor 点名的「scale and weight steps 不明显」)。
// · **动效**:transition 由 `.15s ease` 改为 `.18s cubic-bezier(.16,1,.3,1)`(exponential ease-out)。
//
// v0.2.7(档案 75 维度 B 落地 · 2026-09-13 用户要求):
// · **卡片加内存预估**:每张卡片多一行「预估内存 ≈ N MiB」小徽章,卡片加高(minHeight 112)
// 让「用途说明 / 包名 / 预估内存 / 状态徽章」四层排得开。
// · **列表上方新增「内存预估」状态条**:实心段 = 当前已启用插件的预估占用,
// 半透明段 = 本次勾选带来的增量;超过单实例上限(384 MiB)时整条转红并提示。
// · **口径**:`MEM_BASE_MIB`(-dsh 本体+官方插件基线) + `MEM_TABLE`(逐插件实测加载成本);
// **是预估值,不是实时读数**(客户端拿不到 cgroup 实测值,需平台加只读路由才能做实时 —— 见档案 75)。
//
// 功能:在 dsh 设置面板注册「功能管理」section —— 列出候选池 + 每个插件启用状态,
// 批量勾选后点「应用更改」,确认弹窗(提示重启会中断会话)→ POST 门户
// /api/plugins/mine/apply(跨子域 fetch,credentials include;门户已加 CORS)。
// 门户端启用=复制 bundle 到该用户 profile 并加入 bundles,禁用=从 bundles 移除,
// 然后重启该用户实例生效;**启用会让实例起不来的插件会被门户单独摘掉并标记**。
window.__ModuleLoader__.load({
id: "@dsh-local/business-plugins",
factory: (require) => {
var module = { exports: {} };
var exports = module.exports;
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
var jsxRuntime = require("react/jsx-runtime");
var React = require("react");
// 门户主域:去掉实例子域 label(admin.alotbuy.com -> alotbuy.com)。
function portalHost() {
try {
var labels = window.location.hostname.split(".");
if (labels.length > 2) {
return window.location.protocol + "//" + labels.slice(1).join(".");
}
} catch (e) {}
return window.location.origin;
}
// 官方 design token(dsh 主题变量)。刻意**不硬编码颜色**:dsw-* 由主题提供,
// 亮/暗/第三方主题自动适配;fallback 仅在该 token 缺失时兜底。
// 注:06-工作台UI规范 §2.1 给的是亮色基线色值,本 section 嵌在 dsh 面板内,
// 跟随宿主主题优先,故取 token + 规范色值作 fallback。
var T = {
text: "var(--dsw-alias-label-primary, #24292f)",
sub: "var(--dsw-alias-label-secondary, #4e5969)",
dim: "var(--dsw-alias-label-tertiary, #6b7280)",
border: "var(--dsw-alias-border-l2, #e3e6ea)",
field: "var(--dsw-alias-bg-layer-3, #f5f6f8)",
primary: "var(--dsw-alias-brand-primary, #2f6fed)",
danger: "var(--dsw-alias-state-error-primary, #d93026)",
warn: "var(--dsw-alias-state-warning-primary, #bf8700)",
success: "var(--dsw-alias-state-success-primary, #1a7f37)"
};
// ── 内存模型(档案 84;口径**与平台编排器同源**)──────────────────────────
//
// 权威定义在 `src/supervisor/orchestrator.ts`:
// 配额 = min(MIN_MEM_MIB, MEM_MAX_MIB) —— **固定值,与插件开关无关**(2026-09-14 用户裁定)
// `MEM_TABLE` 只用于给用户看的**预估**,不再是实例上限的来源。
// 下面的常量**逐键与它对齐**,并由 `scripts/verify-mem-model.mjs` 在 `npm run verify`
// 里交叉断言 —— 两处一旦不同步,构建直接失败。
//
// ★ 历史教训(2026-09-13 实测):这里曾自建一套**独立**模型(基线 285 / univer 64 /
// mcn-suite 39,且把 384 当硬上限)⇒ 界面显示「勾选后 388 MiB」并报「⚠ 将超出上限」,
// 而平台真实配额是 672(guest)/ 384(admin),**384 早已只是下限** ⇒ 纯属误报。
// 修法:① 常量与规则对齐平台;② 优先读 `/api/dsh/status` 的 `quota.memMb/heapMb`
// (平台**实际使用**的值)直接显示;③「超限」改为判断是否顶到硬顶 MAX。
// 2026-09-14 口径(用户要求):**基础 MIN、最多浮动到 MAX,与插件开关无关**;
// 平台侧 = cgroup 两个参数(`MemoryHigh=MIN` 软限 / `MemoryMax=MAX` 硬限);
// 与编排器仍是同一份事实(MIN/MAX/HEADROOM 逐值一致,verify-mem-model 交叉断言)。
/** **仅用于预估**(0.1.5 基座实测 312 MiB):不再参与实例配额计算。 */
var MEM_BASE_MIB = 448;
/** 与编排器 `MIN_MEM_MB` 一致 —— **基础值**(cgroup `MemoryHigh` 软限;稳态落在这附近)。 */
var MEM_MIN_MIB = 448;
/** 与编排器 `MAX_MEM_MB` 一致 —— **上浮上界**(cgroup `MemoryMax` 硬限;越界 OOM)。 */
var MEM_MAX_MIB = 1024;
/** **仅用于预估**:逐插件加载成本(未识别的按 0 计)。⚠️ 平台不再据此调整配额。 */
var MEM_TABLE = {
"dsh-univer-office": 512,
"dsh-plugin-mcn-suite": 128
};
/** 与编排器 `HEAP_HEADROOM_MB` 一致。 */
var MEM_HEAP_HEADROOM_MIB = 96;
/** 越过该比例即提前警示(相对**硬顶**),给页缓存与用户自己的任务留余量。 */
var MEM_TIGHT_RATIO = 0.85;
/** 单个插件的加载成本(MiB);未识别 = 0(与平台同口径:宁可少给也不虚高)。 */
function memMiB(p) {
if (p && p.name && Object.prototype.hasOwnProperty.call(MEM_TABLE, p.name)) {
return MEM_TABLE[p.name];
}
return 0;
}
/** 未 clamp 的原始需求(MiB)= 基线 + Σ命中 `key` 为真的插件。 */
function rawMiB(list, key) {
var total = MEM_BASE_MIB;
for (var i = 0; i < list.length; i++) {
if (list[i][key]) total += memMiB(list[i]);
}
return total;
}
/** 实例内存的**上浮上界**(MiB)= cgroup 硬限;进度条 100% 基准与「超限」判据都用它。
* ⚠️ 平台真值优先取 `/api/dsh/status` 的 `quota.memMb`;本函数只是读不到时的兜底。
* 形参 `_raw` 保留只为调用处可读 —— 预估另由 `rawMiB()` 单独算。 */
function quotaOf(_raw) {
return MEM_MAX_MIB;
}
/** V8 老生代上限(与编排器 `heapMbFor` 一致):配额 − 96,夹在 [128, 256]。 */
function heapMiBFor(memMb) {
return Math.max(128, Math.min(256, memMb - MEM_HEAP_HEADROOM_MIB));
}
/** 本插件的 locale 命名空间(官方 i18n)。 */
var NS = "business-plugins";
/** 简体中文字典(key 集的事实来源)。 */
var zh = {
"section.label": "能力管理",
// 顶部页内分页(2026-09-15 用户要求:「能力管理」按插件 / 技能分页各自操作)。
// 技能页的标签直接复用 `msk.group`,不另造同义词。
"cap.tabPlugins": "功能插件",
// ── 「我的技能」分组(档案 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": "暂无功能插件",
"empty": "需管理员先在门户「插件管理」里投放,之后可在此启用",
"noMatch": "没有匹配的插件",
"search": "搜索插件名或用途",
"selectAll": "全选",
"clear": "清空",
"enabled": "已启用",
"disabled": "未启用",
"selected": "已选",
"apply": "应用更改",
"applying": "应用中…",
"confirm.title": "确认应用更改?",
"confirm": "将重启当前 dsh 实例以生效。重启会中断当前会话(正在进行的对话会断开)。",
"confirm.cancel": "取消",
"confirm.ok": "确认应用",
"confirm.overTitle": "⚠ 内存需求已顶到单实例硬顶",
"nochange": "没有需要更改的插件",
"queued": "排队中…",
"installing": "正在安装",
"configuring": "正在配置",
"restarting": "正在重启实例",
"probing": "正在检查实例",
"isolating": "正在定位问题插件",
"applied": "✓ 已应用",
"reloading": "实例重启中,页面将自动刷新…",
"rejected.title": "以下插件与实例不兼容,已自动禁用",
"retry": "重试",
"requestFailed": "请求失败",
"loadFailed": "加载失败",
"mem.title": "内存配额",
"mem.est": "估算值(未读到平台配额,按平台同款算式推导)",
"mem.base": "基础",
"mem.real": "实际配额",
"mem.heap": "V8 堆",
"mem.now": "当前",
"mem.planned": "勾选后",
"mem.limit": "硬顶",
"mem.left": "剩余",
"mem.over": "顶到硬顶",
"mem.tight": "接近硬顶",
"mem.safe": "余量充足",
"mem.overWarn": "本插件的内存**预估**已超过实例配额(配额固定,不会因为多装插件而增加),实例可能起不来。建议先停用部分插件再应用。",
"mem.perCard": "预估内存",
// ── 「系统管理」分区(第 3 轮 · 2026-09-13)──────────────────────────────
// 用户要求:弹窗里的**功能名称 / 表格 / 功能**必须与门户 `web/portal.html` 一致。
// ⇒ 分区内不再是自造的 5 项摘要,而是门户的 **6 个功能页**,成对命名:
// `pa.p.*` = 卡片名 / `pa.d.*` = 卡片说明 / `pa.s.*` = 页头副标题
// (三者的中文逐字取自门户 `renderHome()` 与 `pageHead()`)
// 命名规则:`pa.<域>.<项>`;`{x}` 为占位符,由 `pafmt()` 填充。
"pa.label": "系统管理",
"pa.homeSub": "与门户同一批功能;点开任一功能,在弹窗内直接管理。",
"pa.group.svc": "服务",
"pa.group.mgmt": "管理",
"pa.back": "← 返回",
"pa.close": "关闭",
"pa.retry": "重试",
"pa.loading": "加载中…",
"pa.loadingDesc": "正在读取平台状态…",
"pa.loadFailed": "平台状态读取失败",
"pa.notAdminTitle": "系统管理仅对管理员显示",
"pa.notAdminDesc": "当前账号不是管理员,此处不展示平台信息。",
"pa.empty": "暂无数据",
"pa.done": "已更新",
"pa.failed": "操作失败",
"pa.working": "处理中…",
"pa.sk.delFail": "删除失败",
"pa.p.files": "实例管理",
"pa.d.files": "用户实例与工作区",
"pa.p.keys": "模型管理",
"pa.d.keys": "全局 API 密钥",
"pa.p.users": "用户管理",
"pa.d.users": "审批 / 禁用 / 删除",
"pa.p.skills": "技能管理",
"pa.d.skills": "共享技能上传",
"pa.p.plugins": "插件管理",
"pa.d.plugins": "功能插件投放",
"pa.p.runtime": "运行环境",
"pa.d.runtime": "共享运行时与工具",
"pa.s.files": "按用户查看工作区、启动 / 停止其 DSH 实例",
"pa.s.keys": "平台共享密钥(未自配密钥的用户默认使用;仅管理员可改)",
"pa.s.users": "审批 / 禁用 / 删除用户",
"pa.s.skills": "共享技能(所有用户可用 · 只读)",
"pa.s.plugins": "功能插件(系统外插件)的投放与管理",
"pa.s.runtime": "实例共享的运行时与工具(服务器装一次,全部用户共享)",
"pa.k.name": "名称",
"pa.k.type": "类型",
"pa.k.size": "大小",
"pa.k.mtime": "修改时间",
"pa.k.op": "操作",
"pa.k.user": "用户名",
"pa.k.role": "角色",
"pa.k.reg": "注册时间",
"pa.k.skill": "技能名",
"pa.k.desc": "说明",
"pa.k.files": "文件",
"pa.k.updated": "更新时间",
"pa.k.plugin": "插件名",
"pa.k.ver": "版本",
"pa.k.deployed": "投放时间",
"pa.k.src": "来源",
"pa.k.script": "安装/升级脚本",
"pa.k.removable": "可卸载",
"pa.k.item": "项目",
"pa.k.value": "值",
"pa.k.used": "已用",
"pa.k.pluginDesc": "插件说明",
"pa.k.cat": "分类",
"pa.k.importKind": "导入方式",
"pa.k.downloads": "下载量",
"pa.k.detail": "详情",
"pa.st.running": "运行中",
"pa.st.stopped": "未运行",
"pa.st.dir": "文件夹",
"pa.st.file": "文件",
"pa.st.enabled": "启用中",
"pa.st.removable": "可卸载",
"pa.st.required": "平台必备",
"pa.role.admin": "管理员",
"pa.role.active": "正常",
"pa.role.pending": "待审核",
"pa.role.disabled": "已禁用",
"pa.op.approve": "通过",
"pa.op.disable": "禁用",
"pa.op.enable": "恢复",
"pa.op.del": "删除",
"pa.op.add": "添加",
"pa.op.select": "启用",
"pa.op.upload": "上传 / 替换",
"pa.op.newFolder": "新建文件夹",
"pa.op.uploadFile": "上传文件",
"pa.op.launch": "启动 DSH",
"pa.op.stop": "停止",
"pa.op.openDsh": "打开 DSH ↗",
"pa.op.search": "搜索",
"pa.op.refreshDir": "重新拉取目录",
"pa.op.import": "导入到平台",
"pa.op.detail": "详情 ↗",
"pa.op.repull": "正在重新拉取…",
"pa.f.launchDir": "启动目录:",
"pa.f.targetUser": "用户:",
"pa.f.selfOnly": "仅管理员可切换用户",
"pa.f.root": "根目录",
"pa.f.rootShort": "根",
"pa.f.newFolderPrompt": "文件夹名称:",
"pa.f.alreadyRunning": "已有运行中的 DSH",
"pa.f.launchFail": "启动失败",
"pa.f.launched": "DSH 已启动",
"pa.f.uploadFail": "上传失败:",
"pa.f.createFail": "创建失败:",
"pa.key.empty": "暂无全局密钥。添加后所有用户的 DSH 才能调用模型。",
"pa.key.namePh": "名称(如 家用 / 服务器)",
"pa.key.invalid": "名称或密钥格式无效",
"pa.key.needBoth": "请填名称和密钥",
"pa.key.confirmDel": "删除这个密钥?",
"pa.u.confirmDel": "删除后不可恢复。输入用户名「{name}」以确认:",
"pa.u.mismatch": "用户名不匹配,已取消",
"pa.u.delFail": "删除失败(admin 不可删除)",
"pa.u.noUsers": "暂无用户",
"pa.sk.hint": "仅支持 .zip:包内单个顶层目录且含 SKILL.md(name 小写连字符 + description)。上传先做安全检测;同名确认后整体替换。",
"pa.sk.empty": "暂无共享技能 —— 上传 .zip 投放第一个",
"pa.sk.loadFail": "加载失败",
"pa.sk.pickZip": "请先选择 .zip 文件",
"pa.sk.zipOnly": "仅支持 .zip 文件",
"pa.sk.tooBig": "文件过大(>150MB)",
"pa.sk.detecting": "检测中…",
"pa.sk.uploadFail": "上传失败:",
"pa.sk.installed": "✓ 已安装技能「{name}」",
"pa.sk.replaceConfirm": "检测到同名技能「{name}」已存在。确认整体替换?\n\n⚠️ 替换将删除旧技能全部文件,不可恢复。",
"pa.sk.canceled": "已取消",
"pa.sk.replaceFail": "替换失败:",
"pa.sk.replaced": "✓ 已整体替换技能「{name}」",
"pa.sk.delConfirm": "删除共享技能「{name}」?所有用户将立即不可用。",
"pa.sk.deleted": "✓ 已删除",
"pa.pl.tabOfficial": "官方推荐插件",
"pa.pl.tabManual": "手动添加 / 管理",
"pa.pl.searchPh": "搜索插件名 / 描述 / npm 包名",
"pa.pl.allCat": "全部分类",
"pa.pl.onlyImportable": "只看可导入",
"pa.pl.hintOfficial": "来源:awesome-dsh-plugin 官方插件目录(dsh 官方社区精选,按下载量排序,取前 300 条)。导入只接受预构建包:官方 npm 包(registry tarball)或作者发布的 release 资产 —— 平台不执行任何第三方构建脚本。标记「需源码构建」的插件暂不支持一键导入,可自行构建后到「手动添加 / 管理」上传。列表**已按当前 dsh 版本过滤**:只显示「匹配」与「需更高版本」(后者橙色标注,升级 dsh 前用不了);仅声明了更旧版本的会被隐藏(勾「含仅兼容更旧版本的」可查)。",
"pa.pl.importNpm": "npm 预构建",
"pa.pl.importTarball": "release 资产",
"pa.pl.importSource": "需源码构建",
"pa.pl.publishing": "投放中…",
"pa.pl.published": "✓ 已投放功能插件「{name}」",
"pa.pl.replacedOk": "✓ 已替换功能插件「{name}」",
"pa.pl.trusted": "(已记录信任声明)",
"pa.pl.uploadFail": "上传失败:",
"pa.pl.wlLoading": "加载目录中…(首次会从官方站下载一次,之后读本地缓存)",
"pa.pl.wlFail": "拉取失败",
"pa.pl.wlEmpty": "无匹配",
"pa.pl.wlInfo": "清单 {total} 条 | 可导入 {importable} 条 | 匹配 {count} 条 | 显示 {shown} 条 | 目录缓存更新于 {when}(6 小时内直接复用,不联网)",
"pa.pl.wlStale": " | ⚠ 官方站暂不可达,正在使用本地缓存",
// ── 档案 92:按 dsh 版本过滤官方推荐列表(用户 2026-09-14 要求「只能显示匹配当前
// dsh 版本 和 超过当前版本 的插件,超过的要明确标注」)──
// 判定在平台侧做(官方目录**不带** dsh 版本字段 ⇒ 逐包查 npm 声明),
// 口径与实测依据见 `src/web/plugin-dsh-compat.ts` 头部注释。
"pa.pl.compatCol": "dsh 版本",
"pa.pl.showAllCompat": "含仅兼容更旧版本的",
"pa.pl.cmpMatch": "匹配",
"pa.pl.cmpNewer": "需更高版本",
"pa.pl.cmpNone": "未声明",
"pa.pl.cmpOlder": "仅兼容更旧",
"pa.pl.cmpUnknown": "未验证",
"pa.pl.cmpMatchTip": "声明范围满足当前 dsh {cur}。",
"pa.pl.cmpNewerTip": "该插件声明需要 dsh ≥ {v}(当前 {cur})—— 升级 dsh 之前装不上。",
"pa.pl.cmpNoneTip": "该插件没有声明任何 @deepseek-ai/* 依赖,据此判不了(≠ 不兼容)。",
"pa.pl.cmpOlderTip": "该插件只声明了更旧的 dsh 版本;实测未必不能跑,但不属于「兼容当前版本」。",
"pa.pl.cmpUnknownTip": "没取到该包的元数据(网络或 registry 问题),未做判断 —— 不是「不兼容」。",
"pa.pl.cmpHidden": " | 已按 dsh {cur} 过滤:**本次候选中**隐藏 {n} 个仅兼容更旧版本的插件",
"pa.pl.cmpAll": " | 未按 dsh 版本过滤",
"pa.pl.cmpWarming": " | 部分条目「未验证」:兼容判定仍在后台预热,稍后重新搜索即可",
"pa.pl.empty": "暂无已投放插件 —— 从「官方推荐插件」导入,或在上方上传 .tgz",
"pa.pl.repullOk": "✓ 目录已重新拉取(缓存已更新)",
"pa.pl.repullFail": "重新拉取失败(网络或官方站不可达)",
"pa.pl.selInfo": "已选 {n}",
"pa.pl.importPick": "请先勾选插件",
"pa.pl.importMax": "单次最多导入 20 个",
"pa.pl.importConfirm": "将 {n} 个插件投放(收录)到平台?\n\n投放后默认禁用,对用户不生效;用户需在实例「设置 → 功能插件」里自行启用。",
"pa.pl.importing": "导入中…",
"pa.pl.importResult": "投放完成:成功 {ok} / {total}",
"pa.pl.importFailTail": ",失败 {fail}",
"pa.pl.importOkBox": "✓ 已投放 {n} 个插件到平台(默认禁用,用户未启用前不生效)。切到「手动添加 / 管理」可查看或删除。",
"pa.pl.failTitle": "失败 {n} 个",
"pa.pl.importErr": "投放失败:网络错误",
"pa.pl.cardPublish": "投放插件",
"pa.pl.cardPublishSub": "上传 .tgz 收录到平台",
"pa.pl.cardPublished": "已投放插件",
"pa.pl.poolHint": "共 {n} 个 · 全部默认禁用",
"pa.pl.hintManual": "投放 ≠ 生效。这里只是把插件收录到平台,对任何用户都不生效;用户要真正用上,需在自己实例的「设置 → 功能插件」里勾选启用 —— 启用时平台才会把插件装进他的环境,重启实例后生效。",
"pa.pl.hintTgz": "仅支持 .tgz:dsh 插件 bundle(含 package.json,name 为 npm 包名)。上传先做安全检测;同名按替换策略(旧包先删再落新包)。命中 P0 规则时默认拒绝,但会逐条列出命中内容 —— 确认可信可显式声明信任后投放(会记入审计)。",
"pa.pl.pickTgz": "请先选择 .tgz 文件",
"pa.pl.tgzOnly": "仅支持 .tgz 文件",
"pa.pl.delConfirm": "删除已投放插件「{name}」?\n\n将从平台移除;已启用它的用户实例会失去该插件。",
"pa.pl.deleted": "✓ 已删除",
"pa.pl.delFail": "删除失败",
"pa.pl.scanP0": "安全检测命中 P0 规则,已拒绝投放(共 {n} 处)",
"pa.pl.scanCompat": "与当前平台 dsh 版本不兼容,已拒绝投放(共 {n} 条依据)",
"pa.pl.warnP1": "另有 {n} 条 P1 告警(不阻断)",
"pa.pl.trustPh": "信任理由(可选,会记入审计)",
"pa.pl.trustBtn": "我已逐条确认,信任并投放",
"pa.pl.trustNote": "⚠ 只在确认命中确属误报或风险可控时继续。平台会记录「谁 / 何时 / 命中什么 / 理由」。",
"pa.rt.drift": "⚠️ 检测到版本漂移(与基线不一致):{list}",
"pa.rt.driftHint": "若是有意升级,请跑 {script} 刷新基线。",
"pa.rt.ok": "✅ 版本与基线一致",
"pa.rt.okAt": "(基线时间 {t})",
"pa.rt.dir": "目录",
"pa.rt.size": "体积",
"pa.rt.changed": "最近变更",
"pa.rt.q1": "升级 / 卸载 / 迁移怎么做?",
"pa.rt.a1": "升级:改脚本里的固定版本号 → 重跑对应安装脚本(幂等 + 官方 sha256 校验)→ 再跑 {script} 刷新版本基线,否则页面会一直提示漂移。\n卸载:删 /usr/local/bin/<名字> 软链即可(「平台必备」项请勿删);\n迁移:整个 {dir} 就是一个打包单元 —— tar czf dsh-runtime.tar.gz -C /usr/local dsh-runtime,目标机解压回原位后重跑两个安装脚本(只重建软链)。\n实例内 /usr 只读 → 用户无法自行安装或修改,这里的变更对全部用户立即生效。",
"pa.rt.q2": "安装清单原文(SHARED-TOOLS.md)",
"pa.rt.loadFail": "加载失败:",
// ── 档案 87「模型设置」分区 ────────────────────────────────────────────
// 为什么平台自己做这一页:官方「设置 → 模型」页在平台环境**必然报错**(它要
// Host settings 镜像,而平台是浏览器经域名访问远程服务器 ⇒ `isLoopback=false`
// ⇒ persistence 降级 memory ⇒ 页面报「加载提供方目录失败」)。所以官方那个分区
// 被 `ensure-role-profile-patch.cjs` 对**所有角色(含 admin)**隐藏。
// 三条口径(用户 2026-09-13 定,勿再当选择题):① 条目各自开关、可同时启用
// ② admin 配的共享模型**也列在这里**、用户可开关 ③ 用哪个模型在 dsh 对话框选。
"ms.title": "模型设置",
"ms.sub": "填入各提供方的 API 密钥即可使用其模型:每条可单独开关、可同时启用;具体用哪个模型,在对话界面的模型选择器里选。保存后需重启实例生效。",
"ms.loadFailed": "加载失败,请刷新重试",
"ms.saveFailed": "保存失败,请重试",
"ms.saved": "已保存 —— 重启实例后生效",
"ms.needName": "请填写显示名称",
"ms.needKey": "请填写 API 密钥",
"ms.needUrl": "自定义提供方需要填写 API 地址。",
"ms.needModels": "自定义提供方至少需要一个模型。",
"ms.shared": "平台共享模型",
"ms.sharedOwner": "由 {who} 配置",
"ms.sharedOwnerMe": "由你配置",
"ms.sharedHint": "关掉后,你的实例不再接收平台共享的模型条目(你自己配的仍照常生效)。",
"ms.available": "可用",
"ms.unavailable": "平台未配置",
"ms.sharedOn": "停用共享模型",
"ms.sharedOff": "启用共享模型",
"ms.list": "我已添加的提供方",
"ms.empty": "还没有添加任何提供方 —— 用下面的按钮添加。",
"ms.tagBuiltin": "官方",
"ms.tagCatalog": "目录",
"ms.tagCustom": "自定义",
"ms.kindBuiltin": "DeepSeek",
"ms.builtinHint": "官方通道,无需端点",
"ms.catalogRow": "官方目录提供方(端点 / 协议 / 模型由目录提供)",
"ms.stateOn": "已启用",
"ms.stateOff": "已停用",
"ms.enable": "启用",
"ms.disable": "停用",
"ms.del": "删除",
"ms.delTitle": "删除 {who}?",
"ms.delDesc": "删除 {who} 会移除该条目的配置与已存储的 API 密钥,且无法撤销。",
"ms.delConfirm": "删除 {who}",
"ms.addProvider": "添加提供方",
"ms.addCustom": "添加自定义提供方",
"ms.provider": "提供方",
"ms.keyInput": "API 密钥",
"ms.keyPlaceholder": "输入 API 密钥",
"ms.keyPlaceholderBuiltin": "输入你的 DeepSeek API 密钥",
"ms.keyHintCatalog": "端点与 {n} 个模型由官方目录提供,你只需填 API 密钥。",
"ms.customized": "自定义设置",
"ms.displayName": "显示名称",
"ms.displayNamePlaceholder": "留空时使用提供方名称",
"ms.baseUrl": "API 地址",
"ms.baseUrlPlaceholder": "https://gateway.example/v1",
"ms.customApi": "API 协议",
"ms.customApiUnset": "未选择",
"ms.customTitle": "自定义提供方",
"ms.customRoute": "Provider ID",
"ms.customRouteHint": "以小写字母开头的标识,在请求中唯一标识该提供方,并用于派生凭据名。",
"ms.customRouteInvalid": "需以小写字母开头,之后可用小写字母、数字和短横线。",
"ms.catalogModels": "模型目录",
"ms.catalogModelsMeta": "由官方目录提供,共 {n} 个模型:",
"ms.addModel": "添加模型",
"ms.removeModel": "删除模型",
"ms.modelIdPlaceholder": "模型 ID",
"ms.modelEmpty": "尚未添加模型 —— 至少需要一个,否则模型选择器里不会出现该提供方。",
"ms.advancedHint": "其余字段在 settings.yaml 中,请直接编辑对应段。",
"ms.cancel": "取消",
"ms.create": "创建提供方",
"ms.creating": "创建中…",
"ms.save": "保存",
"ms.saving": "保存中…",
"ms.showModels": "查看模型清单",
"ms.hideModels": "收起模型清单",
"ms.optBuiltin": "DeepSeek",
"ms.builtinPickHint": "DeepSeek:官方通道,固定官方端点,无需填 API 地址。",
"ms.grpCn": "中国大陆",
"ms.grpIntl": "国际",
"ms.e.name": "名称不合法",
"ms.e.key": "API 密钥不合法(只允许字母数字与 - _ .)",
"ms.e.url": "API 地址必须以 http(s):// 开头",
"ms.e.route": "Provider ID 不合法(小写字母/数字/短横线,字母开头)",
"ms.e.api": "不支持该协议",
"ms.e.models": "请至少填一个模型 id",
"ms.e.taken": "该提供方标识已被占用,换一个",
"ms.e.provider": "官方目录里没有这个提供方",
"ms.catUnreadable": "⚠️ 平台未能读取官方提供方目录({dir})—— 当前只能使用 DeepSeek 或自定义提供方。请联系管理员检查安装路径配置。",
"ms.catEmpty": "官方提供方目录为空(该部署可能未随附 pi-ai 提供方数据)—— 当前只能使用 DeepSeek 或自定义提供方。"
};
/** English dictionary, key-set complete against zh. */
var en = {
"section.label": "Capability management",
// In-page tabs (2026-09-15): "Capability management" splits into plugins / skills,
// each operating independently. The skills tab reuses `msk.group` as its label.
"cap.tabPlugins": "Feature plugins",
// ── "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",
"empty": "An admin must publish them in the portal's plugin management first",
"noMatch": "No matching plugins",
"search": "Search by name or purpose",
"selectAll": "Select all",
"clear": "Clear",
"enabled": "Enabled",
"disabled": "Disabled",
"selected": "selected",
"apply": "Apply changes",
"applying": "Applying…",
"confirm.title": "Apply the changes?",
"confirm": "The dsh instance will restart, which interrupts the current session (any running conversation is disconnected).",
"confirm.cancel": "Cancel",
"confirm.ok": "Apply",
"confirm.overTitle": "⚠ This selection exceeds the per-instance memory limit",
"nochange": "No changes to apply",
"queued": "Queued…",
"installing": "Installing",
"configuring": "Configuring",
"restarting": "Restarting instance",
"probing": "Checking instance health",
"isolating": "Locating the failing plugin",
"applied": "✓ Applied",
"reloading": "Instance is restarting — the page will refresh automatically…",
"rejected.title": "These plugins are incompatible with the instance and were disabled automatically",
"retry": "Retry",
"requestFailed": "Request failed",
"loadFailed": "Failed to load",
"mem.title": "Memory quota",
"mem.est": "estimate (platform quota unavailable; same formula as the orchestrator)",
"mem.base": "base",
"mem.real": "actual quota",
"mem.heap": "V8 heap",
"mem.now": "now",
"mem.planned": "after selection",
"mem.limit": "hard cap",
"mem.left": "left",
"mem.over": "hit the hard cap",
"mem.tight": "close to the hard cap",
"mem.safe": "headroom is fine",
"mem.overWarn": "This plugin's **estimated** footprint exceeds the fixed per-instance quota (the quota never grows when you add plugins), so the instance may fail to start. Disable some plugins first.",
"mem.perCard": "load cost",
"pa.label": "System management",
"pa.homeSub": "The same feature set as the portal — open any item to manage it in a popup.",
"pa.group.svc": "Service",
"pa.group.mgmt": "Administration",
"pa.back": "← Back",
"pa.close": "Close",
"pa.retry": "Retry",
"pa.loading": "Loading…",
"pa.loadingDesc": "Reading platform status…",
"pa.loadFailed": "Failed to read platform status",
"pa.notAdminTitle": "System management is admin-only",
"pa.notAdminDesc": "This account is not an admin, so platform info is hidden here.",
"pa.empty": "No data",
"pa.done": "Updated",
"pa.failed": "Action failed",
"pa.working": "Working…",
"pa.sk.delFail": "Delete failed",
"pa.p.files": "Instance management",
"pa.d.files": "User instances and workspaces",
"pa.p.keys": "Model management",
"pa.d.keys": "Global API keys",
"pa.p.users": "User management",
"pa.d.users": "Approve / disable / delete",
"pa.p.skills": "Skill management",
"pa.d.skills": "Shared skill upload",
"pa.p.plugins": "Plugin management",
"pa.d.plugins": "Feature plugin publishing",
"pa.p.runtime": "Runtime",
"pa.d.runtime": "Shared runtimes and tools",
"pa.s.files": "Browse a user's workspace and start/stop their DSH instance",
"pa.s.keys": "Platform-shared key (used by users who have not set their own; admin-only)",
"pa.s.users": "Approve / disable / delete users",
"pa.s.skills": "Shared skills (available to all users · read-only)",
"pa.s.plugins": "Publishing and managing feature (out-of-tree) plugins",
"pa.s.runtime": "Runtimes and tools shared by every instance (installed once, shared by all users)",
"pa.k.name": "Name",
"pa.k.type": "Type",
"pa.k.size": "Size",
"pa.k.mtime": "Modified",
"pa.k.op": "Actions",
"pa.k.user": "Username",
"pa.k.role": "Role",
"pa.k.reg": "Registered",
"pa.k.skill": "Skill",
"pa.k.desc": "Description",
"pa.k.files": "Files",
"pa.k.updated": "Updated",
"pa.k.plugin": "Plugin",
"pa.k.ver": "Version",
"pa.k.deployed": "Published",
"pa.k.src": "Source",
"pa.k.script": "Install/upgrade script",
"pa.k.removable": "Removable",
"pa.k.item": "Item",
"pa.k.value": "Value",
"pa.k.used": "Used",
"pa.k.pluginDesc": "Plugin",
"pa.k.cat": "Category",
"pa.k.importKind": "Import",
"pa.k.downloads": "Downloads",
"pa.k.detail": "Details",
"pa.st.running": "Running",
"pa.st.stopped": "Stopped",
"pa.st.dir": "Folder",
"pa.st.file": "File",
"pa.st.enabled": "Enabled",
"pa.st.removable": "Removable",
"pa.st.required": "Platform-required",
"pa.role.admin": "Admin",
"pa.role.active": "Active",
"pa.role.pending": "Pending",
"pa.role.disabled": "Disabled",
"pa.op.approve": "Approve",
"pa.op.disable": "Disable",
"pa.op.enable": "Restore",
"pa.op.del": "Delete",
"pa.op.add": "Add",
"pa.op.select": "Enable",
"pa.op.upload": "Upload / replace",
"pa.op.newFolder": "New folder",
"pa.op.uploadFile": "Upload file",
"pa.op.launch": "Launch DSH",
"pa.op.stop": "Stop",
"pa.op.openDsh": "Open DSH ↗",
"pa.op.search": "Search",
"pa.op.refreshDir": "Re-fetch catalog",
"pa.op.import": "Import to platform",
"pa.op.detail": "Details ↗",
"pa.op.repull": "Re-fetching…",
"pa.f.launchDir": "Launch folder:",
"pa.f.targetUser": "User:",
"pa.f.selfOnly": "Only an admin can switch users",
"pa.f.root": "root",
"pa.f.rootShort": "root",
"pa.f.newFolderPrompt": "Folder name:",
"pa.f.alreadyRunning": "A DSH instance is already running",
"pa.f.launchFail": "Launch failed",
"pa.f.launched": "DSH launched",
"pa.f.uploadFail": "Upload failed: ",
"pa.f.createFail": "Create failed: ",
"pa.key.empty": "No global key yet. Add one so every user's DSH can call the model.",
"pa.key.namePh": "Name (e.g. home / server)",
"pa.key.invalid": "Invalid name or key format",
"pa.key.needBoth": "Enter both name and key",
"pa.key.confirmDel": "Delete this key?",
"pa.u.confirmDel": "Deletion cannot be undone. Type the username \"{name}\" to confirm:",
"pa.u.mismatch": "Username mismatch — cancelled",
"pa.u.delFail": "Delete failed (admin cannot be deleted)",
"pa.u.noUsers": "No users",
"pa.sk.hint": ".zip only: a single top-level directory containing SKILL.md (lowercase-hyphenated name + description). Uploads are security-scanned first; a same-name skill is replaced as a whole after confirmation.",
"pa.sk.empty": "No shared skill yet — upload a .zip to publish the first one",
"pa.sk.loadFail": "Failed to load",
"pa.sk.pickZip": "Choose a .zip file first",
"pa.sk.zipOnly": ".zip files only",
"pa.sk.tooBig": "File too large (>150MB)",
"pa.sk.detecting": "Scanning…",
"pa.sk.uploadFail": "Upload failed: ",
"pa.sk.installed": "✓ Installed skill \"{name}\"",
"pa.sk.replaceConfirm": "A skill named \"{name}\" already exists. Replace it entirely?\n\n⚠️ Replacement deletes all of the old skill's files. This cannot be undone.",
"pa.sk.canceled": "Cancelled",
"pa.sk.replaceFail": "Replace failed: ",
"pa.sk.replaced": "✓ Replaced skill \"{name}\"",
"pa.sk.delConfirm": "Delete shared skill \"{name}\"? It becomes unavailable to all users immediately.",
"pa.sk.deleted": "✓ Deleted",
"pa.pl.tabOfficial": "Recommended plugins",
"pa.pl.tabManual": "Manual upload / manage",
"pa.pl.searchPh": "Search name / description / npm package",
"pa.pl.allCat": "All categories",
"pa.pl.onlyImportable": "Importable only",
"pa.pl.hintOfficial": "Source: the awesome-dsh-plugin official catalog (community picks, sorted by downloads, top 300). Import accepts prebuilt packages only — official npm tarballs or author release assets; the platform runs no third-party build scripts. Entries marked \"needs source build\" cannot be imported directly; build them yourself and upload under \"Manual upload / manage\". This list is **filtered by the current dsh version**: only \"Match\" and \"Needs newer\" entries are shown (the latter in orange — unusable until dsh is upgraded); entries that only declare older versions are hidden (tick \"Include older-only\" to see them).",
"pa.pl.importNpm": "npm prebuilt",
"pa.pl.importTarball": "release asset",
"pa.pl.importSource": "needs source build",
"pa.pl.publishing": "Publishing…",
"pa.pl.published": "✓ Published feature plugin \"{name}\"",
"pa.pl.replacedOk": "✓ Replaced feature plugin \"{name}\"",
"pa.pl.trusted": " (trust declaration recorded)",
"pa.pl.uploadFail": "Upload failed: ",
"pa.pl.wlLoading": "Loading catalog… (downloaded from the official site on first open, cached afterwards)",
"pa.pl.wlFail": "Fetch failed",
"pa.pl.wlEmpty": "No match",
"pa.pl.wlInfo": "{total} listed | {importable} importable | {count} matched | {shown} shown | catalog cached {when} (reused within 6 hours, no network)",
"pa.pl.wlStale": " | ⚠ official site unreachable, using the local cache",
"pa.pl.compatCol": "dsh version",
"pa.pl.showAllCompat": "Include older-only",
"pa.pl.cmpMatch": "Match",
"pa.pl.cmpNewer": "Needs newer",
"pa.pl.cmpNone": "Undeclared",
"pa.pl.cmpOlder": "Older only",
"pa.pl.cmpUnknown": "Unverified",
"pa.pl.cmpMatchTip": "The declared range covers the current dsh {cur}.",
"pa.pl.cmpNewerTip": "This plugin requires dsh ≥ {v} (current {cur}) — it cannot be installed before upgrading.",
"pa.pl.cmpNoneTip": "This plugin declares no @deepseek-ai/* dependency, so nothing can be judged from it (≠ incompatible).",
"pa.pl.cmpOlderTip": "This plugin only declares older dsh versions; it may still run, but it is not declared-compatible with the current one.",
"pa.pl.cmpUnknownTip": "Package metadata could not be fetched (network or registry issue) — no judgement was made, which is not the same as incompatible.",
"pa.pl.cmpHidden": " | filtered by dsh {cur}: {n} older-only plugin(s) hidden **in this candidate set**",
"pa.pl.cmpAll": " | not filtered by dsh version",
"pa.pl.cmpWarming": " | some entries are “Unverified”: the compatibility check is still warming up in the background — search again shortly",
"pa.pl.empty": "No published plugin yet — import from \"Recommended plugins\" or upload a .tgz above",
"pa.pl.repullOk": "✓ Catalog re-fetched (cache updated)",
"pa.pl.repullFail": "Re-fetch failed (network or official site unreachable)",
"pa.pl.selInfo": "{n} selected",
"pa.pl.importPick": "Select at least one plugin",
"pa.pl.importMax": "Up to 20 plugins per import",
"pa.pl.importConfirm": "Publish (list) {n} plugins on the platform?\n\nThey stay disabled and have no effect until each user enables them under Settings → Feature plugins.",
"pa.pl.importing": "Importing…",
"pa.pl.importResult": "Publish finished: {ok} / {total} succeeded",
"pa.pl.importFailTail": ", {fail} failed",
"pa.pl.importOkBox": "✓ Published {n} plugins to the platform (disabled by default; no effect until a user enables them). Switch to \"Manual upload / manage\" to inspect or delete them.",
"pa.pl.failTitle": "{n} failed",
"pa.pl.importErr": "Publish failed: network error",
"pa.pl.cardPublish": "Publish a plugin",
"pa.pl.cardPublishSub": "Upload a .tgz to list it on the platform",
"pa.pl.cardPublished": "Published plugins",
"pa.pl.poolHint": "{n} total · all disabled by default",
"pa.pl.hintManual": "Publishing ≠ enabling. This only lists the plugin on the platform — it stays inert for every user. To actually use it, a user must tick it under Settings → Feature plugins in their own instance; the platform then installs it into their environment and it takes effect after an instance restart.",
"pa.pl.hintTgz": ".tgz only: a dsh plugin bundle (with package.json whose name is the npm package name). Uploads are security-scanned first; same-name uploads follow the replace policy (old package removed before the new one lands). A P0 hit is rejected by default, but every hit is listed — if you trust it, declare that explicitly and it will be published (and audited).",
"pa.pl.pickTgz": "Choose a .tgz file first",
"pa.pl.tgzOnly": ".tgz files only",
"pa.pl.delConfirm": "Delete published plugin \"{name}\"?\n\nIt will be removed from the platform; instances that enabled it will lose it.",
"pa.pl.deleted": "✓ Deleted",
"pa.pl.delFail": "Delete failed",
"pa.pl.scanP0": "Security scan hit P0 rules — publishing rejected ({n} findings)",
"pa.pl.scanCompat": "Incompatible with the platform's current dsh version — publishing rejected ({n} findings)",
"pa.pl.warnP1": "{n} further P1 warnings (non-blocking)",
"pa.pl.trustPh": "Reason for trusting (optional, audited)",
"pa.pl.trustBtn": "I reviewed every finding — trust and publish",
"pa.pl.trustNote": "⚠ Continue only if the hits are genuine false positives or the risk is acceptable. The platform records who / when / what was hit / why.",
"pa.rt.drift": "⚠️ Version drift detected (differs from the baseline): {list}",
"pa.rt.driftHint": "If this upgrade was intentional, run {script} to refresh the baseline.",
"pa.rt.ok": "✅ Versions match the baseline",
"pa.rt.okAt": " (baseline {t})",
"pa.rt.dir": "Directory",
"pa.rt.size": "Size",
"pa.rt.changed": "Last change",
"pa.rt.q1": "How do I upgrade / uninstall / migrate?",
"pa.rt.a1": "Upgrade: bump the pinned version in the script → re-run that install script (idempotent + official sha256 check) → run {script} to refresh the baseline, otherwise this page keeps reporting drift.\nUninstall: just remove the /usr/local/bin/<name> symlink (do not remove platform-required entries).\nMigrate: {dir} is a single packaging unit — tar czf dsh-runtime.tar.gz -C /usr/local dsh-runtime, unpack it back in place on the target and re-run both install scripts (they only rebuild symlinks).\n/usr is read-only inside instances → users cannot install or modify anything; changes here apply to all users immediately.",
"pa.rt.q2": "Manifest source (SHARED-TOOLS.md)",
"pa.rt.loadFail": "Failed to load: ",
"ms.title": "Model settings",
"ms.sub": "Enter each provider's API key to use its models: every entry has its own switch and several can be on at once. Pick which model to use in the chat model selector. Restart the instance for changes to take effect.",
"ms.loadFailed": "Failed to load — refresh and try again",
"ms.saveFailed": "Save failed — try again",
"ms.saved": "Saved — restart the instance to apply",
"ms.needName": "Please fill in a display name",
"ms.needKey": "Please fill in an API key",
"ms.needUrl": "A custom provider needs a base URL.",
"ms.needModels": "A custom provider needs at least one model.",
"ms.shared": "Platform-shared model",
"ms.sharedOwner": "Configured by {who}",
"ms.sharedOwnerMe": "Configured by you",
"ms.sharedHint": "When off, your instance stops receiving the platform-shared model entries (your own entries still apply).",
"ms.available": "Available",
"ms.unavailable": "Not configured",
"ms.sharedOn": "Disable shared model",
"ms.sharedOff": "Enable shared model",
"ms.list": "My providers",
"ms.empty": "No providers yet — use the buttons below to add one.",
"ms.tagBuiltin": "Official",
"ms.tagCatalog": "Catalog",
"ms.tagCustom": "Custom",
"ms.kindBuiltin": "DeepSeek",
"ms.builtinHint": "Official channel; no endpoint needed",
"ms.catalogRow": "Catalog provider (endpoint/protocol/models from the catalog)",
"ms.stateOn": "Enabled",
"ms.stateOff": "Disabled",
"ms.enable": "Enable",
"ms.disable": "Disable",
"ms.del": "Delete",
"ms.delTitle": "Delete {who}?",
"ms.delDesc": "Deleting {who} removes its configuration and stored API key. This cannot be undone.",
"ms.delConfirm": "Delete {who}",
"ms.addProvider": "Add provider",
"ms.addCustom": "Add a custom provider",
"ms.provider": "Provider",
"ms.keyInput": "API key",
"ms.keyPlaceholder": "Enter your API key",
"ms.keyPlaceholderBuiltin": "Enter your DeepSeek API key",
"ms.keyHintCatalog": "Endpoint and {n} models come from the official catalog — you only need the API key.",
"ms.customized": "Customized settings",
"ms.displayName": "Display name",
"ms.displayNamePlaceholder": "Uses the provider name when empty",
"ms.baseUrl": "Base URL",
"ms.baseUrlPlaceholder": "https://gateway.example/v1",
"ms.customApi": "API protocol",
"ms.customApiUnset": "Not selected",
"ms.customTitle": "Custom provider",
"ms.customRoute": "Provider ID",
"ms.customRouteHint": "Lowercase identifier, starting with a letter, that uniquely names this provider in requests and as its credential name.",
"ms.customRouteInvalid": "Start with a lowercase letter; then lowercase letters, digits, and dashes.",
"ms.catalogModels": "Model catalog",
"ms.catalogModelsMeta": "Supplied by the official catalog — {n} models in total:",
"ms.addModel": "Add model",
"ms.removeModel": "Delete model",
"ms.modelIdPlaceholder": "Model ID",
"ms.modelEmpty": "No models yet — at least one is required, otherwise this provider will not show up in the model selector.",
"ms.advancedHint": "Other fields live in settings.yaml; edit that section directly.",
"ms.cancel": "Cancel",
"ms.create": "Create provider",
"ms.creating": "Creating…",
"ms.save": "Save",
"ms.saving": "Saving…",
"ms.showModels": "Show models",
"ms.hideModels": "Hide models",
"ms.optBuiltin": "DeepSeek",
"ms.builtinPickHint": "DeepSeek: the official channel with a fixed endpoint — no base URL needed.",
"ms.grpCn": "Mainland China",
"ms.grpIntl": "International",
"ms.e.name": "Invalid name",
"ms.e.key": "Invalid API key (letters, digits, - _ . only)",
"ms.e.url": "The base URL must start with http(s)://",
"ms.e.route": "Invalid provider ID (lowercase letters, digits, dashes; must start with a letter)",
"ms.e.api": "Unsupported protocol",
"ms.e.models": "Add at least one model id",
"ms.e.taken": "That provider ID is taken — pick another",
"ms.e.provider": "That provider is not in the official catalog",
"ms.catUnreadable": "⚠️ The platform could not read the official provider catalog ({dir}) — only DeepSeek and custom providers are available. Ask your administrator to check the install-path configuration.",
"ms.catEmpty": "The official provider catalog is empty (this deployment may not ship the pi-ai provider data) — only DeepSeek and custom providers are available."
};
/**
* Required services (cordis fiber inject). `locale` is required for the
* dictionaries; the slot registry for the settings section.
*/
var inject = ["slots", "locale"];
/** 应用成功后探活并刷新(重启会换端口;探通了再 reload,避免白屏)。 */
function waitAndReload(attempts) {
if (attempts <= 0) {
window.location.reload();
return;
}
fetch(window.location.origin + "/", { cache: "no-store" })
.then(function (r) {
if (r.ok) window.location.reload();
else setTimeout(function () { waitAndReload(attempts - 1); }, 1500);
})
.catch(function () { setTimeout(function () { waitAndReload(attempts - 1); }, 1500); });
}
function apply(ctx) {
ctx.effect(function () {
return ctx.locale.register(NS, { zh: zh, en: en });
}, "business-plugins: dictionaries");
// 内联样式表达不了 `:hover`,而 06-工作台UI规范要求「列表行 hover 反馈」(§4.3)
// 与「hover 才提示可点」(§5)。这里注入一小段 CSS,disposer 随 fiber 移除 ——
// 不引外部样式表,也不污染宿主全局(选择器统一带 bp- 前缀)。
ctx.effect(function () {
var el = document.createElement("style");
el.setAttribute("data-bp-style", "1");
el.textContent = [
".bp-row{transition:background .18s cubic-bezier(.16,1,.3,1),border-color .18s cubic-bezier(.16,1,.3,1),box-shadow .18s cubic-bezier(.16,1,.3,1)}",
".bp-row:hover{border-color:var(--dsw-alias-brand-primary, #2f6fed);box-shadow:0 4px 12px rgba(47,111,237,.15)}",
".bp-btn{transition:border-color .18s cubic-bezier(.16,1,.3,1),color .18s cubic-bezier(.16,1,.3,1)}",
".bp-btn:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary, #2f6fed);color:var(--dsw-alias-brand-primary, #2f6fed)}",
".bp-row input[type=checkbox]{cursor:pointer;margin:0}",
// 2026-09-13 第 2 轮 ·「系统管理」视觉层 —— **照抄门户 `web/portal.html` 的对应组件**
// (用户要求:「说了要用之前门户的里面的对应样式」)。逐条对应关系,便于核对:
// .pa-hd / .pa-sub ← .page-title(20-600) / .page-sub(13, dim)
// .pa-sec ← .home-section-title(13, dim, 600)
// .pa-grid ← .nav-grid(repeat(auto-fill,minmax(200px,1fr)) / gap 14px)
// .pa-card ← .nav-card(r10 / 18px 16px / hover: 蓝边 + 0 4px 12px rgba(47,111,237,.12) + 上浮 -2px)
// .pa-card .pa-ic/.pa-t/.pa-d ← .nav-card .ic(主色) / .t(15-600) / .d(12, dim)
// .pa-cnt ← .pg-tab .pg-cnt(12-600 胶囊 / 主色底)
// .pa-wrap ← .table-wrap(r8 / 白底 / 1px 边框)
// .pa-tbl th/td ← table.tbl th(灰底 8px 10px 600) / td(7px 10px) / 行 hover 浅蓝
// .pa-badge ← .badge(2px 8px / r10 / 13px / 四色)
// .pa-sm ← .btn-sm(3px 10px / r6 / 14px;.danger 红字红边)
// .pa-empty-cell ← 门户 `emptyRow()`(表格内空态:居中 + 40px 上下留白)
// .pa-card-h ← .card-h(15-600 + .sub 13 dim)
// .pa-modal ← 门户无弹窗 ⇒ 面板取 .card(r10 / 18px) + 门户家族阴影(design.css `--shadow`)
// ⚠️ 唯一差异:颜色走 dsh 官方 token `--dsw-*`(本 section 嵌在 dsh 面板内、须随 dsh 主题;
// 门户那套 `--bp-*` 是给亮色静态页的硬编码)。字号/间距/圆角/动效与门户**逐值一致**。
".pa-hd{font-size:20px;font-weight:600;margin:0 0 4px}",
".pa-sub{font-size:13px;color:var(--dsw-alias-label-tertiary,#6b7280);margin:0 0 20px}",
".pa-sec{font-size:13px;font-weight:600;color:var(--dsw-alias-label-tertiary,#6b7280);margin:0 0 10px}",
".pa-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px}",
".pa-card{display:flex;flex-direction:column;gap:6px;background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,#e3e6ea);border-radius:10px;padding:18px 16px;cursor:pointer;transition:all .15s}",
".pa-card:hover{border-color:var(--dsw-alias-brand-primary,#2f6fed);box-shadow:0 4px 12px rgba(47,111,237,.12);transform:translateY(-2px)}",
".pa-card:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#2f6fed);outline-offset:2px}",
".pa-card .pa-ic{color:var(--dsw-alias-brand-primary,#2f6fed);font-size:18px;line-height:1}",
".pa-card .pa-t{font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary,#24292f)}",
".pa-card .pa-d{font-size:12px;color:var(--dsw-alias-label-tertiary,#6b7280)}",
".pa-cnt{display:inline-block;min-width:18px;padding:1px 6px;margin-left:6px;border-radius:9px;background:rgba(47,111,237,.10);color:var(--dsw-alias-brand-primary,#2f6fed);font-size:12px;font-weight:600;line-height:16px;text-align:center}",
".pa-wrap{overflow:auto;border:1px solid var(--dsw-alias-border-l2,#e3e6ea);border-radius:8px;background:var(--dsw-alias-bg-layer-1,#fff)}",
// 官方推荐插件列表:**拉高到「离弹窗底部约 100px」**(2026-09-13 用户要求)。
// 弹窗高 = min(92vh,1040px);表上方固定消耗 ≈470px(弹窗头 + 页签 + 工具条 + 说明 + 信息行),
// 表下方还有按钮行 + 结果区 + 内边距 ⇒ 用 `92vh - 470px` 反推,再夹到 [280, 580]:
// 下限 280px 防小屏被压成一条线;上限 580px 对应弹窗触顶 1040px 时的可用高度。
".pa-plist{max-height:max(280px,min(calc(92vh - 470px),580px))}",
".pa-tbl{width:100%;border-collapse:collapse;font-size:15px}",
".pa-tbl th{background:var(--dsw-alias-bg-layer-2,#f6f8fa);padding:8px 10px;text-align:left;font-weight:600;white-space:nowrap;border-bottom:1px solid var(--dsw-alias-border-l2,#e3e6ea)}",
".pa-tbl td{padding:7px 10px;border-bottom:1px solid var(--dsw-alias-border-l2,#e3e6ea);color:var(--dsw-alias-label-primary,#24292f)}",
".pa-tbl tbody tr:last-child td{border-bottom:none}",
".pa-tbl tbody tr:hover{background:rgba(47,111,237,.06)}",
".pa-tbl td.pa-num{text-align:right;font-size:13px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary,#6b7280)}",
".pa-tbl td.pa-dim{color:var(--dsw-alias-label-tertiary,#6b7280)}",
".pa-badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:13px}",
".pa-badge.admin{background:rgba(47,111,237,.10);color:var(--dsw-alias-brand-primary,#2f6fed)}",
".pa-badge.active{background:rgba(26,127,55,.10);color:#1a7f37}",
".pa-badge.pending{background:rgba(232,89,12,.10);color:#e8590c}",
".pa-badge.disabled{background:rgba(107,114,128,.10);color:var(--dsw-alias-label-tertiary,#6b7280)}",
".pa-sm{display:inline-flex;align-items:center;padding:3px 10px;font-size:14px;border-radius:6px;cursor:pointer;border:1px solid var(--dsw-alias-border-l2,#e3e6ea);background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#24292f);transition:all .15s}",
".pa-sm:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary,#2f6fed);color:var(--dsw-alias-brand-primary,#2f6fed)}",
".pa-sm.danger{color:#d93026}",
".pa-sm.danger:hover:not(:disabled){border-color:#d93026;color:#d93026}",
".pa-sm:disabled{opacity:.5;cursor:default}",
".pa-tbl td.pa-empty-cell{text-align:center;color:var(--dsw-alias-label-tertiary,#6b7280);padding:40px 10px}",
".pa-card-h{display:flex;align-items:baseline;gap:10px;font-size:15px;font-weight:600;margin:0 0 12px}",
".pa-card-h .sub{font-size:13px;font-weight:400;color:var(--dsw-alias-label-tertiary,#6b7280)}",
".pa-overlay{position:fixed;inset:0;z-index:9999;background:rgba(15,28,51,.35);display:flex;align-items:center;justify-content:center;padding:24px}",
".pa-modal{background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,#e3e6ea);border-radius:10px;box-shadow:0 18px 50px -18px rgba(9,24,58,.35);animation:paIn .18s cubic-bezier(.16,1,.3,1)}",
"@keyframes paIn{from{opacity:0;transform:translateY(6px) scale(.985)}to{opacity:1;transform:none}}",
// ── 第 3 轮(2026-09-13):门户 `web/portal.html` **页面级组件**逐条译为 `.pa-*` ──
// 译法与第 2 轮一致:**字号 / 间距 / 圆角 / 动效逐值照抄**,颜色换 dsh 官方 token。
// 对应关系(门户 → 本节):
// .card → .pa-box | .btn / .btn-primary / .btn:disabled → .pa-btn*
// .page-head → .pa-phead | #view 内边距 → .pa-page
// .pg-tabs/.pg-tab/.pg-cnt → .pa-tabs/.pa-tab/.pa-tcnt(下划线式页内 tab)
// .dsh-bar/.dot → .pa-dshbar/.pa-dot | .pathbar/.crumb → .pa-pathbar/.pa-crumb
// .upload-row/.hint → .pa-row/.pa-hint | .key-row → .pa-keyrow
// .wl-cell/.wl-desc/.wl-meta/.wl-cat/.wl-link → .pa-wl*
// .empty → .pa-empty | .toast → .pa-toast | td.desc/td.act → td.pa-desc/td.pa-act
".pa-box{background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,#e3e6ea);border-radius:10px;padding:18px}",
".pa-btn{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;border-radius:8px;font-size:14px;cursor:pointer;border:1px solid var(--dsw-alias-border-l2,#e3e6ea);background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#24292f);transition:all .15s}",
".pa-btn:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary,#2f6fed);color:var(--dsw-alias-brand-primary,#2f6fed)}",
".pa-btn.pa-primary{background:var(--dsw-alias-brand-primary,#2f6fed);border-color:var(--dsw-alias-brand-primary,#2f6fed);color:#fff}",
".pa-btn.pa-primary:hover:not(:disabled){background:#2a5fd0;color:#fff}",
".pa-btn:disabled{opacity:.5;cursor:default}",
".pa-phead{display:flex;align-items:center;gap:14px;flex:1;min-width:0}",
".pa-phead .pa-hd{margin:0}",
".pa-phead .pa-sub{margin:4px 0 0}",
".pa-page{padding:18px 20px 28px}",
".pa-mhead{display:flex;align-items:center;gap:14px;padding:18px 20px 14px;border-bottom:1px solid var(--dsw-alias-border-l2,#e3e6ea);flex:none}",
".pa-mbody{flex:1;min-height:0;overflow:auto}",
// 「需要多大就多大」:宽度对齐门户功能页(`.page` max-width 1440)、高度吃满视口。
// 门户首页是 960,但弹窗只在**打开具体功能页**时出现 ⇒ 统一取功能页宽度即可。
".pa-modal.pa-wide{width:min(1440px,96vw);height:min(92vh,1040px);display:flex;flex-direction:column;overflow:hidden;padding:0}",
".pa-tabs{display:flex;gap:22px;border-bottom:1px solid var(--dsw-alias-border-l2,#e3e6ea);margin:0 0 16px}",
".pa-tab{padding:8px 2px 10px;font-size:15px;color:var(--dsw-alias-label-tertiary,#6b7280);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;transition:color .15s,border-color .15s;user-select:none;white-space:nowrap}",
".pa-tab:hover{color:var(--dsw-alias-brand-primary,#2f6fed)}",
".pa-tab.pa-on{color:var(--dsw-alias-brand-primary,#2f6fed);font-weight:600;border-bottom-color:var(--dsw-alias-brand-primary,#2f6fed)}",
".pa-tcnt{display:inline-block;min-width:18px;padding:1px 6px;margin-left:6px;border-radius:9px;background:rgba(47,111,237,.10);color:var(--dsw-alias-brand-primary,#2f6fed);font-size:12px;font-weight:600;line-height:16px;text-align:center}",
".pa-tab:not(.pa-on) .pa-tcnt{background:rgba(107,114,128,.12);color:var(--dsw-alias-label-tertiary,#6b7280)}",
".pa-dshbar{display:flex;align-items:center;gap:12px;background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,#e3e6ea);border-radius:10px;padding:14px 16px;margin-bottom:16px}",
".pa-dshbar .pa-stat{font-size:14px}",
".pa-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}",
".pa-dot.pa-on{background:#1fb56a}",
".pa-dot.pa-off{background:#c4c9d0}",
".pa-pathbar{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap}",
".pa-crumbs{display:flex;align-items:center;gap:4px}",
".pa-crumb{color:var(--dsw-alias-brand-primary,#2f6fed);cursor:pointer;font-size:14px}",
".pa-crumb:hover{text-decoration:underline}",
".pa-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:6px}",
".pa-row input[type=file]{flex:1;min-width:220px;padding:8px;border:1px solid var(--dsw-alias-border-l2,#e3e6ea);border-radius:8px;font-size:14px;background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#24292f)}",
".pa-hint{font-size:12px;color:var(--dsw-alias-label-tertiary,#6b7280);margin:0 0 14px;line-height:1.6}",
".pa-keyrow{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--dsw-alias-border-l2,#e3e6ea)}",
".pa-keyrow:last-child{border-bottom:none}",
".pa-wlcell{white-space:normal}",
".pa-wldesc{font-size:14px;line-height:1.5;color:var(--dsw-alias-label-primary,#24292f);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;max-width:620px}",
".pa-wldesc.pa-fb{font-weight:600}",
".pa-wlmeta{font-size:12px;color:var(--dsw-alias-label-tertiary,#6b7280);margin-top:3px;max-width:520px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
".pa-wlcat{font-size:12px;color:var(--dsw-alias-label-tertiary,#6b7280);white-space:nowrap}",
".pa-wllink{text-decoration:none;white-space:nowrap}",
".pa-tbl td.pa-desc{max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary,#6b7280)}",
".pa-tbl td.pa-act,.pa-tbl th.pa-act{text-align:center}",
".pa-tbl td.pa-cell{white-space:normal}",
".pa-csep{color:var(--dsw-alias-label-tertiary,#6b7280)}",
// 门户的文件夹行没有任何可点提示(无手型 / 无颜色)⇒ 弹窗内补上,否则文件树没法导航。
".pa-dir{cursor:pointer;color:var(--dsw-alias-brand-primary,#2f6fed)}",
".pa-dir:hover{text-decoration:underline}",
".pa-empty{padding:48px 20px;text-align:center;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:14px}",
".pa-toast{position:fixed;bottom:32px;left:50%;transform:translateX(-50%);background:#24292f;color:#fff;padding:10px 18px;border-radius:8px;font-size:14px;z-index:10001;display:none;white-space:pre-wrap;max-width:90vw}",
".pa-toast.pa-show{display:block}",
".pa-input{flex:1;min-width:120px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2,#e3e6ea);border-radius:8px;font-size:14px;background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#24292f)}",
"select.pa-input{cursor:pointer}",
".pa-check{display:inline-flex;align-items:center;gap:6px;font-size:14px;color:var(--dsw-alias-label-tertiary,#6b7280);cursor:pointer}",
".pa-note{border:1px solid var(--dsw-alias-border-l2,#e3e6ea);border-radius:8px;padding:12px 14px;font-size:13px;line-height:1.6}",
// ── 档案 91(2026-09-14):「模型设置」页面视觉层 —— **照抄官方 `dsh-client-ui-settings-models`
// 的 CSS 模块**(官方类名带 CSS Module 哈希前缀 `zGbnIq_`,此处去哈希、前缀改 `ms-`)。
// 圆角 / 字号 / 行高 / 内边距 / gap **逐值照抄**;颜色走同一批 dsh token `--dsw-*`
// (官方原文用的就是这批 token,故连 token 名都一致),只补一个硬编码兜底色。
//
// 为什么把表格换成卡片行:表格 4 列是**自动列宽**,「DeepSeek」(当时叫「内置 DeepSeek」)和「停用/删除」
// 两个按钮会被挤到换行(2026-09-14 用户截图);官方这套 `rowHead + margin-left:auto`
// +`white-space:nowrap` 的卡片行**结构上不可能换行**,顺带把整页拉齐到官方观感。
".ms-rows{display:flex;flex-direction:column;gap:8px;margin:12px 0 16px;padding:0;list-style:none}",
".ms-rowCard{border:.5px solid var(--dsw-alias-border-l4,#e3e6ea);border-radius:16px;display:flex;flex-direction:column;gap:12px;padding:12px 14px}",
".ms-rowHead{display:flex;align-items:center;gap:10px}",
".ms-rowIdentity{display:inline-flex;align-items:center;gap:6px;min-width:0}",
".ms-rowName{color:var(--dsw-alias-label-primary,#24292f);font-size:14px;font-weight:500;line-height:22px;white-space:nowrap}",
".ms-rowTag{border:.5px solid var(--dsw-alias-border-l3,#d0d5dd);color:var(--dsw-alias-label-secondary,#6b7280);border-radius:4px;flex:none;padding:1px 6px;font-size:11px;line-height:16px;white-space:nowrap}",
".ms-dot{box-sizing:border-box;border-radius:50%;flex:none;width:8px;height:8px;display:inline-block}",
".ms-dot.on{background:var(--dsw-alias-state-success-primary,#1a7f37)}",
".ms-dot.idle{background:var(--dsw-alias-label-dimmed,#c4c9d0)}",
".ms-rowActions{display:inline-flex;align-items:center;gap:4px;margin-left:auto;white-space:nowrap}",
".ms-rowMeta{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,#6b7280);word-break:break-all}",
".ms-btn{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;height:36px;padding:0 14px;border:none;border-radius:18px;background:transparent;color:var(--dsw-alias-label-primary,#24292f);font-family:inherit;font-size:14px;line-height:22px;cursor:pointer;transition:background .15s,border-color .15s,color .15s}",
".ms-btn.outline{border:.5px solid var(--dsw-alias-border-l3,#d0d5dd)}",
".ms-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(9,24,58,.05))}",
".ms-btn.primary{background:var(--dsw-alias-button-primary-fill,#2f6fed);color:#fff}",
".ms-btn.danger{color:var(--dsw-alias-state-error-primary,#d93026)}",
".ms-btn:disabled{opacity:.4;cursor:default}",
".ms-rowActions .ms-btn{height:28px;border-radius:14px;padding:0 10px;font-size:12px;line-height:18px}",
".ms-addBlock{display:flex;flex-direction:column;gap:12px}",
".ms-addActions{display:flex;flex-wrap:wrap;gap:10px}",
".ms-addBtn{box-sizing:border-box;flex:1 1 0;min-width:180px;height:44px;display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px dashed var(--dsw-alias-border-l3,#d0d5dd);border-radius:16px;background:transparent;color:var(--dsw-alias-label-primary,#24292f);font-family:inherit;font-size:14px;cursor:pointer;transition:background .15s,border-color .15s,color .15s}",
".ms-addBtn:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary,#2f6fed);color:var(--dsw-alias-brand-primary,#2f6fed);background:var(--dsw-alias-interactive-bg-hover,rgba(47,111,237,.05))}",
".ms-addBtn:disabled{opacity:.4;cursor:default}",
".ms-card{background:var(--dsw-alias-bg-layer-2,#f6f8fa);border-radius:12px;display:flex;flex-direction:column;gap:14px;padding:14px 16px}",
".ms-field{display:flex;flex-direction:column;gap:6px}",
".ms-fieldLabel{display:inline-flex;align-items:center;gap:10px;color:var(--dsw-alias-label-secondary,#6b7280);font-size:12px;font-weight:500;line-height:18px}",
".ms-input{box-sizing:border-box;width:100%;height:32px;padding:0 10px;border:.5px solid var(--dsw-alias-border-l4,#e3e6ea);border-radius:8px;background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#24292f);font-family:inherit;font-size:14px;line-height:22px}",
".ms-input:focus{border-color:var(--dsw-alias-brand-primary,#2f6fed);outline:none}",
".ms-input::placeholder{color:var(--dsw-alias-label-dimmed,#9aa1ab)}",
"select.ms-input{cursor:pointer;max-width:320px}",
".ms-editorActions{display:flex;justify-content:flex-end;gap:8px}",
".ms-hint{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,#6b7280)}",
".ms-err{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-state-error-primary,#d93026)}",
".ms-ok{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-state-success-primary,#1a7f37)}",
".ms-details{border-top:.5px solid var(--dsw-alias-border-l2,#e3e6ea);padding-top:10px}",
".ms-summary{display:flex;align-items:center;gap:6px;width:fit-content;margin-left:-4px;padding:2px 4px;border-radius:6px;color:var(--dsw-alias-label-secondary,#6b7280);font-size:12px;font-weight:500;line-height:18px;cursor:pointer;list-style:none}",
".ms-summary::-webkit-details-marker{display:none}",
".ms-summary::before{content:'';width:5px;height:5px;border-bottom:1.5px solid;border-right:1.5px solid;transform:rotate(-45deg) translate(-1px,-1px);transition:transform .12s}",
".ms-details[open]>.ms-summary::before{transform:rotate(45deg) translate(-1px,-1px)}",
".ms-summary:hover{color:var(--dsw-alias-label-primary,#24292f)}",
".ms-detailsBody{display:flex;flex-direction:column;gap:12px;padding-top:12px}",
".ms-modelList{display:flex;flex-direction:column;gap:8px}",
".ms-modelEntry{border:.5px solid var(--dsw-alias-border-l4,#e3e6ea);border-radius:10px;padding:6px}",
".ms-modelRow{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:6px}",
".ms-iconBtn{box-sizing:border-box;width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;border:none;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary,#6b7280);font-family:inherit;font-size:14px;line-height:1;cursor:pointer}",
".ms-iconBtn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(9,24,58,.05));color:var(--dsw-alias-label-primary,#24292f)}",
".ms-iconBtn.danger:hover:not(:disabled){background:rgba(217,48,38,.08);color:var(--dsw-alias-state-error-primary,#d93026)}",
".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)}",
// ── 「我的技能」分组(档案 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)}",
// 分页工具行(技能页:左说明 / 右上传入口 —— 左对齐、右侧靠 `.bp-groupAct` 的 margin-left:auto)
".bp-tabHead{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;margin:0 0 12px}",
// 插件卡片的**中文用途描述 = 最多 3 行**(2026-09-15 用户要求:「插件卡片中增加中文用途
// 描述,显示三行」)。用 `-webkit-line-clamp` 做多行截断:单行 ellipsis 会把"这个插件
// 到底干什么"截得看不全;`white-space:normal` 必须显式写(否则继承单行截断的 nowrap)。
".bp-plugDesc{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;white-space:normal;word-break:break-word;line-height:1.45}",
"@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(); };
}, "business-plugins: styles");
var t = ctx.locale.bind(NS);
function BusinessPluginsSection() {
var useState = React.useState;
var useEffect = React.useEffect;
var pluginsState = useState([]);
var plugins = pluginsState[0];
var setPlugins = pluginsState[1];
var loadingState = useState(true);
var loading = loadingState[0];
var setLoading = loadingState[1];
var busyState = useState(false);
var busy = busyState[0];
var setBusy = busyState[1];
var msgState = useState("");
var msg = msgState[0];
var setMsg = msgState[1];
var queryState = useState("");
var query = queryState[0];
var setQuery = queryState[1];
var rejectedState = useState([]);
var rejected = rejectedState[0];
var setRejected = rejectedState[1];
// 「应用更改」的页内确认弹窗开关(替代 window.confirm)。
var confirmState = useState(false);
var confirmOpen = confirmState[0];
var setConfirmOpen = confirmState[1];
// 「服务端当前已启用」的 id 快照 —— 用于把「当前占用」与「勾选后会有多少」分开算。
// 没有它就没法在用户勾选时显示「增量」(p.enabled 会被 toggle 就地改掉)。
var savedState = useState([]);
var savedIds = savedState[0];
var setSavedIds = savedState[1];
// 平台**实际下发**的内存配额(档案 84)—— 来自 `/api/dsh/status` 的 `quota`
// (`{memMb, heapMb}`,与编排器 spawn 时用的是同一个值)。有它就以它为准显示;
// 拿不到(老平台 / k8s 模式)才退回本地算式,并在文案里如实标注为估算。
var quotaState = useState(null);
var quotaInfo = quotaState[0];
var setQuotaInfo = quotaState[1];
function load() {
setLoading(true);
fetch(portalHost() + "/api/plugins/mine", { credentials: "include" })
.then(function (r) { return r.json(); })
.then(function (d) {
var list = d.plugins || [];
setPlugins(list);
// 记下「服务端认为已启用」的那批 → 内存条据此区分「当前」与「勾选后」。
setSavedIds(list.filter(function (p) { return p.enabled; }).map(function (p) { return p.id; }));
setLoading(false);
})
.catch(function () { setLoading(false); setMsg(t("loadFailed")); });
// 并行取「平台实际配了多少内存」(档案 84)。失败静默 —— 只影响内存条,
// 不该连带把插件列表也报成加载失败。
fetch(portalHost() + "/api/dsh/status", { credentials: "include" })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { setQuotaInfo(d && d.quota ? d.quota : null); })
.catch(function () { setQuotaInfo(null); });
}
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];
/**
* 顶部分页(`"plugins"` | `"skills"`)—— 2026-09-15 用户要求:
* 「能力管理」按**插件 / 技能分页**,各自独立操作(默认落在插件页,与改造前一致)。
*/
var tabState = useState("plugins");
var tab = tabState[0];
var setTab = tabState[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 });
return p;
}));
}
/**
* 打开**页内确认弹窗**(不再用 `window.confirm`)。
*
* 2026-09-13 用户要求:所有弹窗改成页面弹窗,视觉参考 MCN 工作台
* (`dsh-plugin-mcn` 的 `fixed inset:0` 遮罩 + 居中面板 + 点遮罩关闭 +
* `stopPropagation` + ✕,见其 `modalTitle/modalText/modalBtns` 三段结构)。
*/
function askApply() {
setConfirmOpen(true);
}
/** 用户已在弹窗里点「确认应用」:真正的提交(原 `applyChanges` 主体)。 */
function doApply() {
setConfirmOpen(false);
setBusy(true);
setMsg(t("queued"));
setRejected([]);
fetch(portalHost() + "/api/plugins/mine/apply", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plugins: plugins.map(function (p) { return { id: p.id, enabled: p.enabled }; }) })
})
.then(function (r) { return r.json().catch(function () { return {}; }); })
.then(function (d) {
if (d.error) { setBusy(false); setMsg(t("requestFailed") + ":" + d.error); return; }
if (d.noop) { setBusy(false); setMsg(t("nochange")); return; }
pollTask(d.taskId);
})
.catch(function () { setBusy(false); setMsg(t("requestFailed")); });
}
// 异步任务:轮询「阶段 + 已用秒数」(B 方案,不滚日志)。
function pollTask(taskId) {
var start = Date.now();
var timer = setInterval(function () {
fetch(portalHost() + "/api/plugins/mine/task/" + taskId, { credentials: "include" })
.then(function (r) { return r.json().catch(function () { return {}; }); })
.then(function (task) {
var secs = Math.floor((Date.now() - start) / 1000);
var stage = task.stage || t("configuring");
if (task.status === "pending" || task.status === "running") {
setMsg("⏳ " + stage + "(" + secs + "s)");
} else if (task.status === "success") {
clearInterval(timer);
var bad = task.rejected || [];
setRejected(bad);
setBusy(false);
if (bad.length > 0) {
setMsg(t("applied") + " — " + t("rejected.title"));
load();
} else {
setMsg(t("applied") + " · " + t("reloading"));
waitAndReload(3);
}
} else {
clearInterval(timer);
setBusy(false);
setMsg("✗ " + (task.error || t("requestFailed")) + "(已回滚到改动前的状态)");
// 2026-09-12 修复:失败后必须重新拉取真实状态。否则本地 plugins 仍停留在
// 用户勾选后的样子 → 徽章显示「已启用」,与「安装失败」自相矛盾(用户实测报障)。
// 后端失败分支已 restoreProfile() 回滚,load() 即可取回正确状态。
load();
}
})
.catch(function () { /* 网络抖动忽略,继续下一轮 */ });
}, 1500);
}
// 06-工作台UI规范 §2.2 字号阶梯:section 语境取「正文 14 / 次要 13 / 辅助 12」三档
//(不照搬页面级 16px 基准 —— 本 section 嵌在 dsh 设置面板内,非独立页)。
var wrap = {
display: "flex",
flexDirection: "column",
gap: "10px",
padding: "4px 2px",
color: T.text,
fontSize: "14px"
};
/** 规范 §4.9 空态/加载态:应有「标题 + 说明」层次,而非一行灰字。 */
function StateBlock(props) {
return jsxRuntime.jsxs("div", {
style: {
padding: "28px 20px", display: "flex", flexDirection: "column",
alignItems: "center", gap: "6px", textAlign: "center"
},
children: [
jsxRuntime.jsx("div", {
key: "t",
style: { fontSize: "15px", fontWeight: 500, color: T.text },
children: props.title
}),
jsxRuntime.jsx("div", {
key: "d",
style: { fontSize: "13px", color: T.sub, lineHeight: 1.6 },
children: props.desc
})
]
});
}
/**
* 内存预估状态条(置于列表上方 —— 2026-09-13 用户要求)。
*
* 三段视觉:**实心** = 「服务端当前已启用」那批的预估占用;**半透明** = 本次勾选
* 带来的增量;超过单实例上限时整条转红并给出文字警告。全程只读本地数据、不发请求。
*/
function MemBar(props) {
// 进度条的 100% 基准 = **实际配额**(= `MEM_MIN_MIB`,固定值)。
// 2026-09-14 起配额不再随插件集合增长 ⇒ 拿配额当基准才反映「用了多少比例」。
var limit = props.limit;
var quotaNow = props.quotaNow; // 当前实际配额(优先 status 真值)
var quotaPlanned = props.quotaPlanned; // 勾选后的配额
var rawNow = props.rawNow; // 未 clamp 的原始需求
var rawPlanned = props.rawPlanned;
var factText = props.factText; // 事实行文案(调用方算好,确认弹窗复用同一条)
// 「顶到硬顶」= 原始需求已超过 MAX ⇒ 再加也不会给更多 ⇒ 这才是真会起不来的情形。
// (旧版把 clamp 下限 384 当上限判,导致 388 就报「将超出上限」的**误报**。)
var over = rawPlanned > limit;
var tight = !over && rawPlanned > limit * MEM_TIGHT_RATIO;
var accent = over ? T.danger : (tight ? T.warn : T.success);
var nowPct = Math.max(0, Math.min(100, (rawNow / limit) * 100));
var planPct = Math.max(0, Math.min(100, (rawPlanned / limit) * 100));
var delta = quotaPlanned - quotaNow;
var stateText = over ? t("mem.over") : (tight ? t("mem.tight") : t("mem.safe"));
var line = t("mem.now") + " " + quotaNow + " MiB";
if (delta !== 0) {
line += " → " + t("mem.planned") + " " + quotaPlanned + " MiB";
} else {
line += " / " + t("mem.limit") + " " + limit + " MiB";
}
// 事实行:平台**实际下发**的配额与 V8 堆(来自 /api/dsh/status;文案由调用方传入)。
var fact = factText;
return jsxRuntime.jsxs("div", {
style: {
display: "flex", flexDirection: "column", gap: "6px", padding: "10px 12px",
borderRadius: "10px", background: T.field,
border: "1px solid " + (over ? T.danger : T.border)
},
children: [
jsxRuntime.jsxs("div", {
key: "h",
style: { display: "flex", alignItems: "baseline", gap: "8px", flexWrap: "wrap" },
children: [
jsxRuntime.jsx("span", {
key: "t",
style: { fontSize: "13px", fontWeight: 500, color: T.text },
children: t("mem.title")
}),
jsxRuntime.jsx("span", {
key: "n",
style: { fontSize: "13px", color: T.sub, fontVariantNumeric: "tabular-nums" },
children: line
}),
jsxRuntime.jsx("span", {
key: "s",
style: { fontSize: "12px", color: accent, marginLeft: "auto", flexShrink: 0 },
children: (over ? "⚠ " : "") + stateText
}),
jsxRuntime.jsx("span", {
key: "e",
style: { fontSize: "12px", color: T.dim, flexBasis: "100%" },
children: fact
})
]
}),
jsxRuntime.jsxs("div", {
key: "bar",
style: {
position: "relative", height: "8px", borderRadius: "4px",
background: T.border, overflow: "hidden"
},
children: [
jsxRuntime.jsx("div", {
key: "plan",
style: {
position: "absolute", left: 0, top: 0, bottom: 0,
width: planPct + "%", background: accent, opacity: 0.28
}
}),
jsxRuntime.jsx("div", {
key: "now",
style: {
position: "absolute", left: 0, top: 0, bottom: 0,
width: nowPct + "%", background: accent, opacity: 0.85
}
})
]
}),
over
? jsxRuntime.jsx("div", {
key: "w",
style: { fontSize: "12px", color: T.danger, lineHeight: 1.6 },
children: t("mem.overWarn")
})
: null
]
});
}
if (loading) {
return jsxRuntime.jsx("div", {
style: wrap,
children: jsxRuntime.jsx(StateBlock, { title: t("loading"), desc: t("loadingDesc") })
});
}
if (plugins.length === 0) {
return jsxRuntime.jsx("div", {
style: wrap,
children: jsxRuntime.jsx(StateBlock, { title: t("emptyTitle"), desc: t("empty") })
});
}
var kw = query.trim().toLowerCase();
var shown = kw === ""
? plugins
: plugins.filter(function (p) {
return (p.name || "").toLowerCase().indexOf(kw) >= 0 || (p.description || "").toLowerCase().indexOf(kw) >= 0;
});
var selected = plugins.filter(function (p) { return p.enabled; }).length;
// 内存配额推演(档案 84,**与平台编排器同一套算式**):
// · raw* = 基线 + Σ 成本(未 clamp);quota* = clamp(raw, MIN, MAX)
// · Now = 服务端快照里「已启用」的那批(savedIds)→ 当前状态
// · Planned = 当前勾选状态(用户点一下就变)→ 应用后会是多少
// 若 `/api/dsh/status` 给了 `quota.memMb`(平台实际用的值),**以它为准**显示「当前」。
var rawNow = rawMiB(plugins.filter(function (p) { return savedIds.indexOf(p.id) >= 0; }), "enabled");
var rawPlanned = rawMiB(plugins, "enabled");
var quotaNow = (quotaInfo && quotaInfo.memMb != null) ? quotaInfo.memMb : quotaOf(rawNow);
var quotaPlanned = quotaOf(rawPlanned);
var realHeapMb = (quotaInfo && quotaInfo.heapMb != null) ? quotaInfo.heapMb : heapMiBFor(quotaNow);
// 事实行文案:优先展示平台**实际下发**的配额与堆;读不到才如实标注为估算。
var baseMb = (quotaInfo && quotaInfo.baseMb != null) ? quotaInfo.baseMb : MEM_MIN_MIB;
var memFactText = (quotaInfo && quotaInfo.memMb != null)
? (t("mem.base") + " " + baseMb + " MiB → " + t("mem.limit") + " " + quotaInfo.memMb + " MiB · " + t("mem.heap") + " " + realHeapMb + " MiB")
: t("mem.est");
var inputStyle = {
flex: "1",
minWidth: "180px",
padding: "6px 10px",
borderRadius: "8px",
border: "1px solid " + T.border,
background: T.field,
color: T.text,
fontSize: "14px",
outline: "none"
};
// 规范 §4.1:次要动作用白底 + border(`.btn` / `.btn-sm` 量级),非主色实心。
var ghostBtnStyle = {
padding: "5px 12px", borderRadius: "8px", cursor: "pointer",
border: "1px solid " + T.border, background: "transparent", color: T.sub, fontSize: "13px"
};
var rows = [];
var cards = [];
/** 当前分页 —— 插件页与技能页**各自独立操作**(2026-09-15 用户要求)。 */
var isPlugins = tab === "plugins";
// 顶部页内分页:**复用既有 `.pa-tabs/.pa-tab/.pa-tcnt`**(下划线式;规范 §4.4
// 「数据页用下划线式」)。计数 = 该类当前有多少项,用户不必点进去才知道。
rows.push(jsxRuntime.jsxs("div", {
key: "__tabs",
className: "pa-tabs",
role: "tablist",
children: [
jsxRuntime.jsxs("div", {
key: "p",
className: "pa-tab" + (isPlugins ? " pa-on" : ""),
role: "tab",
"aria-selected": isPlugins ? "true" : "false",
onClick: function () { setTab("plugins"); },
children: [
t("cap.tabPlugins"),
jsxRuntime.jsx("span", { key: "c", className: "pa-tcnt", children: String(plugins.length) })
]
}),
jsxRuntime.jsxs("div", {
key: "s",
className: "pa-tab" + (isPlugins ? "" : " pa-on"),
role: "tab",
"aria-selected": isPlugins ? "false" : "true",
onClick: function () { setTab("skills"); },
children: [
t("msk.group"),
jsxRuntime.jsx("span", { key: "c", className: "pa-tcnt", children: String(skills.length) })
]
})
]
}));
if (isPlugins) {
// 内存配额状态条:**卡片列表上方**(2026-09-13 用户要求)——
// 当前配额 / 勾选后配额 / 是否顶到硬顶,让用户在点「应用」之前就知道后果。
rows.push(jsxRuntime.jsx(MemBar, {
key: "__membar",
limit: quotaOf(),
quotaNow: quotaNow,
quotaPlanned: quotaPlanned,
rawNow: rawNow,
rawPlanned: rawPlanned,
factText: memFactText
}));
// 工具行:搜索 + 全选/清空 + 计数(规范 §2.4:工具条内部 gap 8~10px)
rows.push(jsxRuntime.jsxs("div", {
key: "__toolbar",
style: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" },
children: [
jsxRuntime.jsx("input", {
key: "q",
value: query,
placeholder: t("search"),
onChange: function (e) { setQuery(e.target.value); },
style: inputStyle
}),
jsxRuntime.jsx("button", {
key: "all",
type: "button",
className: "bp-btn",
onClick: function () {
setPlugins(plugins.map(function (p) { return Object.assign({}, p, { enabled: true }); }));
},
style: ghostBtnStyle,
children: t("selectAll")
}),
jsxRuntime.jsx("button", {
key: "none",
type: "button",
className: "bp-btn",
onClick: function () {
setPlugins(plugins.map(function (p) { return Object.assign({}, p, { enabled: false }); }));
},
style: ghostBtnStyle,
children: t("clear")
}),
jsxRuntime.jsx("span", {
key: "cnt",
style: { color: T.sub, fontSize: "13px", marginLeft: "auto" },
children: selected + " / " + plugins.length + " " + t("selected")
})
]
}));
if (shown.length === 0) {
rows.push(jsxRuntime.jsx("div", {
key: "__nomatch",
style: { padding: "20px 0", textAlign: "center", color: T.sub, fontSize: "13px" },
children: t("noMatch")
}));
}
for (var i = 0; i < shown.length; i++) {
(function (p) {
var bad = rejected.filter(function (r) { return r.id === p.id; })[0];
var desc = p.description && String(p.description).trim() !== "" ? String(p.description).trim() : "";
// 主视觉:用途说明(候选池入库时已优先取官方目录中文);无说明时回退为包名。
var primary = desc !== "" ? desc : p.name + (p.version ? " v" + p.version : "");
// 副行:包名 + 版本(有说明时才渲染,避免与主视觉重复)。
var meta = p.name + (p.version ? " v" + p.version : "");
cards.push(jsxRuntime.jsxs("label", {
key: p.id,
className: "bp-row",
style: {
display: "flex", alignItems: "flex-start", gap: "10px", cursor: "pointer",
padding: "14px 16px", borderRadius: "12px", minWidth: 0, minHeight: "112px",
background: "var(--dsw-alias-bg-layer-1, #ffffff)",
border: "1px solid " + (bad ? T.danger : T.border)
},
children: [
jsxRuntime.jsx("input", {
key: "cb",
type: "checkbox",
checked: !!p.enabled,
onChange: function () { toggle(p.id); }
}),
jsxRuntime.jsxs("span", {
key: "nm",
style: { flex: "1", minWidth: "0", display: "flex", flexDirection: "column", gap: "2px" },
children: [
// 主视觉 = 用途说明(规范 §5「信息层次」;与门户 plugins 页同一决策)
// 主视觉 = **中文用途描述,最多 3 行**(2026-09-15 用户要求:
// 「插件卡片中增加中文用途描述,显示三行」)。截断交给 `.bp-plugDesc`
// 的 `-webkit-line-clamp:3`;`title` 仍挂全文,hover 可看完整。
jsxRuntime.jsx("span", {
key: "p1",
className: "bp-plugDesc",
style: { fontSize: "14px", fontWeight: 500, color: T.text },
title: primary,
children: primary
}),
// 副行 = 包名 + 版本(长文本截断三件套 + title,规范 §5)
desc !== ""
? jsxRuntime.jsx("span", {
key: "p2",
style: {
fontSize: "12px", color: T.sub,
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap"
},
title: meta,
children: meta
})
: null,
// 加载成本(2026-09-13 用户要求):数值越大越"贵",按硬顶量级分色
// (≥256 红 / ≥64 黄 / 其余次要色)。⚠️ 未进 `MEM_TABLE` 的插件平台按 0 计
// (「宁可少给也不虚高」)⇒ 此时**不显示徽章**,免得满屏「≈ 0 MiB」噪音。
memMiB(p) > 0
? jsxRuntime.jsx("span", {
key: "mem",
style: { marginTop: "4px" },
children: jsxRuntime.jsx("span", {
style: {
padding: "1px 8px", borderRadius: "10px",
border: "1px solid " + T.border, background: T.field,
color: memMiB(p) >= 256 ? T.danger : (memMiB(p) >= 64 ? T.warn : T.sub)
},
children: t("mem.perCard") + " ≈ " + memMiB(p) + " MiB"
})
})
: null
]
}),
jsxRuntime.jsx("span", {
key: "bd",
style: {
fontSize: "12px", padding: "2px 8px", borderRadius: "10px", flexShrink: 0,
color: p.enabled ? T.success : T.dim,
border: "1px solid " + (p.enabled ? T.success : T.border)
},
children: p.enabled ? t("enabled") : t("disabled")
}),
bad
? jsxRuntime.jsx("span", {
key: "bad",
style: { fontSize: "12px", color: T.danger, flexShrink: 0 },
title: String(bad.reason || ""),
children: "⚠"
})
: null
]
}));
})(shown[i]);
}
// 卡片网格:一行 2 个(用户 2026-09-12 要求「插件列表改为卡片形式,一行放 2 个卡片」)
// 单独包一层 grid —— 不能直接改外层 wrap(它同时装标题/搜索/按钮/提示等纵向块)。
rows.push(jsxRuntime.jsx("div", {
key: "__grid",
style: { display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: "10px" },
children: cards
}));
rows.push(jsxRuntime.jsxs("div", {
key: "__actions",
style: { display: "flex", alignItems: "center", gap: "10px", marginTop: "4px" },
children: [
// 规范 §4.1:主行动用 primary 实心(`.btn` 量级,section 内 14px)
jsxRuntime.jsx("button", {
key: "apply",
type: "button",
onClick: askApply,
disabled: busy,
style: {
display: "inline-flex", alignItems: "center", justifyContent: "center",
padding: "7px 16px", borderRadius: "8px", border: "1px solid " + T.primary,
background: T.primary, color: "#ffffff", cursor: busy ? "default" : "pointer",
opacity: busy ? 0.5 : 1, fontSize: "14px"
},
children: busy ? t("applying") : t("apply")
}),
rejected.length > 0
? jsxRuntime.jsx("button", {
key: "retry",
type: "button",
className: "bp-btn",
onClick: askApply,
disabled: busy,
style: Object.assign({}, ghostBtnStyle, { opacity: busy ? 0.5 : 1 }),
children: t("retry")
})
: null
]
}));
}
// ══ 「我的技能」分组(档案 100;2026-09-15 起改为**独立分页**)══════════════
// 与「功能插件」**同一个 section 内**(用户口径「不要分开管理」=入口层合并),
// 但两者**各自一页、各自操作**(用户 2026-09-15:「改造为 tab 可切换 插件和技能
// 分别进行操作」);机制上始终独立:技能是 watch 驱动 ⇒ 启用/停用**立即生效、
// 无需重启**,行内文案如实说明。
// 平台共享技能(`locked:true`)**不渲染任何动作按钮** —— 与后端 `assertNotShared()`
// 的 409 双向一致:前端不给出注定失败的按钮,后端仍然兜底。
if (!isPlugins) {
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: [
// 工具行:左 = 生效方式说明(与「功能插件」需重启形成对照)/ 右 = 上传入口。
// ⚠️ **不再自带分组标题** —— 标题由顶部 tab 承担,避免同页出现两个同名标签。
jsxRuntime.jsxs("div", {
key: "head",
className: "bp-tabHead",
children: [
jsxRuntime.jsx("p", {
key: "hint",
style: { margin: 0, fontSize: "12px", lineHeight: "18px", color: T.dim },
children: t("msk.hint")
}),
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")
})
})
]
}),
// 上传面板(默认收起;支持点击选文件与拖入)
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 (isPlugins) {
if (rejected.length > 0) {
// impeccable craft-floor 明令禁止「卡片/列表项/提示上 >1px 的彩色 border-left/right」
// —— 改为整块浅底 + 圆角,危险语义由标题文字色承担(不靠侧边色条)。
rows.push(jsxRuntime.jsxs("div", {
key: "__rejected",
style: {
padding: "10px 12px", borderRadius: "8px",
color: T.sub, fontSize: "13px", lineHeight: "1.6", background: T.field
},
children: [
jsxRuntime.jsxs("div", {
key: "h",
style: { fontWeight: 500, color: T.danger, marginBottom: "2px" },
children: ["⚠ ", t("rejected.title")]
}),
jsxRuntime.jsx("div", { key: "b", children: rejected.map(function (r) { return r.id; }).join("、") })
]
}));
}
if (msg) {
rows.push(jsxRuntime.jsx("div", { key: "__msg", style: { color: T.sub, fontSize: "13px" }, children: msg }));
}
// ── 页内确认弹窗(替代 window.confirm;视觉对齐 MCN 工作台弹窗)──────────
// 结构照 MCN:遮罩(fixed inset:0 + rgba(0,0,0,.35) + zIndex 2000 + 居中)→ 面板
// (radius 12 / padding 18·22 / maxHeight 82vh / 阴影)→ 标题行(✕ 关闭)→ 正文 →
// 内存块(**超限时红底 + 警告**,用户 2026-09-13 明确要求)→ 按钮行(取消 / 确认)。
// 点遮罩关闭、点面板 stopPropagation(与 MCN 同款交互)。
if (confirmOpen) {
var overNow = rawPlanned > quotaOf();
rows.push(jsxRuntime.jsxs("div", {
key: "__confirm",
style: {
position: "fixed", inset: 0, background: "rgba(0,0,0,.35)", zIndex: 2000,
display: "flex", alignItems: "center", justifyContent: "center", padding: "24px"
},
onClick: function () { setConfirmOpen(false); },
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: t("confirm.title")
}),
jsxRuntime.jsx("button", {
key: "x",
type: "button",
title: t("confirm.cancel"),
onClick: function () { setConfirmOpen(false); },
style: {
border: "none", background: "none", fontSize: "18px", lineHeight: 1,
cursor: "pointer", color: T.dim, padding: "2px 4px"
},
children: "✕"
})
]
}),
jsxRuntime.jsx("div", {
key: "b",
style: { fontSize: "13px", color: T.sub, lineHeight: 1.6 },
children: t("confirm")
}),
jsxRuntime.jsxs("div", {
key: "m",
style: {
padding: "10px 12px", borderRadius: "10px",
background: overNow ? "rgba(217,48,38,.06)" : T.field,
border: "1px solid " + (overNow ? T.danger : T.border),
display: "flex", flexDirection: "column", gap: "4px"
},
children: [
jsxRuntime.jsx("div", {
key: "h",
style: { fontSize: "13px", fontWeight: 500, color: overNow ? T.danger : T.text },
children: overNow ? t("confirm.overTitle") : t("mem.title")
}),
jsxRuntime.jsx("div", {
key: "n",
style: { fontSize: "13px", color: T.sub, fontVariantNumeric: "tabular-nums" },
children: t("mem.now") + " " + quotaNow + " MiB → " + t("mem.planned") + " " + quotaPlanned
+ " MiB / " + t("mem.limit") + " " + quotaOf() + " MiB"
}),
overNow
? jsxRuntime.jsx("div", {
key: "w",
style: { fontSize: "12px", color: T.danger, lineHeight: 1.6 },
children: t("mem.overWarn")
})
: null,
jsxRuntime.jsx("div", {
key: "e",
style: { fontSize: "12px", color: T.dim },
children: memFactText
})
]
}),
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: function () { setConfirmOpen(false); },
style: ghostBtnStyle,
children: t("confirm.cancel")
}),
jsxRuntime.jsx("button", {
key: "k",
type: "button",
onClick: doApply,
disabled: busy,
style: {
padding: "7px 16px", borderRadius: "8px", cursor: busy ? "default" : "pointer",
border: "1px solid " + (overNow ? T.danger : T.primary),
background: overNow ? T.danger : T.primary, color: "#ffffff",
fontSize: "14px", opacity: busy ? 0.5 : 1
},
children: busy ? t("applying") : t("confirm.ok")
})
]
})
]
})
}));
}
// ── 「我的技能」的两个页内确认弹窗(档案 100 §2.5)──────────────────────
// 共同点:**只点名 + 讲清后果**。替换额外给出「旧 vs 新」的事实行(tabular-nums),
// 因为"删掉几个文件"是用户真正关心的量;删除与替换同样不可逆,故同样必须过弹窗。
}
if (!isPlugins) {
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 });
}
// ═══════════════════════════════════════════════════════════════════════════
// 「模型设置」分区(档案 87 · 2026-09-13 第三轮口径)
//
// 为什么平台自己做这一页:官方「设置 → 模型」页在平台环境**必然报错** ——
// 它要求 Host settings 镜像,而平台是"浏览器经域名访问远程服务器"⇒
// `isLoopback = transport.ownsHost || pageLocation === undefined ||
// isLoopbackHostname(page)` 三条皆不成立 ⇒ persistence 降级为 `memory`
// ⇒ 页面必报「加载提供方目录失败: settings are unavailable in this browser」。
// 所以官方那个分区被 `ensure-role-profile-patch.cjs` 对**所有角色(含 admin)**
// 隐藏(见其 `DISABLE_MODELS_BLOCK` / `ADMIN_MODELS_BLOCK`),用户自配改走本页。
//
// 用户定下的三条口径(**已定,不要再拿去当选择题**):
// ① 条目**各自开关、可同时启用**(互斥已删);
// ② admin 配的**平台共享模型也列在这里**,用户可开关;
// ③ 具体用哪个模型**在 dsh 对话框的模型选择器里选** —— 本页只负责把
// 「已启用」的都配好(平台在 spawn 时写 `$DSH_HOME/.credentials.yaml`
// 与 `settings.yaml` 的 `llm-pi-ai.providers.<route>`)。
// ⚠️ 改动**重启实例后生效**(落地发生在 spawn 时),页面文案已如实说明。
//
// 接口:平台 `src/web/routes/auth.ts` 的 `/api/me/keys`(GET/POST)、
// `/api/me/keys/:id/toggle`、`/api/me/models/shared`、`DELETE /api/me/keys/:id`
// —— 与门户既有 API 一样走 `paReq`(跨子域 + credentials)。
// ═══════════════════════════════════════════════════════════════════════════
/** 后端错误码 → 词典键(认不出的码退回通用失败文案,不把码裸露给用户)。 */
var MS_ERR = {
invalid_name: "ms.e.name", invalid_api_key: "ms.e.key", invalid_base_url: "ms.e.url",
invalid_route: "ms.e.route", invalid_api: "ms.e.api", models_required: "ms.e.models",
route_taken: "ms.e.taken", unknown_provider: "ms.e.provider"
};
/**
* 「模型设置」——**按官方 `dsh-client-ui-settings-models` 的结构做**(用户 2026-09-14 要求:
* 「页面就按照 dsh 模型设置的页面,增加 admin 设置的模型管理」)。官方那页的骨架是:
* · **提供方行**:每个已配置的厂家一行;卡片主字段是**单独一个「API 密钥」输入框**
* (页面从不问环境变量名 —— 派生 `<ROUTE>_API_KEY`,pi-ai 记为 `apiKeyEnv`);
* · 收起的「自定义设置」才承载 `baseURL` / 协议 / 模型目录;
* · **新增** = 一张卡片,含**目录厂家选择框**(裸挂载的 `llm-pi-ai` 就能提供完整
* 已安装 catalog)+ 「添加自定义提供方」(要 Provider ID / 端点 / 协议 / ≥1 模型)。
* 本页与之同构,另加**平台共享模型**(= admin 配的那批,用户可开关,口径②)。
*
* 关键差别:官方页把密钥写进 profile 的凭据引用;本平台是**平台侧落地**(spawn 时写
* 实例的 `.credentials.yaml` / `settings.yaml`),所以保存后**要重启实例才生效**。
*/
function ModelSettingsSection() {
var useState = React.useState;
var useEffect = React.useEffect;
var dataState = useState(null);
var data = dataState[0];
var setData = dataState[1];
var provState = useState([]);
var providers = provState[0];
var setProviders = provState[1];
var loadingState = useState(true);
var loading = loadingState[0];
var setLoading = loadingState[1];
var busyState = useState(false);
var busy = busyState[0];
var setBusy = busyState[1];
var msgState = useState("");
var msg = msgState[0];
var setMsg = msgState[1];
// 「新增」流程的状态 —— **官方同构:虚线按钮 → 卡片**,同一时刻只开一张。
// `mode`:"" = 未开始(露两个虚线按钮)/"add" = 添加提供方/"custom" = 自定义提供方。
// 官方原文:「每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿」
// —— 我们只有一张卡,关掉即整份草稿作废(`closeAdd()`)。
var modeState = useState("");
var mode = modeState[0];
var setMode = modeState[1];
var fPick = useState("");
var fKey = useState("");
var fName = useState("");
var fRoute = useState("");
var fUrl = useState("");
var fApi = useState("");
// 模型目录 = **行数组**(官方 `ModelListEditor` 的形态)。平台后端 `models` 就是
// `string[]`(`keyAddSchema`),所以只收模型 ID —— 不假装支持官方的
// 「显示名称 / 上下文窗口 / 最大输出」三列(那些字段无处落库)。
var fModels = useState([""]);
var fShow = useState(false);
// 删除确认(官方 `deleteDialog`:**点名该提供方**再删)。
// 为什么必须有:删条目 = 连已存的 API 密钥一起删;而「删除」就挨着「停用」
// (2026-09-14 用户截图里这两个按钮还被挤到换行)⇒ 误点的代价不可逆。
var delState = useState(null);
var delTarget = delState[0];
var setDelTarget = delState[1];
// 后端给的**提供方目录可读性**诊断:读不到时要说清"为什么只剩两项",
// 而不是静默给个空列表(2026-09-14:这个静默曾让一个 P1 布局缺陷长期不可见)。
var catState = useState(null);
var catalog = catState[0];
var setCatalog = catState[1];
function load() {
setLoading(true);
Promise.all([
paReq("/api/me/keys").then(function (r) { return r.ok ? r.json() : null; }),
paReq("/api/me/model-providers").then(function (r) { return r.ok ? r.json() : null; })
])
.then(function (res) {
if (res[0]) setData(res[0]);
if (res[1]) {
if (res[1].providers) setProviders(res[1].providers);
setCatalog(res[1].catalog || null);
}
setLoading(false);
})
.catch(function () { setLoading(false); setMsg(t("ms.loadFailed")); });
}
useEffect(function () { load(); }, []);
/** 统一收尾:跑一个写操作 → 成功则刷新列表 / 失败则把后端错误码翻成文案。 */
function act(promise, onOk) {
if (busy) return;
setBusy(true);
setMsg("");
promise
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (res) {
setBusy(false);
if (!res.ok) {
setMsg(t(MS_ERR[res.d && res.d.error] || "ms.saveFailed"));
return;
}
setMsg(t("ms.saved"));
if (onOk) onOk();
load();
})
.catch(function () { setBusy(false); setMsg(t("ms.saveFailed")); });
}
/** 收起新增卡片并清空草稿(官方:取消 = 丢弃这张卡的草稿)。 */
function closeAdd() {
setMode("");
fPick[1](""); fKey[1](""); fName[1](""); fRoute[1](""); fUrl[1](""); fApi[1](""); fModels[1]([""]);
setShow(false);
}
var pick = fPick[0];
var picked = null;
for (var pi = 0; pi < providers.length; pi++) {
if (providers[pi].id === pick) picked = providers[pi];
}
/** 行标题:内置 → 固定文案;目录提供方 → 官方目录里的中文名;自定义 → route。 */
function labelOf(k) {
if (!k.route) return t("ms.kindBuiltin");
for (var i = 0; i < providers.length; i++) {
if (providers[i].id === k.route) return providers[i].label;
}
return k.route;
}
/** 行标签(官方 `rowTag` 的位置):内置 / 目录 / 自定义。 */
function tagOf(k) {
if (!k.route) return t("ms.tagBuiltin");
for (var i = 0; i < providers.length; i++) {
if (providers[i].id === k.route) return t("ms.tagCatalog");
}
return t("ms.tagCustom");
}
/** 行副标题:内置 → 说明;自定义 → 端点 · 协议;目录提供方 → 「由目录提供」。 */
function descOf(k) {
if (!k.route) return t("ms.builtinHint");
if (k.baseUrl) return k.baseUrl + " · " + (k.api || "openai-completions");
return t("ms.catalogRow");
}
/** 模型目录的**行式编辑器**(官方 `ModelListEditor` 的形态:一行一个 + 行尾删除)。 */
function modelRows() {
return fModels[0].map(function (m, i) {
return jsxRuntime.jsx("div", { className: "ms-modelEntry", children: jsxRuntime.jsxs("div", { className: "ms-modelRow", children: [
jsxRuntime.jsx("input", {
className: "ms-input",
placeholder: t("ms.modelIdPlaceholder"),
value: m,
onChange: function (e) {
var next = fModels[0].slice();
next[i] = e.target.value;
fModels[1](next);
}
}),
jsxRuntime.jsx("button", {
type: "button",
className: "ms-iconBtn danger",
title: t("ms.removeModel"),
"aria-label": t("ms.removeModel"),
onClick: function () {
var next = fModels[0].slice();
next.splice(i, 1);
fModels[1](next.length > 0 ? next : [""]);
},
children: "✕"
})
] }) }, "m" + i);
});
}
/**
* 提交「添加提供方」(目录提供方 / DeepSeek —— 即内置通道那条)。
* ⚠️ 密钥**不可留空**:`keyAddSchema` 里 `apiKey` 是 `required + minLength:1`,
* 留空必得 400 `invalid_api_key`(旧文案「留空则回落平台共享密钥」是错的,已改)。
* 显示名留空 ⇒ 取提供方名(官方同做法:页面从不先问名字)。
*/
function submitAdd() {
var key = fKey[0].trim();
if (key === "") { setMsg(t("ms.needKey")); return; }
var name = fName[0].trim() || (picked !== null ? picked.label : "");
if (name === "") { setMsg(t("ms.needName")); return; }
var body = { name: name, apiKey: key };
if (pick !== "") body.provider = pick; // 目录提供方:端点 / 协议 / 模型全由官方目录兜底
act(papostJson("/api/me/keys", body), closeAdd);
}
/** 提交「自定义提供方」:端点 / 协议 / ≥1 模型 / 可选 Provider ID —— 官方同字段集。 */
function submitCustom() {
var key = fKey[0].trim();
if (fUrl[0].trim() === "") { setMsg(t("ms.needUrl")); return; }
var ids = fModels[0].map(function (s) { return s.trim(); }).filter(function (s) { return s !== ""; });
if (ids.length === 0) { setMsg(t("ms.needModels")); return; }
var name = fName[0].trim() || fRoute[0].trim();
if (name === "") { setMsg(t("ms.needName")); return; }
var body = { name: name, apiKey: key, baseUrl: fUrl[0].trim(), models: ids };
if (fRoute[0].trim() !== "") body.route = fRoute[0].trim();
if (fApi[0]) body.api = fApi[0];
act(papostJson("/api/me/keys", body), closeAdd);
}
if (loading) {
return jsxRuntime.jsx("div", { className: "pa-page", children: jsxRuntime.jsx("div", { className: "pa-empty", children: t("loading") }) });
}
var keys = (data && data.keys) || [];
var shared = (data && data.shared) || {};
var sharedOn = !!(data && data.sharedModelEnabled);
var protocols = (data && data.protocols) || [];
var cnList = providers.filter(function (p) { return p.cn; });
var intlList = providers.filter(function (p) { return !p.cn; });
function provOpts(list) {
return list.map(function (p) {
return jsxRuntime.jsx("option", { value: p.id, children: p.label + "(" + p.modelCount + " 个模型)" }, p.id);
});
}
// ── 已添加的提供方:**卡片行**(官方 `rowCard` 形态;原表格已删)──
// 官方 `rowHead` = 「身份(名称+标签+状态点)」左对齐 + 「操作」`margin-left:auto`
// 右对齐,两侧都 `white-space:nowrap` ⇒ **结构上不会换行**(这是换掉表格的全部理由)。
var rows = keys.map(function (k) {
return jsxRuntime.jsxs("li", { className: "ms-rowCard", children: [
jsxRuntime.jsxs("div", { className: "ms-rowHead", children: [
jsxRuntime.jsxs("span", { className: "ms-rowIdentity", children: [
jsxRuntime.jsx("span", { className: "ms-rowName", children: labelOf(k) }),
jsxRuntime.jsx("span", { className: "ms-rowTag", children: tagOf(k) }),
// 状态点:官方 `credentialDot` 的位置与尺寸(8px 圆点),语义换成「启用 / 停用」。
jsxRuntime.jsx("span", {
className: "ms-dot " + (k.enabled ? "on" : "idle"),
role: "img",
title: k.enabled ? t("ms.stateOn") : t("ms.stateOff"),
"aria-label": k.enabled ? t("ms.stateOn") : t("ms.stateOff")
})
] }),
jsxRuntime.jsxs("span", { className: "ms-rowActions", children: [
jsxRuntime.jsx("button", {
type: "button",
className: "ms-btn outline",
disabled: busy,
onClick: function () { act(papostJson("/api/me/keys/" + k.id + "/toggle", { enabled: !k.enabled })); },
children: k.enabled ? t("ms.disable") : t("ms.enable")
}),
jsxRuntime.jsx("button", {
type: "button",
className: "ms-btn danger",
disabled: busy,
onClick: function () { setDelTarget(k); },
children: t("ms.del")
})
] })
] }),
// 状态文字放**独占一行的副标题**(整行可用宽度 ⇒ 不可能换行),信息量也不丢。
jsxRuntime.jsx("p", { className: "ms-rowMeta", children: descOf(k) + " · " + (k.enabled ? t("ms.stateOn") : t("ms.stateOff")) })
] }, k.id);
});
/** 编辑卡片右下角的动作行(官方的「取消 / 保存」两键,右对齐)。 */
function editorActions(submit, label, busyLabel, disabled) {
return jsxRuntime.jsxs("div", { className: "ms-editorActions", children: [
jsxRuntime.jsx("button", { type: "button", className: "ms-btn outline", disabled: busy, onClick: closeAdd, children: t("ms.cancel") }),
jsxRuntime.jsx("button", { type: "button", className: "ms-btn primary", disabled: disabled, onClick: submit, children: busy ? t(busyLabel) : t(label) })
] });
}
var addBlock = jsxRuntime.jsxs("div", { className: "ms-addBlock", children: [
// ── 卡片 A:添加提供方(官方 `addCard` + `ProviderEditor` 同构)──
// 官方原话:「编辑卡片上的主字段是单独一个 **API 密钥**输入框 —— 页面从不问
// 环境变量名」「收起的『自定义设置』才承载 baseURL / 协议 / 模型目录」。
mode === "add" ? jsxRuntime.jsxs("div", { className: "ms-card", children: [
jsxRuntime.jsxs("div", { className: "ms-field", children: [
jsxRuntime.jsx("span", { className: "ms-fieldLabel", children: t("ms.provider") }),
jsxRuntime.jsx("select", {
className: "ms-input",
value: pick,
"aria-label": t("ms.provider"),
onChange: function (e) { fPick[1](e.target.value); },
children: [
jsxRuntime.jsx("option", { value: "", children: t("ms.optBuiltin") }, "_b"),
cnList.length > 0 ? jsxRuntime.jsx("optgroup", { label: t("ms.grpCn"), children: provOpts(cnList) }, "_cn") : null,
intlList.length > 0 ? jsxRuntime.jsx("optgroup", { label: t("ms.grpIntl"), children: provOpts(intlList) }, "_intl") : null
]
})
] }),
jsxRuntime.jsxs("div", { className: "ms-field", children: [
jsxRuntime.jsxs("span", { className: "ms-fieldLabel", children: [
t("ms.keyInput"),
picked !== null ? jsxRuntime.jsx("span", { className: "ms-hint", children: t("ms.keyHintCatalog").replace("{n}", String(picked.modelCount)) }) : null
] }),
jsxRuntime.jsx("input", {
className: "ms-input",
type: "password",
autoComplete: "off",
placeholder: pick === "" ? t("ms.keyPlaceholderBuiltin") : t("ms.keyPlaceholder"),
value: fKey[0],
onChange: function (e) { fKey[1](e.target.value); }
})
] }),
jsxRuntime.jsxs("details", { className: "ms-details", children: [
jsxRuntime.jsx("summary", { className: "ms-summary", children: t("ms.customized") }),
jsxRuntime.jsxs("div", { className: "ms-detailsBody", children: [
jsxRuntime.jsxs("div", { className: "ms-field", children: [
jsxRuntime.jsx("span", { className: "ms-fieldLabel", children: t("ms.displayName") }),
jsxRuntime.jsx("input", {
className: "ms-input",
placeholder: picked !== null ? picked.label : t("ms.displayNamePlaceholder"),
value: fName[0],
onChange: function (e) { fName[1](e.target.value); }
})
] }),
pick === "" ? jsxRuntime.jsx("p", { className: "ms-hint", children: t("ms.builtinPickHint") }) : null,
picked !== null ? jsxRuntime.jsxs("div", { className: "ms-field", children: [
jsxRuntime.jsxs("span", { className: "ms-fieldLabel", children: [
t("ms.catalogModels"),
jsxRuntime.jsx("button", { type: "button", className: "ms-btn outline", style: { height: 28, borderRadius: 14, padding: "0 10px", fontSize: 12 }, onClick: function () { setShow(!fShow[0]); }, children: fShow[0] ? t("ms.hideModels") : t("ms.showModels") })
] }),
fShow[0]
? jsxRuntime.jsx("ul", { className: "ms-chips", children: (picked.models || []).map(function (m) { return jsxRuntime.jsx("li", { className: "ms-chip", children: m }, m); }) })
: jsxRuntime.jsx("p", { className: "ms-hint", children: t("ms.catalogModelsMeta").replace("{n}", String(picked.modelCount)) })
] }) : null,
jsxRuntime.jsx("p", { className: "ms-hint", children: t("ms.advancedHint") })
] })
] }),
msg ? jsxRuntime.jsx("p", { className: "ms-err", children: msg }) : null,
editorActions(submitAdd, "ms.save", "ms.saving", busy || fKey[0].trim() === "")
] }) : mode === "custom" ? (
// ── 卡片 B:自定义提供方(官方 `CustomProviderCard` 同字段集)──
// 官方:「创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别
// 的模型,因为没有东西能为它们兜底」;Provider ID 必填 ⇒ 我们把它排在首位,
// 留空时后端会用显示名 slug 化兜底(`slugify`),不额外造字段。
jsxRuntime.jsxs("div", { className: "ms-card", children: [
jsxRuntime.jsx("div", { className: "ms-field", children: jsxRuntime.jsx("span", { className: "ms-fieldLabel", children: t("ms.customTitle") }) }),
jsxRuntime.jsxs("div", { className: "ms-field", children: [
jsxRuntime.jsx("span", { className: "ms-fieldLabel", children: t("ms.customRoute") }),
jsxRuntime.jsx("input", {
className: "ms-input",
placeholder: "acme-gateway",
value: fRoute[0],
onChange: function (e) { fRoute[1](e.target.value); }
}),
// 与小写字母/数字/短横线的约定不符时**就地报错**(官方同:语法错误在字段处阻止创建)。
fRoute[0] !== "" && !/^[a-z][a-z0-9-]*$/.test(fRoute[0])
? jsxRuntime.jsx("p", { className: "ms-err", children: t("ms.customRouteInvalid") })
: jsxRuntime.jsx("p", { className: "ms-hint", children: t("ms.customRouteHint") })
] }),
jsxRuntime.jsxs("div", { className: "ms-field", children: [
jsxRuntime.jsx("span", { className: "ms-fieldLabel", children: t("ms.displayName") }),
jsxRuntime.jsx("input", {
className: "ms-input",
placeholder: fRoute[0] === "" ? t("ms.displayNamePlaceholder") : fRoute[0],
value: fName[0],
onChange: function (e) { fName[1](e.target.value); }
})
] }),
jsxRuntime.jsxs("div", { className: "ms-field", children: [
jsxRuntime.jsx("span", { className: "ms-fieldLabel", children: t("ms.baseUrl") }),
jsxRuntime.jsx("input", {
className: "ms-input",
placeholder: t("ms.baseUrlPlaceholder"),
value: fUrl[0],
onChange: function (e) { fUrl[1](e.target.value); }
})
] }),
jsxRuntime.jsxs("div", { className: "ms-field", children: [
jsxRuntime.jsx("span", { className: "ms-fieldLabel", children: t("ms.customApi") }),
jsxRuntime.jsx("select", {
className: "ms-input",
value: fApi[0],
onChange: function (e) { fApi[1](e.target.value); },
children: [
jsxRuntime.jsx("option", { value: "", children: t("ms.customApiUnset") }, "_u"),
protocols.map(function (p) { return jsxRuntime.jsx("option", { value: p, children: p }, p); })
]
})
] }),
jsxRuntime.jsxs("div", { className: "ms-field", children: [
jsxRuntime.jsx("span", { className: "ms-fieldLabel", children: t("ms.keyInput") }),
jsxRuntime.jsx("input", {
className: "ms-input",
type: "password",
autoComplete: "off",
placeholder: t("ms.keyPlaceholder"),
value: fKey[0],
onChange: function (e) { fKey[1](e.target.value); }
})
] }),
jsxRuntime.jsxs("div", { className: "ms-field", children: [
jsxRuntime.jsx("span", { className: "ms-fieldLabel", children: t("ms.catalogModels") }),
fModels[0].some(function (s) { return s.trim() !== ""; }) ? null : jsxRuntime.jsx("p", { className: "ms-hint", children: t("ms.modelEmpty") }),
jsxRuntime.jsx("div", { className: "ms-modelList", children: modelRows() }),
jsxRuntime.jsx("button", {
type: "button",
className: "ms-btn outline",
style: { alignSelf: "flex-start", height: 28, borderRadius: 14, padding: "0 10px", fontSize: 12 },
onClick: function () { fModels[1](fModels[0].concat([""])); },
children: "+ " + t("ms.addModel")
})
] }),
msg ? jsxRuntime.jsx("p", { className: "ms-err", children: msg }) : null,
jsxRuntime.jsx("p", { className: "ms-hint", children: t("ms.advancedHint") }),
editorActions(submitCustom, "ms.create", "ms.creating", busy || fUrl[0].trim() === "" || fModels[0].every(function (s) { return s.trim() === ""; }))
] }) ) : (
// ── 默认态:**两个虚线按钮**(官方 `addActions`;按钮本身不展开任何字段)──
jsxRuntime.jsxs("div", { className: "ms-addActions", children: [
jsxRuntime.jsxs("button", {
type: "button",
className: "ms-addBtn",
onClick: function () { setMsg(""); setMode("add"); },
children: ["+ " + t("ms.addProvider")]
}),
jsxRuntime.jsxs("button", {
type: "button",
className: "ms-addBtn",
onClick: function () { setMsg(""); setMode("custom"); },
children: ["+ " + t("ms.addCustom")]
})
] })
),
// 目录读不到时如实说明(不得静默只剩两个按钮)
providers.length === 0
? jsxRuntime.jsx("p", { className: "ms-hint", children: catalog && catalog.readable === false ? t("ms.catUnreadable").replace("{dir}", catalog.dir || "?") : t("ms.catEmpty") })
: null
] });
/** 删除确认弹窗(官方 `deleteDialog` 的等价物):遮罩点击 / 取消 = 关闭。 */
var delModal = delTarget === null ? null : jsxRuntime.jsxs("div", {
className: "pa-overlay",
onClick: function () { setDelTarget(null); },
children: jsxRuntime.jsxs("div", {
className: "pa-modal",
style: { width: "min(480px,100%)", padding: "18px 22px", display: "flex", flexDirection: "column", gap: 12 },
onClick: function (e) { e.stopPropagation(); },
children: [
jsxRuntime.jsx("div", { className: "pa-card-h", style: { margin: 0 }, children: t("ms.delTitle").replace("{who}", labelOf(delTarget)) }),
jsxRuntime.jsx("p", { className: "ms-hint", children: t("ms.delDesc").replace("{who}", labelOf(delTarget)) }),
msg ? jsxRuntime.jsx("p", { className: "ms-err", children: msg }) : null,
jsxRuntime.jsxs("div", { className: "ms-editorActions", children: [
jsxRuntime.jsx("button", { type: "button", className: "ms-btn outline", disabled: busy, onClick: function () { setDelTarget(null); }, children: t("ms.cancel") }),
jsxRuntime.jsx("button", {
type: "button",
className: "ms-btn danger",
disabled: busy,
onClick: function () { act(paReq("/api/me/keys/" + delTarget.id, { method: "DELETE" }), function () { setDelTarget(null); }); },
children: busy ? t("ms.saving") : t("ms.delConfirm").replace("{who}", labelOf(delTarget))
})
] })
]
})
});
return jsxRuntime.jsxs("div", { className: "pa-page", children: [
jsxRuntime.jsx("h1", { className: "pa-hd", children: t("ms.title") }),
jsxRuntime.jsx("p", { className: "pa-sub", children: t("ms.sub") }),
// ── admin 配的「平台共享模型」(口径②:也列在这里、用户可开关)──
jsxRuntime.jsxs("div", { className: "pa-box", style: { marginBottom: 16 }, children: [
jsxRuntime.jsxs("div", { className: "pa-card-h", children: [
jsxRuntime.jsx("span", { children: t("ms.shared") }),
// ⚠️ 看的人是 admin 本人时不能说「由 admin 配置」(2026-09-14 截图里就是这么写的)
// —— 后端给了 `ownerIsMe`,据此换成「由你配置」。
shared.owner
? jsxRuntime.jsx("span", { className: "sub", children: shared.ownerIsMe ? t("ms.sharedOwnerMe") : t("ms.sharedOwner").replace("{who}", shared.owner) })
: null
] }),
jsxRuntime.jsx("p", { className: "pa-hint", children: t("ms.sharedHint") }),
jsxRuntime.jsxs("div", { className: "pa-row", style: { margin: 0 }, children: [
jsxRuntime.jsx("span", { className: "pa-badge " + (shared.available ? "active" : "disabled"), children: shared.available ? (shared.name || t("ms.available")) + (shared.count > 1 ? " ×" + shared.count : "") : t("ms.unavailable") }),
jsxRuntime.jsx("button", { className: "ms-btn " + (sharedOn ? "outline" : "primary"), disabled: busy || !shared.available, onClick: function () { act(papostJson("/api/me/models/shared", { enabled: !sharedOn })); }, children: sharedOn ? t("ms.sharedOn") : t("ms.sharedOff") })
] })
] }),
// ── 我已添加的提供方(官方页的「提供方行」)──
jsxRuntime.jsx("div", { className: "pa-sec", children: t("ms.list") }),
rows.length > 0
? jsxRuntime.jsx("ul", { className: "ms-rows", children: rows })
: jsxRuntime.jsx("p", { className: "ms-hint", style: { marginBottom: 16 }, children: t("ms.empty") }),
// ── 新增区(官方 `addBlock`)──
addBlock,
// 写操作的结果提示(保存成功 / 后端错误码译文)
msg ? jsxRuntime.jsx("p", { className: mode === "" ? "ms-ok" : "ms-err", style: { marginTop: 12 }, role: "status", "aria-live": "polite", children: msg }) : null,
// 删除确认弹窗(点名该提供方)
delModal
] });
}
// 2026-09-15(用户要求):**「偏好设置」分区已撤除** —— 语言切换搬到
// `@dsh-local/portal-entry` 的「用户设置」分区(用户级偏好与「账号 / 退出登录」同区即可,
// 再单开一个分区就是重复入口)。撤掉的实现(PreferencesSection + id `preferences`
// order 99 + `pref.*` 词条)见 `04-调整方案/102`。
ctx.slots.inject("settings.section", function () {
return ctx.slots.register(
{
name: "settings.section",
id: "model-settings",
order: 100,
label: function () { return t("ms.title"); }
},
ModelSettingsSection
);
});
ctx.slots.inject("settings.section", function () {
return ctx.slots.register(
{
name: "settings.section",
id: "business-plugins",
order: 101,
label: function () { return t("section.label"); }
},
BusinessPluginsSection
);
});
// ═══════════════════════════════════════════════════════════════════════════
// 「系统管理」页面族 —— **逐函数移植门户 `web/portal.html` 的 <script>**(第 3 轮)
// 2026-09-13 用户要求:弹窗里点开的内容必须 = 门户点击功能后的**详细表格与功能**
// (此前是自造的 5 项只读摘要 —— 功能名与表格都与门户对不上)。
// 移植只做三处机械改写,**结构 / 表格列 / 文案逐字保持一致**:
// ① `document.getElementById(x)` → `$("x")`:`$` 是弹窗子树内的局部查询器,
// 避免与 dsh 面板里的同名 id 撞车(门户用全局 document,弹窗不能)。
// ② `fetch("/api/…")` → `paReq("/api/…")`:跨子域 + `credentials:"include"`,
// 与门户同源调用等价(实例子域已在门户 CORS 白名单内,含 GET/POST/DELETE)。
// ③ `location.hash` 路由 → 弹窗内的页签状态(门户靠 hash 深链,弹窗不需要)。
// ⚠️ 门户改了这些函数 ⇒ 本文件要按**同名函数**对照同步(唯一需人工对账的地方)。
// ═══════════════════════════════════════════════════════════════════════════
/** 占位符填充:`pafmt("成功 {ok} / {total}", { ok: 1, total: 2 })`。 */
function pafmt(text, a) {
return String(text).replace(/\{(\w+)\}/g, function (m, k) {
return a && Object.prototype.hasOwnProperty.call(a, k) ? String(a[k]) : m;
});
}
/** 门户 `esc()` 同名移植。 */
function paesc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
/** 门户 `fmtSize()` 同名移植。 */
function pafmtSize(n) {
if (!n) return "—";
if (n < 1024) return n + " B";
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
return (n / 1024 / 1024).toFixed(1) + " MB";
}
/** 门户 `when()` 同名移植。 */
function pawhen(ms) { return ms ? new Date(ms).toLocaleString() : "—"; }
/** 门户 `emptyRow()` 同名移植(视觉落在 `.pa-empty-cell`)。 */
function paemptyRow(cols, text) {
return '<tr><td colspan="' + cols + '" class="pa-empty-cell">' + paesc(text) + "</td></tr>";
}
/**
* 门户 `readAsBase64()` 同名移植,**但修掉门户侧的缺陷**:
* 门户原文 `return String(new Promise(…)).split(",")[1] ?? ""` 缺 `await` ⇒ 恒得
* `"[object Promise]"` ⇒ 返回空串 ⇒ 门户的「上传文件 / 上传技能 zip / 上传插件 tgz」
* 实际传的是**空数据**。弹窗侧改为返回真 Promise(门户侧待同修,见档案 82 §追加)。
*/
function pareadAsBase64(file) {
return new Promise(function (res, rej) {
var r = new FileReader();
r.onload = function () { res(String(r.result).split(",")[1] || ""); };
r.onerror = rej;
r.readAsDataURL(file);
});
}
/** 门户弱化色(`--bp-dim`)→ dsh token,供内联 style 复用。 */
var PA_DIM = "color:var(--dsw-alias-label-tertiary,#6b7280)";
/** 门户 `fetch` 的跨子域等价物。 */
function paReq(path, opt) {
return fetch(portalHost() + path, Object.assign({ credentials: "include" }, opt || {}));
}
/** 门户 `postJson()` 同名移植。 */
function papostJson(path, body) {
return fetch(portalHost() + path, {
method: "POST", credentials: "include",
headers: { "content-type": "application/json" }, body: JSON.stringify(body)
});
}
/** 门户 `relTime()` 同名移植(目录缓存时间展示用)。 */
function parelTime(ts) {
if (!ts || typeof ts !== "number") return "—";
var s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
if (s < 60) return "刚刚";
if (s < 3600) return Math.floor(s / 60) + " 分钟前";
if (s < 86400) return Math.floor(s / 3600) + " 小时前";
return Math.floor(s / 86400) + " 天前";
}
/** 6 个功能页的定义(门户 `PAGES` 的等价物;`html()` = `renderXxx`,`init()` = `initXxx`)。 */
var PA_PAGES = {};
/** 服务管理 ← 门户 `#/files`(`renderFiles` + `initFiles`)。 */
PA_PAGES.files = {
icon: "🖥️",
title: function () { return t("pa.p.files"); },
desc: function () { return t("pa.d.files"); },
sub: function () { return t("pa.s.files"); },
html: function () {
return '<div class="pa-dshbar">' +
// 档案 86:admin 可切换目标用户 —— 下方文件树 / 启停 / 打开 全部针对所选用户
'<span style="font-size:13px;' + PA_DIM + '">' + paesc(t("pa.f.targetUser")) + "</span>" +
'<select class="pa-input" id="svcUser" style="flex:none;min-width:130px"></select>' +
'<span class="pa-stat" id="dshState"><span class="pa-dot pa-off"></span>' + paesc(t("pa.st.stopped")) + "</span>" +
'<button class="pa-btn pa-primary" id="launchBtn">' + paesc(t("pa.op.launch")) + "</button>" +
'<button class="pa-btn" id="stopBtn" style="display:none">' + paesc(t("pa.op.stop")) + "</button>" +
'<button class="pa-btn" id="openDshBtn" style="display:none">' + paesc(t("pa.op.openDsh")) + "</button>" +
'<span style="flex:1"></span>' +
'<span style="font-size:13px;' + PA_DIM + '">' + paesc(t("pa.f.launchDir")) +
'<b id="dshFolder">' + paesc(t("pa.f.root")) + "</b></span>" +
"</div>" +
'<div class="pa-box"><div class="pa-pathbar">' +
'<button class="pa-sm" id="newFolderBtn">' + paesc(t("pa.op.newFolder")) + "</button>" +
'<button class="pa-sm" id="uploadBtn">' + paesc(t("pa.op.uploadFile")) + "</button>" +
'<span class="pa-crumbs" id="breadcrumb"></span>' +
"</div>" +
'<div class="pa-wrap"><table class="pa-tbl"><thead><tr>' +
"<th>" + paesc(t("pa.k.name")) + "</th>" +
"<th>" + paesc(t("pa.k.type")) + "</th>" +
'<th class="pa-num" style="text-align:right">' + paesc(t("pa.k.size")) + "</th>" +
"<th>" + paesc(t("pa.k.mtime")) + "</th>" +
'</tr></thead><tbody id="rows"></tbody></table></div></div>' +
'<input id="fileInput" type="file" style="display:none" />';
},
init: function ($, root) {
var cur = []; // 目录栈
var uid = ""; // 目标用户(档案 86:admin 可切到任意用户;不再是"自己")
/** admin 视角的基路径 —— 文件与实例操作全部打到这里(requireAdmin)。 */
function base() { return "/api/admin/users/" + encodeURIComponent(uid); }
function pathString() { return cur.join("/"); }
function load() {
var p = pathString();
return paReq(base() + "/fs/tree" + (p ? "?path=" + encodeURIComponent(p) : ""))
.then(function (r) { return r.json(); })
.then(function (body) {
var rows = $("rows");
rows.innerHTML = "";
if (p) rows.insertAdjacentHTML("beforeend", '<tr><td class="pa-dir" data-up="1">..</td><td></td><td></td><td></td></tr>');
var list = body.entries || [];
for (var i = 0; i < list.length; i++) {
var e = list[i];
var attr = e.type === "dir"
? ' class="pa-dir" data-path="' + encodeURIComponent(p ? p + "/" + e.name : e.name) + '"'
: "";
rows.insertAdjacentHTML("beforeend", "<tr><td" + attr + ">" + paesc(e.name) + "</td><td>" +
paesc(e.type === "dir" ? t("pa.st.dir") : t("pa.st.file")) + '</td><td class="pa-num">' +
pafmtSize(e.size) + "</td><td>" + new Date(e.mtimeMs).toLocaleString() + "</td></tr>");
}
// 面包屑
var parts = ['<span class="pa-crumb" data-path="">' + paesc(t("pa.f.rootShort")) + "</span>"];
var acc = [];
for (var j = 0; j < cur.length; j++) {
acc.push(cur[j]);
parts.push('<span class="pa-csep">/</span><span class="pa-crumb" data-path="' +
paesc(acc.join("/")) + '">' + paesc(cur[j]) + "</span>");
}
var bc = $("breadcrumb");
bc.innerHTML = parts.join("");
bc.querySelectorAll(".pa-crumb").forEach(function (c) {
c.addEventListener("click", function () {
cur = c.dataset.path ? c.dataset.path.split("/").filter(Boolean) : [];
load();
});
});
rows.querySelectorAll(".pa-dir[data-path]").forEach(function (el) {
el.addEventListener("click", function () {
cur = decodeURIComponent(el.dataset.path).split("/").filter(Boolean);
load();
});
});
var up = rows.querySelector(".pa-dir[data-up]");
if (up) up.addEventListener("click", function () { cur = cur.slice(0, -1); load(); });
});
}
function refreshDsh() {
return paReq(base() + "/dsh/status").then(function (r) { return r.ok ? r.json() : null; }).then(function (body) {
if (!body) return;
var running = body.running;
$("dshState").innerHTML = '<span class="pa-dot ' + (running ? "pa-on" : "pa-off") + '"></span>' +
paesc(running ? t("pa.st.running") : t("pa.st.stopped"));
$("stopBtn").style.display = running ? "" : "none";
$("openDshBtn").style.display = running ? "" : "none";
if (running) $("openDshBtn").onclick = function () { window.open(body.url, "_blank"); };
});
}
function doCreate(name, type) {
return papostJson(base() + "/fs/create", { path: pathString(), name: name, type: type }).then(function (res) {
if (!res.ok) {
return res.json().catch(function () { return {}; }).then(function (e) {
root.toast(t("pa.f.createFail") + (e.error || res.status));
});
}
return load();
});
}
$("launchBtn").onclick = function () {
return papostJson(base() + "/dsh/launch", { folder: pathString() }).then(function (res) {
if (!res.ok) {
return res.json().catch(function () { return {}; }).then(function (b) {
root.toast(b.error === "already_running" ? t("pa.f.alreadyRunning") : t("pa.f.launchFail"));
});
}
root.toast(t("pa.f.launched"));
return refreshDsh();
});
};
$("stopBtn").onclick = function () {
return paReq(base() + "/dsh/stop", { method: "POST" }).then(function () { return refreshDsh(); });
};
$("newFolderBtn").onclick = function () {
var name = window.prompt(t("pa.f.newFolderPrompt"));
if (name) doCreate(name, "dir");
};
$("uploadBtn").onclick = function () { $("fileInput").click(); };
$("fileInput").onchange = function (ev) {
var file = ev.target.files[0];
if (!file) return;
var name = file.name;
return pareadAsBase64(file).then(function (data) {
return papostJson(base() + "/fs/upload", { path: pathString(), name: name, data: data }).then(function (res) {
if (!res.ok) {
return res.json().catch(function () { return {}; }).then(function (e) {
root.toast(t("pa.f.uploadFail") + (e.error || res.status));
});
}
ev.target.value = "";
return load();
});
});
};
/** 填「用户」下拉(`/api/admin/users`,requireAdmin);默认选自己。 */
function loadUsers() {
return paReq("/api/admin/users")
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
var list = (d && d.users) || [];
var sel = $("svcUser");
if (!sel) return;
sel.innerHTML = list.map(function (u) {
return '<option value="' + paesc(u.id) + '">' + paesc(u.username) +
(u.role === "admin" ? "(" + paesc(t("pa.role.admin")) + ")" : "") + "</option>";
}).join("");
var mine = list.filter(function (u) { return u.role === "admin"; })[0] || list[0];
uid = mine ? mine.id : "";
sel.value = uid;
sel.onchange = function () { uid = sel.value; cur = []; load(); refreshDsh(); };
});
}
return loadUsers().then(function () { return Promise.all([load(), refreshDsh()]); });
}
};
/** 模型管理 ← 门户 `#/keys`(`renderKeys` + `initKeys`)。 */
PA_PAGES.keys = {
icon: "🔑",
title: function () { return t("pa.p.keys"); },
desc: function () { return t("pa.d.keys"); },
sub: function () { return t("pa.s.keys"); },
html: function () {
return '<div class="pa-box"><div id="keyList"></div>' +
'<div style="margin-top:14px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">' +
'<input id="keyNameInput" class="pa-input" placeholder="' + paesc(t("pa.key.namePh")) + '" />' +
'<input id="keyValueInput" class="pa-input" type="password" placeholder="sk-..." style="flex:1.4;min-width:160px" />' +
'<button class="pa-btn pa-primary" id="addKeyBtn">' + paesc(t("pa.op.add")) + "</button>" +
"</div></div>";
},
init: function ($, root) {
function load() {
return paReq("/api/me/keys").then(function (r) {
if (!r.ok) return;
return r.json().then(function (d) {
var keys = d.keys || [];
var list = $("keyList");
if (keys.length === 0) {
list.innerHTML = '<div class="pa-empty">' + paesc(t("pa.key.empty")) + "</div>";
return;
}
list.innerHTML = keys.map(function (k) {
return '<div class="pa-keyrow"><span class="pa-dot ' + (k.enabled ? "pa-on" : "pa-off") + '"></span>' +
"<strong>" + paesc(k.name) + '</strong><span style="flex:1"></span>' +
(k.enabled
? '<span style="font-size:12px;color:#1a7f37">' + paesc(t("pa.st.enabled")) + "</span>"
: '<button class="pa-sm" data-sel="' + paesc(k.id) + '">' + paesc(t("pa.op.select")) + "</button>") +
'<button class="pa-sm danger" data-del="' + paesc(k.id) + '">' + paesc(t("pa.op.del")) + "</button></div>";
}).join("");
list.querySelectorAll("[data-sel]").forEach(function (b) {
b.onclick = function () {
return paReq("/api/me/keys/" + encodeURIComponent(b.dataset.sel) + "/select", { method: "POST" }).then(load);
};
});
list.querySelectorAll("[data-del]").forEach(function (b) {
b.onclick = function () {
if (!window.confirm(t("pa.key.confirmDel"))) return;
return paReq("/api/me/keys/" + encodeURIComponent(b.dataset.del), { method: "DELETE" }).then(load);
};
});
});
});
}
$("addKeyBtn").onclick = function () {
var name = $("keyNameInput").value.trim();
var apiKey = $("keyValueInput").value.trim();
if (!name || !apiKey) { root.toast(t("pa.key.needBoth")); return; }
return papostJson("/api/me/keys", { name: name, apiKey: apiKey }).then(function (res) {
if (!res.ok) { root.toast(t("pa.key.invalid")); return; }
$("keyNameInput").value = "";
$("keyValueInput").value = "";
return load();
});
};
return load();
}
};
/** 用户管理 ← 门户 `#/users`(`renderUsers` + `initUsers`)。 */
PA_PAGES.users = {
icon: "👥",
title: function () { return t("pa.p.users"); },
desc: function () { return t("pa.d.users"); },
sub: function () { return t("pa.s.users"); },
html: function () {
return '<div class="pa-wrap"><table class="pa-tbl"><thead><tr>' +
"<th>" + paesc(t("pa.k.user")) + "</th><th>" + paesc(t("pa.k.role")) + "</th><th>" + paesc(t("pa.k.reg")) + "</th>" +
'<th class="pa-act">' + paesc(t("pa.k.op")) + "</th></tr></thead>" +
'<tbody id="usersTbody"></tbody></table></div>';
},
init: function ($, root) {
function load() {
return paReq("/api/admin/users").then(function (r) { return r.json(); }).then(function (d) {
var users = d.users || [];
var tbody = $("usersTbody");
var badge = {
admin: t("pa.role.admin"), active: t("pa.role.active"),
pending: t("pa.role.pending"), disabled: t("pa.role.disabled")
};
if (users.length === 0) { tbody.innerHTML = paemptyRow(4, t("pa.u.noUsers")); return; }
tbody.innerHTML = users.map(function (u) {
var actions = u.role === "pending"
? '<button class="pa-sm" data-act="approve" data-id="' + u.id + '">' + paesc(t("pa.op.approve")) + "</button>"
: u.role === "active"
? '<button class="pa-sm danger" data-act="disable" data-id="' + u.id + '">' + paesc(t("pa.op.disable")) + "</button>"
: u.role === "disabled"
? '<button class="pa-sm" data-act="enable" data-id="' + u.id + '">' + paesc(t("pa.op.enable")) + "</button>"
: "";
var del = u.role !== "admin"
? '<button class="pa-sm danger" data-act="del" data-id="' + u.id + '" data-name="' + paesc(u.username) + '">' + paesc(t("pa.op.del")) + "</button>"
: "";
return "<tr><td><strong>" + paesc(u.username) + '</strong></td><td><span class="pa-badge ' + paesc(u.role) + '">' +
paesc(badge[u.role] || u.role) + "</span></td><td>" + new Date(u.createdAt).toLocaleString() +
'</td><td class="pa-act">' + actions + (actions && del ? " " : "") + del + "</td></tr>";
}).join("");
// 事件委托挂在 tbody 上(门户写法;重渲染后无需重绑)
tbody.onclick = function (event) {
var btn = event.target.closest("button[data-act]");
if (!btn) return;
var act = btn.dataset.act;
var id = btn.dataset.id;
if (act === "del") {
var name = window.prompt(pafmt(t("pa.u.confirmDel"), { name: btn.dataset.name }));
if (name !== btn.dataset.name) { root.toast(t("pa.u.mismatch")); return; }
return paReq("/api/admin/users/" + encodeURIComponent(id), { method: "DELETE" }).then(function (res) {
if (res.ok) return load();
root.toast(t("pa.u.delFail"));
});
}
return paReq("/api/admin/users/" + encodeURIComponent(id) + "/" + act, { method: "POST" }).then(function (res) {
if (res.ok) return load();
root.toast(t("pa.failed"));
});
};
});
}
return load();
}
};
/** 技能管理 ← 门户 `#/skills`(`renderSkills` + `initSkills`)。 */
PA_PAGES.skills = {
icon: "🧩",
title: function () { return t("pa.p.skills"); },
desc: function () { return t("pa.d.skills"); },
sub: function () { return t("pa.s.skills"); },
html: function () {
return '<div class="pa-box">' +
'<div class="pa-row"><input type="file" id="skillFile" accept=".zip" />' +
'<button class="pa-btn pa-primary" id="skillUploadBtn">' + paesc(t("pa.op.upload")) + "</button></div>" +
'<p class="pa-hint">' + paesc(t("pa.sk.hint")) + "</p>" +
'<div class="pa-wrap"><table class="pa-tbl"><thead><tr>' +
"<th>" + paesc(t("pa.k.skill")) + "</th><th>" + paesc(t("pa.k.desc")) + "</th>" +
'<th class="pa-num" style="text-align:right">' + paesc(t("pa.k.files")) + "</th>" +
'<th class="pa-num" style="text-align:right">' + paesc(t("pa.k.size")) + "</th>" +
"<th>" + paesc(t("pa.k.updated")) + "</th>" +
'<th class="pa-act">' + paesc(t("pa.k.op")) + "</th>" +
'</tr></thead><tbody id="skillTbody"></tbody></table></div></div>';
},
init: function ($, root) {
function load() {
var tbody = $("skillTbody");
tbody.innerHTML = paemptyRow(6, t("pa.loading"));
return paReq("/api/skills/shared").then(function (r) { return r.json(); }).then(function (d) {
var skills = d.skills || [];
if (skills.length === 0) { tbody.innerHTML = paemptyRow(6, t("pa.sk.empty")); return; }
tbody.innerHTML = skills.map(function (s) {
return "<tr><td><strong>" + paesc(s.name) + '</strong></td><td class="pa-desc" title="' + paesc(s.description) + '">' +
(paesc(s.description) || "—") + '</td><td class="pa-num">' + (s.files == null ? "—" : s.files) +
'</td><td class="pa-num">' + pafmtSize(s.size) + "</td><td>" + pawhen(s.mtimeMs) +
'</td><td class="pa-act"><button class="pa-sm danger" data-del="' + paesc(s.name) + '">' + paesc(t("pa.op.del")) + "</button></td></tr>";
}).join("");
}).catch(function () { tbody.innerHTML = paemptyRow(6, t("pa.sk.loadFail")); });
}
$("skillUploadBtn").onclick = function () {
var input = $("skillFile");
var file = input.files[0];
if (!file) { root.toast(t("pa.sk.pickZip")); return; }
if (!file.name.toLowerCase().endsWith(".zip")) { root.toast(t("pa.sk.zipOnly")); return; }
if (file.size > 150 * 1024 * 1024) { root.toast(t("pa.sk.tooBig")); return; }
var btn = $("skillUploadBtn");
btn.disabled = true;
btn.textContent = t("pa.sk.detecting");
var fname = file.name;
return pareadAsBase64(file).then(function (base64) {
return papostJson("/api/skills/shared", { file: base64, filename: fname }).then(function (res) {
return res.json().catch(function () { return {}; }).then(function (data) {
if (!res.ok) { root.toast(t("pa.sk.uploadFail") + (data.error == null ? res.status : data.error)); return; }
if (!data.conflict) {
root.toast(pafmt(t("pa.sk.installed"), { name: data.skill.name }));
input.value = "";
return load();
}
if (!window.confirm(pafmt(t("pa.sk.replaceConfirm"), { name: data.skill.name }))) {
root.toast(t("pa.sk.canceled"));
return;
}
return papostJson("/api/skills/shared/apply", { stagedId: data.stagedId }).then(function (r2) {
return r2.json().catch(function () { return {}; }).then(function (d2) {
if (!r2.ok) { root.toast(t("pa.sk.replaceFail") + (d2.error == null ? r2.status : d2.error)); return; }
root.toast(pafmt(t("pa.sk.replaced"), { name: d2.skill.name }));
input.value = "";
return load();
});
});
});
});
}).catch(function () { root.toast(t("pa.sk.uploadFail")); })
.then(function () { btn.disabled = false; btn.textContent = t("pa.op.upload"); });
};
$("skillTbody").onclick = function (event) {
var btn = event.target.closest("button[data-del]");
if (!btn) return;
if (!window.confirm(pafmt(t("pa.sk.delConfirm"), { name: btn.dataset.del }))) return;
return paReq("/api/skills/shared/" + encodeURIComponent(btn.dataset.del), { method: "DELETE" }).then(function (res) {
if (res.ok) { root.toast(t("pa.sk.deleted")); return load(); }
root.toast(t("pa.sk.delFail"));
});
};
return load();
}
};
/** 插件管理 ← 门户 `#/plugins`(`renderPlugins` + `initPlugins`);两个页签同门户。 */
PA_PAGES.plugins = {
icon: "🔌",
title: function () { return t("pa.p.plugins"); },
desc: function () { return t("pa.d.plugins"); },
sub: function () { return t("pa.s.plugins"); },
html: function () {
return '<div class="pa-tabs" id="pluginTabs">' +
'<div class="pa-tab pa-on" data-tab="official">' + paesc(t("pa.pl.tabOfficial")) + "</div>" +
'<div class="pa-tab" data-tab="manual">' + paesc(t("pa.pl.tabManual")) + '<span class="pa-tcnt" id="poolCnt">0</span></div>' +
"</div>" +
'<div id="pane-official"><div class="pa-box">' +
'<div class="pa-row">' +
'<input id="wlQ" class="pa-input" placeholder="' + paesc(t("pa.pl.searchPh")) + '" />' +
'<select id="wlCat" class="pa-input" style="flex:none;min-width:130px"><option value="">' + paesc(t("pa.pl.allCat")) + "</option></select>" +
'<label class="pa-check"><input type="checkbox" id="wlOnlyImportable" checked />' + paesc(t("pa.pl.onlyImportable")) + "</label>" +
// 档案 92:默认按 dsh 版本过滤(隐藏「仅兼容更旧版本」),此勾为显式逃生口。
'<label class="pa-check"><input type="checkbox" id="wlAllCompat" />' + paesc(t("pa.pl.showAllCompat")) + "</label>" +
'<button class="pa-btn" id="wlSearchBtn">' + paesc(t("pa.op.search")) + "</button>" +
'<button class="pa-btn" id="wlRefreshBtn">' + paesc(t("pa.op.refreshDir")) + "</button>" +
"</div>" +
'<p class="pa-hint">' + paesc(t("pa.pl.hintOfficial")) + "</p>" +
'<div style="margin-bottom:8px"><span style="' + PA_DIM + ';font-size:13px" id="wlInfo">' +
paesc(t("pa.pl.wlLoading")) + "</span></div>" +
// 门户此表 max-height 取 `calc(100vh - 520px)`(整页布局,不适用弹窗)。
// 弹窗内改用 `.pa-plist`:拉高到「离弹窗底部约 100px」,见该类的 CSS 注释。
'<div class="pa-wrap pa-plist"><table class="pa-tbl"><thead><tr>' +
'<th style="width:34px"></th>' +
"<th>" + paesc(t("pa.k.pluginDesc")) + "</th>" +
"<th>" + paesc(t("pa.k.cat")) + "</th>" +
"<th>" + paesc(t("pa.k.importKind")) + "</th>" +
// 档案 92:dsh 版本兼容列(「需更高版本」是用户要的显式标注)
"<th>" + paesc(t("pa.pl.compatCol")) + "</th>" +
'<th class="pa-num" style="text-align:right">' + paesc(t("pa.k.downloads")) + "</th>" +
'<th class="pa-act">' + paesc(t("pa.k.detail")) + "</th>" +
'</tr></thead><tbody id="wlTbody"></tbody></table></div>' +
'<div style="margin-top:10px;display:flex;align-items:center;gap:12px">' +
'<button class="pa-btn pa-primary" id="wlImportBtn">' + paesc(t("pa.op.import")) + "</button>" +
'<span style="' + PA_DIM + ';font-size:13px" id="wlSelInfo">' + paesc(pafmt(t("pa.pl.selInfo"), { n: 0 })) + "</span>" +
"</div>" +
'<div id="wlResult" style="margin-top:12px"></div>' +
"</div></div>" +
'<div id="pane-manual" hidden>' +
'<div class="pa-box" style="margin-bottom:16px">' +
'<div class="pa-card-h">' + paesc(t("pa.pl.cardPublish")) +
'<span class="sub">' + paesc(t("pa.pl.cardPublishSub")) + "</span></div>" +
'<div class="pa-row"><input type="file" id="pluginFile" accept=".tgz" />' +
'<button class="pa-btn pa-primary" id="pluginUploadBtn">' + paesc(t("pa.op.upload")) + "</button></div>" +
'<p class="pa-hint" style="margin:0">' + paesc(t("pa.pl.hintTgz")) + "</p>" +
'<div id="pluginScanBox"></div>' +
"</div>" +
'<div class="pa-box">' +
'<div class="pa-card-h">' + paesc(t("pa.pl.cardPublished")) +
'<span class="sub" id="poolHint">' + paesc(pafmt(t("pa.pl.poolHint"), { n: 0 })) + "</span></div>" +
'<p class="pa-hint">' + paesc(t("pa.pl.hintManual")) + "</p>" +
'<div class="pa-wrap"><table class="pa-tbl"><thead><tr>' +
"<th>" + paesc(t("pa.k.plugin")) + "</th>" +
"<th>" + paesc(t("pa.k.ver")) + "</th>" +
"<th>" + paesc(t("pa.k.desc")) + "</th>" +
'<th class="pa-num" style="text-align:right">' + paesc(t("pa.k.size")) + "</th>" +
"<th>" + paesc(t("pa.k.deployed")) + "</th>" +
'<th class="pa-act">' + paesc(t("pa.k.op")) + "</th>" +
'</tr></thead><tbody id="pluginTbody"></tbody></table></div>' +
"</div>" +
"</div>";
},
init: function ($, root) {
var wlSelected = {};
var WL_KIND = function (kind) {
if (kind === "npm") return { label: t("pa.pl.importNpm"), cls: "active" };
if (kind === "tarball") return { label: t("pa.pl.importTarball"), cls: "admin" };
return { label: t("pa.pl.importSource"), cls: "disabled" };
};
/**
* 档案 92:dsh 版本兼容徽章。判定在平台侧做(官方目录不带 dsh 版本字段 ⇒
* 逐包查 npm 声明),口径与实测依据见 `src/web/plugin-dsh-compat.ts` 头部注释。
* `cur` = 当前平台 dsh 版本,只用于 tooltip 给对比。
*/
function wlCompat(p, cur) {
var v = p.dshCompat;
var label, tip, cls;
if (v === "newer") {
label = t("pa.pl.cmpNewer") + (p.dshRequires ? " ≥ " + p.dshRequires : "");
tip = pafmt(t("pa.pl.cmpNewerTip"), { v: p.dshRequires || "?", cur: cur || "?" });
cls = "pending"; // 橙色:用户点名要「明确标注」的就是这一类
} else if (v === "match") {
label = t("pa.pl.cmpMatch");
tip = pafmt(t("pa.pl.cmpMatchTip"), { cur: cur || "?" });
cls = "active";
} else if (v === "none") {
label = t("pa.pl.cmpNone");
tip = t("pa.pl.cmpNoneTip");
cls = "disabled";
} else if (v === "older") {
label = t("pa.pl.cmpOlder");
tip = t("pa.pl.cmpOlderTip");
cls = "disabled";
} else {
label = t("pa.pl.cmpUnknown");
tip = t("pa.pl.cmpUnknownTip");
cls = "disabled";
}
return '<span class="pa-badge ' + cls + '" title="' + paesc(tip) + '">' + paesc(label) + "</span>";
}
/** 页签切换(门户另写 hash 深链,弹窗内不需要)。 */
function showTab(key) {
$.__querySelectorAll("#pluginTabs .pa-tab").forEach(function (x) {
x.classList.toggle("pa-on", x.dataset.tab === key);
});
$("pane-official").hidden = key !== "official";
$("pane-manual").hidden = key !== "manual";
}
function load() {
var tbody = $("pluginTbody");
tbody.innerHTML = paemptyRow(6, t("pa.loading"));
return paReq("/api/plugins/business").then(function (r) { return r.json(); }).then(function (d) {
var plugins = d.plugins || [];
if ($("poolCnt")) $("poolCnt").textContent = String(plugins.length);
if ($("poolHint")) $("poolHint").textContent = pafmt(t("pa.pl.poolHint"), { n: plugins.length });
if (plugins.length === 0) { tbody.innerHTML = paemptyRow(6, t("pa.pl.empty")); return; }
tbody.innerHTML = plugins.map(function (p) {
return "<tr><td><strong>" + paesc(p.name) + "</strong></td><td>" + (paesc(p.version) || "—") +
'</td><td class="pa-desc" title="' + paesc(p.description) + '">' + (paesc(p.description) || "—") +
'</td><td class="pa-num">' + pafmtSize(p.fileSize) + "</td><td>" + pawhen(p.updatedAt) +
'</td><td class="pa-act"><button class="pa-sm danger" data-del="' + paesc(p.id) + '">' + paesc(t("pa.op.del")) + "</button></td></tr>";
}).join("");
}).catch(function () { tbody.innerHTML = paemptyRow(6, t("pa.sk.loadFail")); });
}
function loadWhitelist() {
var tbody = $("wlTbody");
tbody.innerHTML = paemptyRow(7, t("pa.pl.wlLoading"));
var q = encodeURIComponent($("wlQ").value.trim());
var cat = encodeURIComponent($("wlCat").value);
var only = $("wlOnlyImportable").checked ? "1" : "";
// 档案 92:默认按 dsh 版本过滤;勾了「含仅兼容更旧版本的」才不过滤(排障用)。
var cmp = $("wlAllCompat").checked ? "&dshCompat=all" : "";
return paReq("/api/plugins/whitelist?q=" + q + "&category=" + cat + "&onlyImportable=" + only + cmp)
.then(function (r) { return r.json().catch(function () { return {}; }).then(function (d) {
if (!r.ok) { tbody.innerHTML = paemptyRow(7, t("pa.pl.wlFail") + ":" + (d.error == null ? r.status : d.error)); return; }
var sel = $("wlCat");
if (sel.dataset.filled !== "1") {
(d.categories || []).forEach(function (c) {
sel.insertAdjacentHTML("beforeend", '<option value="' + paesc(c.id) + '">' + paesc(c.label) + "(" + c.count + ")</option>");
});
sel.dataset.filled = "1";
}
var shown = (d.plugins || []).length;
// 档案 92:把「按 dsh 版本过滤了什么」说清楚,绝不让条目静默消失。
var unknownN = (d.plugins || []).filter(function (x) { return x.dshCompat === "unknown"; }).length;
var cmpNote = d.dshVersion
? (d.compatFiltered === false
? t("pa.pl.cmpAll")
: pafmt(t("pa.pl.cmpHidden"), { cur: d.dshVersion, n: d.hiddenByDsh || 0 }))
: "";
$("wlInfo").textContent = pafmt(t("pa.pl.wlInfo"), {
total: d.total, importable: d.importable, count: d.count, shown: shown, when: parelTime(d.fetchedAt)
}) + cmpNote + (unknownN > 0 ? t("pa.pl.cmpWarming") : "") + (d.stale ? t("pa.pl.wlStale") : "");
if (shown === 0) { tbody.innerHTML = paemptyRow(7, t("pa.pl.wlEmpty")); return; }
tbody.innerHTML = d.plugins.map(function (p) {
var k = WL_KIND(p.importKind);
var dis = p.importKind === "source";
// 主视觉 = 中文说明(一眼知道这插件干什么);插件名 / npm 包名 / 版本退为副行小字。
var desc = (p.description || "").trim();
var primary = desc !== "" ? desc : p.name;
var meta = [
p.name,
p.npm ? p.npm + (p.version ? " @ " + p.version : "") : "",
(p.stars || 0) > 0 ? "★ " + p.stars : ""
].filter(Boolean).join(" · ");
return "<tr" + (dis ? ' style="opacity:.55"' : "") + ">" +
'<td><input type="checkbox" data-wl="' + paesc(p.id) + '"' + (dis ? " disabled" : "") +
(wlSelected[p.id] ? " checked" : "") + " /></td>" +
'<td class="pa-wlcell"><div class="pa-wldesc' + (desc === "" ? " pa-fb" : "") + '" title="' + paesc(primary) + '">' +
paesc(primary) + '</div><div class="pa-wlmeta" title="' + paesc(meta) + '">' + paesc(meta) + "</div></td>" +
'<td><span class="pa-wlcat">' + (paesc(p.categoryLabel || p.category) || "—") + "</span></td>" +
'<td><span class="pa-badge ' + k.cls + '">' + paesc(k.label) + "</span></td>" +
'<td style="white-space:nowrap">' + wlCompat(p, d.dshVersion) + "</td>" +
'<td class="pa-num">' + (p.downloads || 0) + "</td>" +
'<td class="pa-act">' + (p.page
? '<a class="pa-sm pa-wllink" href="' + paesc(p.page) + '" target="_blank" rel="noreferrer noopener">' + paesc(t("pa.op.detail")) + "</a>"
: "—") + "</td></tr>";
}).join("");
tbody.querySelectorAll("input[data-wl]").forEach(function (cb) {
cb.onchange = function () {
if (cb.checked) wlSelected[cb.dataset.wl] = true;
else delete wlSelected[cb.dataset.wl];
$("wlSelInfo").textContent = pafmt(t("pa.pl.selInfo"), { n: Object.keys(wlSelected).length });
};
});
}); });
}
$.__querySelectorAll("#pluginTabs .pa-tab").forEach(function (x) { x.onclick = function () { showTab(x.dataset.tab); }; });
$("wlSearchBtn").onclick = function () { return loadWhitelist(); };
$("wlQ").onkeydown = function (e) { if (e.key === "Enter") loadWhitelist(); };
$("wlCat").onchange = function () { return loadWhitelist(); };
$("wlOnlyImportable").onchange = function () { return loadWhitelist(); };
$("wlAllCompat").onchange = function () { return loadWhitelist(); };
$("wlRefreshBtn").onclick = function () {
var b = $("wlRefreshBtn");
b.disabled = true;
b.textContent = t("pa.op.repull");
return papostJson("/api/plugins/whitelist/refresh", {}).then(function () {
wlSelected = {};
$("wlSelInfo").textContent = pafmt(t("pa.pl.selInfo"), { n: 0 });
return loadWhitelist();
}).then(function () { root.toast(t("pa.pl.repullOk")); })
.catch(function () { root.toast(t("pa.pl.repullFail")); })
.then(function () { b.disabled = false; b.textContent = t("pa.op.refreshDir"); });
};
$("wlImportBtn").onclick = function () {
var names = Object.keys(wlSelected);
if (names.length === 0) { root.toast(t("pa.pl.importPick")); return; }
if (names.length > 20) { root.toast(t("pa.pl.importMax")); return; }
if (!window.confirm(pafmt(t("pa.pl.importConfirm"), { n: names.length }))) return;
var btn = $("wlImportBtn");
btn.disabled = true;
btn.textContent = t("pa.pl.importing");
$("wlResult").innerHTML = "";
return papostJson("/api/plugins/whitelist/import", { names: names })
.then(function (r) { return r.json().catch(function () { return {}; }).then(function (d) {
var results = d.results || [];
var ok = results.filter(function (x) { return x.ok; });
var fail = results.filter(function (x) { return !x.ok; });
root.toast(pafmt(t("pa.pl.importResult"), { ok: ok.length, total: names.length }) +
(fail.length ? pafmt(t("pa.pl.importFailTail"), { fail: fail.length }) : ""));
if (ok.length > 0 && fail.length === 0) {
$("wlResult").innerHTML = '<div class="pa-note" style="border-left:none;background:rgba(26,127,55,.08)">' +
'<div style="' + PA_DIM + '">' + paesc(pafmt(t("pa.pl.importOkBox"), { n: ok.length })) + "</div></div>";
}
if (fail.length) {
$("wlResult").innerHTML = '<div class="pa-note" style="border-left:none;background:rgba(217,48,38,.07)">' +
'<div style="font-size:13px;font-weight:600;color:#d93026;margin-bottom:6px">' +
paesc(pafmt(t("pa.pl.failTitle"), { n: fail.length })) + "</div>" +
fail.map(function (x) {
return '<div style="font-size:13px;' + PA_DIM + ';margin:4px 0">' +
"<b style=\"color:var(--dsw-alias-label-primary,#24292f)\">" + paesc(x.id) + "</b>:" + paesc(x.error) + "</div>";
}).join("") + "</div>";
}
results.forEach(function (x) { if (x.ok) delete wlSelected[x.id]; });
$("wlSelInfo").textContent = pafmt(t("pa.pl.selInfo"), { n: Object.keys(wlSelected).length });
return Promise.all([loadWhitelist(), load()]);
}); })
.catch(function () { root.toast(t("pa.pl.importErr")); })
.then(function () { btn.disabled = false; btn.textContent = t("pa.op.import"); });
};
/**
* 上传投放。`trust=false` 走默认路径(命中 P0 即被拒绝并回显详情);
* `trust=true` 表示 admin 已逐条确认并显式声明信任 —— 平台放行并写审计。
*/
function doUpload(file, trust) {
var input = $("pluginFile");
var btn = $("pluginUploadBtn");
btn.disabled = true;
btn.textContent = trust ? t("pa.pl.publishing") : t("pa.sk.detecting");
$("pluginScanBox").innerHTML = "";
var fname = file.name;
return pareadAsBase64(file).then(function (base64) {
var payload = { file: base64, filename: fname };
if (trust) {
payload.trust = { confirmed: true, reason: ($("trustReason") && $("trustReason").value.trim()) || "" };
}
return papostJson("/api/plugins/business", payload).then(function (res) {
return res.json().catch(function () { return {}; }).then(function (data) {
if (res.status === 409 && (data.error === "scan_blocked" || data.error === "compat_incompatible")) {
renderScanBlocked(file, data);
return;
}
if (!res.ok) { root.toast(t("pa.pl.uploadFail") + (data.error == null ? res.status : data.error)); return; }
root.toast(pafmt(data.replaced ? t("pa.pl.replacedOk") : t("pa.pl.published"), { name: data.plugin.name }) +
(data.trustedOverride ? t("pa.pl.trusted") : ""));
input.value = "";
return load();
});
});
}).catch(function () { root.toast(t("pa.pl.uploadFail")); })
.then(function () { btn.disabled = false; btn.textContent = t("pa.op.upload"); });
}
/**
* 渲染拒绝详情 + 显式信任入口。两类拒绝共用(信息给足,让 admin 真的看到自己信任了什么):
* `scan_blocked` = 安全检测命中 P0;`compat_incompatible` = 与平台 dsh 版本不兼容。
*/
function renderScanBlocked(file, data) {
var isCompat = data.error === "compat_incompatible";
var list = isCompat ? ((data.compat && data.compat.findings) || []) : (data.blocked || []);
var warn = (data.warnings || []).length;
var rows = list.map(function (b) {
return '<li style="margin:3px 0"><code style="font-size:12px">' +
paesc(isCompat ? (b.pkg + (b.file ? " ← " + b.file : "")) : b.file) + "</code> — " +
paesc(isCompat ? b.detail : b.why) + "</li>";
}).join("");
var title = isCompat
? pafmt(t("pa.pl.scanCompat"), { n: list.length })
: pafmt(t("pa.pl.scanP0"), { n: list.length });
$("pluginScanBox").innerHTML = '<div class="pa-note" style="margin-top:12px;background:rgba(217,48,38,.06)">' +
'<div style="font-size:13px;font-weight:600;color:#d93026;margin-bottom:6px">' + paesc(title) + "</div>" +
'<div style="font-size:13px;' + PA_DIM + ';line-height:1.7;margin-bottom:8px">' + paesc(data.message || "") + "</div>" +
'<ul style="margin:0 0 10px 18px;padding:0;font-size:13px">' + rows + "</ul>" +
(warn ? '<div style="font-size:12px;' + PA_DIM + ';margin-bottom:8px">' + paesc(pafmt(t("pa.pl.warnP1"), { n: warn })) + "</div>" : "") +
'<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">' +
'<input id="trustReason" class="pa-input" placeholder="' + paesc(t("pa.pl.trustPh")) + '" style="min-width:200px" />' +
'<button class="pa-btn pa-primary" id="trustConfirmBtn">' + paesc(t("pa.pl.trustBtn")) + "</button>" +
"</div>" +
'<div style="font-size:12px;' + PA_DIM + ';margin-top:6px;line-height:1.6">' + paesc(t("pa.pl.trustNote")) + "</div></div>";
$("trustConfirmBtn").onclick = function () { return doUpload(file, true); };
}
$("pluginUploadBtn").onclick = function () {
var input = $("pluginFile");
var file = input.files[0];
if (!file) { root.toast(t("pa.pl.pickTgz")); return; }
if (!file.name.toLowerCase().endsWith(".tgz")) { root.toast(t("pa.pl.tgzOnly")); return; }
if (file.size > 150 * 1024 * 1024) { root.toast(t("pa.sk.tooBig")); return; }
return doUpload(file, false);
};
$("pluginTbody").onclick = function (event) {
var btn = event.target.closest("button[data-del]");
if (!btn) return;
if (!window.confirm(pafmt(t("pa.pl.delConfirm"), { name: btn.dataset.del }))) return;
return paReq("/api/plugins/business/" + encodeURIComponent(btn.dataset.del), { method: "DELETE" }).then(function (res) {
if (res.ok) { root.toast(t("pa.pl.deleted")); return load(); }
root.toast(t("pa.pl.delFail"));
});
};
// 初始页签(门户读 hash 后缀,弹窗内固定从「官方推荐插件」进)
showTab("official");
return Promise.all([load(), loadWhitelist()]);
}
};
/** 运行环境 ← 门户 `#/runtime`(`renderRuntime` + `initRuntime`,只读)。 */
PA_PAGES.runtime = {
icon: "📦",
title: function () { return t("pa.p.runtime"); },
desc: function () { return t("pa.d.runtime"); },
sub: function () { return t("pa.s.runtime"); },
html: function () {
return '<div class="pa-box" id="rtWrap"><div class="pa-empty">' + paesc(t("pa.loading")) + "</div></div>";
},
init: function ($) {
var wrap = $("rtWrap");
var mb = function (b) { return b >= 1048576 ? (b / 1048576).toFixed(1) + " MB" : (b / 1024).toFixed(0) + " KB"; };
var bd = "var(--dsw-alias-border-l2,#e3e6ea)";
var cell = "padding:8px 10px;border-bottom:1px solid " + bd + ";vertical-align:top";
var mon = "font-family:ui-monospace,SFMono-Regular,Menlo,monospace";
return paReq("/api/admin/runtime").then(function (r) { return r.json(); }).then(function (rt) {
var rows = (rt.items || []).map(function (i) {
return "<tr>" +
'<td style="' + cell + '"><b>' + paesc(i.name) + '</b><div style="font-size:12px;' + PA_DIM + '">' +
paesc(i.group) + " · " + paesc(i.kind) + "</div></td>" +
'<td style="' + cell + ";" + mon + ';font-size:12px">' + paesc(String(i.version).slice(0, 60)) + "</td>" +
'<td style="' + cell + ';font-size:12px">' + paesc(i.source) + "</td>" +
'<td style="' + cell + ";" + mon + ';font-size:12px">' + paesc(i.script) + "</td>" +
'<td style="' + cell + ';font-size:12px">' +
paesc(i.removable ? t("pa.st.removable") : t("pa.st.required")) + "</td></tr>";
}).join("");
var drift = rt.drift && rt.drift.length
? '<div style="margin:10px 0;padding:10px 12px;border-radius:8px;background:#FCEBEB;color:#501313;font-size:13px">' +
"⚠️ " + paesc(pafmt(t("pa.rt.drift"), { list: rt.drift.join(";") })) + "<br>" +
paesc(pafmt(t("pa.rt.driftHint"), { script: rt.baselineScript })) + "</div>"
: '<div style="margin:10px 0;padding:10px 12px;border-radius:8px;background:#E1F5EE;color:#04342C;font-size:13px">' +
paesc(t("pa.rt.ok")) +
(rt.baseline && rt.baseline.acceptedAt
? paesc(pafmt(t("pa.rt.okAt"), { t: String(rt.baseline.acceptedAt).slice(0, 19) }))
: "") + "</div>";
wrap.innerHTML =
'<div style="font-size:13px;' + PA_DIM + ';margin-bottom:12px">' + paesc(rt.note) + "</div>" + drift +
'<table style="width:100%;border-collapse:collapse;font-size:13px"><thead><tr>' +
['pa.k.name', 'pa.k.ver', 'pa.k.src', 'pa.k.script', 'pa.k.removable'].map(function (k) {
return '<th style="text-align:left;padding:8px 10px;border-bottom:1px solid ' + bd + '">' + paesc(t(k)) + "</th>";
}).join("") +
"</tr></thead><tbody>" + rows + "</tbody></table>" +
'<div style="margin-top:16px;font-size:13px;line-height:1.9">' +
"<b>" + paesc(t("pa.rt.dir")) + "</b>:<code>" + paesc(rt.runtimeDir.path) + "</code> " +
"<b>" + paesc(t("pa.rt.size")) + "</b>:" + mb(rt.runtimeDir.bytes) + " " +
"<b>" + paesc(t("pa.rt.changed")) + "</b>:" + paesc(String(rt.runtimeDir.installedAt).slice(0, 19)) +
"</div>" +
'<details style="margin-top:12px;font-size:13px"><summary style="cursor:pointer">' +
paesc(t("pa.rt.q1")) + "</summary>" +
'<div style="margin-top:8px;line-height:1.9;white-space:pre-wrap">' +
paesc(pafmt(t("pa.rt.a1"), { script: rt.baselineScript, dir: rt.runtimeDir.path })) + "</div></details>" +
'<details style="margin-top:12px;font-size:13px"><summary style="cursor:pointer">' +
paesc(t("pa.rt.q2")) + "</summary>" +
'<pre style="margin-top:8px;white-space:pre-wrap;font-size:12px">' + paesc(rt.manifest) + "</pre></details>";
}).catch(function (err) {
wrap.innerHTML = '<div class="pa-empty">' + paesc(t("pa.rt.loadFail") + err.message) + "</div>";
});
}
};
/** 分组 ← 门户 `renderHome()` 的 sec1(服务)+ sec2(管理),顺序与门户一致。 */
var PA_GROUPS = [
{ label: function () { return t("pa.group.svc"); }, keys: ["files", "keys"] },
{ label: function () { return t("pa.group.mgmt"); }, keys: ["users", "skills", "plugins", "runtime"] }
];
/**
* 门户页面宿主:**门户原文(HTML 字符串) + 门户 init 绑定**,不重写成 React 树 ——
* 这样表格 / 文案与 `web/portal.html` 逐字一致,改动可逐函数对照。
* `dangerouslySetInnerHTML` 注入原文;`useEffect` 在 DOM 就绪后执行门户的事件绑定。
*/
function PortalPage(props) {
var host = null;
React.useEffect(function () {
if (!host || typeof props.init !== "function") return undefined;
var el = host;
var root = {
$: function (id) { return el.querySelector("#" + id); },
all: function (sel) { return el.querySelectorAll(sel); },
toast: function (text) {
var box = el.querySelector(".pa-toast");
if (!box) return;
box.textContent = text;
box.classList.add("pa-show");
clearTimeout(box.__paTimer);
box.__paTimer = setTimeout(function () { box.classList.remove("pa-show"); }, 1800);
}
};
root.$.__querySelectorAll = function (sel) { return el.querySelectorAll(sel); };
var cleanup;
try {
cleanup = props.init(root.$, root);
} catch (e) {
console.error("[platform-admin]", props.pageKey, e);
}
return function () { if (typeof cleanup === "function") cleanup(); };
}, [props.pageKey]);
return jsxRuntime.jsx("div", {
className: "pa-page",
ref: function (n) { host = n; },
dangerouslySetInnerHTML: { __html: props.html + '<div class="pa-toast"></div>' }
});
}
// ── 「系统管理」就地管理面(第 3 轮 · 2026-09-13 · 见档案 82 §追加)────────────
// 形态:**原生渲染**(非 iframe)——复用 dsh 官方 token 与 06-工作台UI规范 字号阶梯。
// 范围:与门户 `web/portal.html` **同一批功能**(服务管理 / 密钥管理 / 用户管理 /
// 技能管理 / 插件管理 / 运行环境),点开每一项即在弹窗内完成该页的全部操作。
// ⚠️ 这**推翻了 R2 初版「只读子集 + 写操作回跳门户」**的口径:2026-09-13 用户明确
// 要求「点开弹窗里面展示的内容,要和门户点击功能后那种详细的表格和功能一致」。
// 可见性:**仅 admin**(先 `/api/auth/me` 判角色;非 admin 连分区都不注册)。
function PlatformAdminSection() {
var useState = React.useState;
var useEffect = React.useEffect;
var st = useState({ loading: true, role: null });
var s = st[0];
var setS = st[1];
var pv = useState(null); // 当前打开的功能页 key(null = 系统管理首页)
var pageKey = pv[0];
var setPageKey = pv[1];
useEffect(function () {
var alive = true;
fetch(portalHost() + "/api/auth/me", { credentials: "include" })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { if (alive) setS({ loading: false, role: d && d.user ? d.user.role : null }); })
.catch(function () { if (alive) setS({ loading: false, role: null }); });
return function () { alive = false; };
}, []);
if (s.loading) {
return jsxRuntime.jsxs("div", { style: { padding: "4px 2px" }, children: [
jsxRuntime.jsx("h1", { key: "h", className: "pa-hd", children: t("pa.loading") }),
jsxRuntime.jsx("p", { key: "d", className: "pa-sub", children: t("pa.loadingDesc") })
] });
}
if (s.role !== "admin") {
return jsxRuntime.jsxs("div", { style: { padding: "4px 2px" }, children: [
jsxRuntime.jsx("h1", { key: "h", className: "pa-hd", children: t("pa.notAdminTitle") }),
jsxRuntime.jsx("p", { key: "d", className: "pa-sub", children: t("pa.notAdminDesc") })
] });
}
/** 首页 ← 门户 `renderHome()`:分组标题 + 三行卡片(图标 / 名称 / 说明)。 */
function home() {
var kids = [
jsxRuntime.jsx("h1", { key: "h", className: "pa-hd", children: t("pa.label") }),
jsxRuntime.jsx("p", { key: "s", className: "pa-sub", children: t("pa.homeSub") })
];
PA_GROUPS.forEach(function (g, gi) {
kids.push(jsxRuntime.jsx("div", { key: "gh" + gi, className: "pa-sec", children: g.label() }));
kids.push(jsxRuntime.jsx("div", { key: "gg" + gi, className: "pa-grid", children: g.keys.map(function (k) {
var p = PA_PAGES[k];
return jsxRuntime.jsx("div", {
key: k, role: "button", tabIndex: 0, className: "pa-card",
onClick: function () { setPageKey(k); },
onKeyDown: function (e) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setPageKey(k); } },
children: [
jsxRuntime.jsx("div", { key: "i", className: "pa-ic", children: p.icon }),
jsxRuntime.jsx("div", { key: "t", className: "pa-t", children: p.title() }),
jsxRuntime.jsx("div", { key: "d", className: "pa-d", children: p.desc() })
]
});
}) }));
});
return jsxRuntime.jsx("div", {
style: { padding: "4px 2px", display: "flex", flexDirection: "column", gap: "12px" },
children: kids
});
}
/** 功能页 ← 门户 `.page-head`(← 返回 + 标题 + 副标题 + 关闭)+ 门户原文做体。 */
function detail(p) {
return jsxRuntime.jsx("div", {
className: "pa-overlay",
onClick: function () { setPageKey(null); },
children: jsxRuntime.jsxs("div", {
className: "pa-modal pa-wide",
onClick: function (e) { e.stopPropagation(); },
children: [
jsxRuntime.jsxs("div", { key: "h", className: "pa-mhead", children: [
jsxRuntime.jsxs("div", { className: "pa-phead", children: [
jsxRuntime.jsx("button", {
type: "button", className: "pa-btn",
onClick: function () { setPageKey(null); }, children: t("pa.back")
}),
jsxRuntime.jsxs("div", { style: { minWidth: 0 }, children: [
jsxRuntime.jsx("h1", { className: "pa-hd", children: p.title() }),
jsxRuntime.jsx("p", { className: "pa-sub", children: p.sub() })
] })
] }),
jsxRuntime.jsx("button", {
type: "button", className: "pa-sm", style: { marginLeft: "auto", flex: "none" },
onClick: function () { setPageKey(null); }, children: t("pa.close")
})
] }),
jsxRuntime.jsx("div", { key: "b", className: "pa-mbody", children:
// key 绑定 pageKey ⇒ 换页时重建组件,确保门户 init 按新 DOM 重新绑定
jsxRuntime.jsx(PortalPage, { pageKey: pageKey, html: p.html(), init: p.init }, pageKey) })
]
})
});
}
return (pageKey && PA_PAGES[pageKey]) ? detail(PA_PAGES[pageKey]) : home();
}
// ── 「系统管理」分区:**仅 admin 注册**(2026-09-13 用户指出:普通用户不该看到这一栏)
// 依据:官方 settings 外壳按 `ctx.slots.getVersion("settings.section")` 失效重算导航
// (`dsh-client-ui-settings/lib/client.js:560`)⇒ **晚注册会被正确反映**。
// 所以先异步判角色:**非 admin 干脆不注册**(而非注册后显示一行「仅管理员」)。
fetch(portalHost() + "/api/auth/me", { credentials: "include" })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
var role = d && d.user && d.user.role;
if (role !== "admin") return;
ctx.slots.inject("settings.section", function () {
return ctx.slots.register(
{
name: "settings.section",
id: "platform-admin",
order: 102,
label: function () { return t("pa.label"); }
},
PlatformAdminSection
);
});
})
// 判角色失败 = 按非 admin 处理,不注册(fail-closed:宁可少显示,不越权暴露)
.catch(function () {});
}
exports.apply = apply;
exports.inject = inject;
return module.exports;
}
});