Files
dsh_ai1net_server/poc/business-plugins/lib/client.js
T

2384 lines
124 KiB
JavaScript
Raw Normal View History

// @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`:
// 配额 = clamp(BASE_MEM_MB + Σ(PLUGIN_MEM_MB[bundles]), MIN_MEM_MB, MAX_MEM_MB)
// 下面的常量**逐键与它对齐**,并由 `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。
/** 与编排器 `BASE_MEM_MB` 一致。 */
var MEM_BASE_MIB = 160;
/** 与编排器 `MIN_MEM_MB` 一致 —— ⚠️ 这是**下限**,不是上限。 */
var MEM_MIN_MIB = 384;
/** 与编排器 `MAX_MEM_MB` 一致 —— 单实例硬顶。 */
var MEM_MAX_MIB = 1024;
/** 与编排器 `PLUGIN_MEM_MB` 一致;未识别的插件平台按 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;
}
/** 按平台同款规则把原始需求折算成实际配额(clamp 到 [MIN, MAX])。 */
function quotaOf(raw) {
return Math.min(Math.max(raw, MEM_MIN_MIB), 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": "功能管理",
"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.real": "实际配额",
"mem.heap": "V8 堆",
"mem.now": "当前",
"mem.planned": "勾选后",
"mem.limit": "硬顶",
"mem.left": "剩余",
"mem.over": "顶到硬顶",
"mem.tight": "接近硬顶",
"mem.safe": "余量充足",
"mem.overWarn": "勾选后的内存需求已顶到单实例硬顶(1024 MiB),配额不会再增加,实例可能起不来。建议先停用部分插件再应用。",
"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": "文件树 + DSH 启动",
"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": "全局 API 密钥(所有用户共用,仅管理员可改)",
"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.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 资产 —— 平台不执行任何第三方构建脚本。标记「需源码构建」的插件暂不支持一键导入,可自行构建后到「手动添加 / 管理」上传。",
"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": " | ⚠ 官方站暂不可达,正在使用本地缓存",
"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": "加载失败:"
};
/** English dictionary, key-set complete against zh. */
var en = {
"section.label": "Feature management",
"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.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": "The selected plugins need more than the per-instance hard cap (1024 MiB), so the quota will not grow further and 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": "Service management",
"pa.d.files": "File tree + launch DSH",
"pa.p.keys": "API keys",
"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 files and launch DSH from this folder",
"pa.s.keys": "Global API keys (shared by all users; admin-editable)",
"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.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\".",
"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.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: "
};
/**
* 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}",
"@media (prefers-reduced-motion: reduce){.pa-card,.pa-modal{animation:none;transition:none}.pa-card:hover{transform: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(); }, []);
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_MAX_MIB`)。
// ⚠️ 不能拿「配额」当基准:配额本身是 clamp 出来的(≥384),当基准会让所有实例都满格。
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 memFactText = (quotaInfo && quotaInfo.memMb != null)
? (t("mem.real") + " " + 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-13 用户要求)——
// 当前配额 / 勾选后配额 / 是否顶到硬顶,让用户在点「应用」之前就知道后果。
rows.push(jsxRuntime.jsx(MemBar, {
key: "__membar",
limit: MEM_MAX_MIB,
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 页同一决策)
jsxRuntime.jsx("span", {
key: "p1",
style: {
fontSize: "14px", fontWeight: 500, color: T.text, lineHeight: 1.45,
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap"
},
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
]
}));
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 > MEM_MAX_MIB;
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") + " " + MEM_MAX_MIB + " 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")
})
]
})
]
})
}));
}
return jsxRuntime.jsx("div", { style: wrap, children: rows });
}
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">' +
'<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 = [];
function pathString() { return cur.join("/"); }
function load() {
var p = pathString();
return paReq("/api/desktop/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("/api/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("/api/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("/api/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("/api/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("/api/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();
});
});
};
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>" +
'<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>" +
'<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" };
};
/** 页签切换(门户另写 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(6, t("pa.pl.wlLoading"));
var q = encodeURIComponent($("wlQ").value.trim());
var cat = encodeURIComponent($("wlCat").value);
var only = $("wlOnlyImportable").checked ? "1" : "";
return paReq("/api/plugins/whitelist?q=" + q + "&category=" + cat + "&onlyImportable=" + only)
.then(function (r) { return r.json().catch(function () { return {}; }).then(function (d) {
if (!r.ok) { tbody.innerHTML = paemptyRow(6, 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;
$("wlInfo").textContent = pafmt(t("pa.pl.wlInfo"), {
total: d.total, importable: d.importable, count: d.count, shown: shown, when: parelTime(d.fetchedAt)
}) + (d.stale ? t("pa.pl.wlStale") : "");
if (shown === 0) { tbody.innerHTML = paemptyRow(6, 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 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(); };
$("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;
}
});