Files
dsh_shenxian/poc/business-plugins/lib/client.js
T
admin 918f1d3a51 feat(models): 平台自建「模型设置」—— 用户自配厂家 / 条目各自开关 / 共享模型开关(档案 87)
- 官方「设置 → 模型」页在平台环境**必然报错**(判据在**浏览器页面**的 loopback 判定;官方 README 原文 Non-loopback pages get no durable settings)⇒ 该分区对全角色(含 admin)隐藏,用户自配改走平台自建页(插件 0.3.11)
- DB 迁移 V6:credential_vault.route/base_url/api/models + users.shared_model_enabled;并**重定义 getEnabledCredentialKeyRef**(互斥删除后原实现无 ORDER BY ⇒ 「任取一条」)
- 新落地层 src/web/model-landing.ts:spawn 时把「已启用条目」写进实例 .credentials.yaml 与 settings.yaml 的 llm-pi-ai.providers.<route>;字段名与官方包实测对齐(apiKeyEnv / baseURL / api,**不是** protocol);只碰自己写过的 + 一次性交接
- 接口 /api/me/keys、/api/me/keys/:id/toggle、/api/me/models/shared;前端新增「设置 → 模型设置」分区(settings.section id=model-settings / order 100 / 全角色)
- 顺手修两处:ensure-role-profile-patch.cjs 的 --force 整文件覆盖会抹掉 admin 的 disable-hmr 与 workspace-scoped-picker 两个平台块(改为 stripManagedBlock 只替换自己那段);verify-mem-model.mjs 因档案 86 重构而长期失败的陈旧断言

⚠️ 本提交同时包含**档案 86(admin 跨用户实例管理 + 两处改名)**的代码改动 —— 该部分已上线并端到端验证;其 import/register 与本次改动同处 src/web/server.ts、poc/business-plugins/lib/client.js 等文件,按**文件粒度无法拆分**,且不带它会让仓库 tsc 直接失败(缺 src/web/routes/admin-user-ops.ts)。
2026-09-14 00:00:15 +08:00

2717 lines
143 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @dsh-local/business-plugins — client half (dsh 0.1.2-rc.1, v0.2.4)
//
// dsh client bundles 以普通脚本形式执行,向 window.__ModuleLoader__ 注册 factory;
// 首次 import 时惰性物化 factory(require) -> exports。require 解析平台 seed 词
// (react/jsx-runtime)与图行(其他 dsh.client 包)。**严格镜像官方 client bundle
// 的 CJS-style factory 形态,只导出 apply + inject,绝不 exports.default**(R3:
// exports.default 会让 loader 的 ESM/CJS interop 把 bare apply FUNCTION 当作插件体,
// 无 inject 声明点 → ctx.slots 抛 cannot get property "slots" without inject)。
//
// v0.2.0(档案 37 · 批次 3):
// · 配色全部改用官方 design token `--dsw-*`(原来硬编码深色)→ **自动跟随 dsh 主题**
// (亮/暗/第三方主题都适配),不再需要自己实现主题切换。
// · 文案走官方 locale:ctx.locale.register(NS,{zh,en}) + ctx.locale.bind(NS) → 中英双语。
// · 交互:搜索过滤 + 每行状态徽章(已启用/未启用)+ **被自动禁用插件的行内提示与重试**
// + 空态/加载态。
// · 应用成功且实例重启后,**自动探活并刷新页面**(重启会换端口,刷新即可接上新实例)。
//
// v0.2.4(档案 67 · 对齐 06-工作台UI规范.md):
// · **信息层次反转**:主视觉改为插件的**用途说明**(候选池入库时已优先取官方目录的中文),
// 插件名 / 版本退为副行小字 —— 与门户 plugins 页同一决策(「一眼知道这插件干什么」)。
// · 字号对齐规范 §2.2 阶梯(section 语境取 14 / 13 / 12,不照搬页面级 16px 基准);
// 徽章改 `2px 8px` r10、按钮改 `.btn-sm` 量级、行距与内边距按规范 §2.4 序列。
// · 空态/加载态按规范 §4.9 改为「标题 + 说明」层次,不再是一行灰字。
// · 新增注入式 CSS 提供行 hover 反馈与按钮 hover(规范 §4.3 / §5);disposer 随 fiber 移除。
//
// v0.2.5(档案 67 补 · 按 `impeccable` 的 craft-floor 复核后修正;检测器未随技能包提供,人工过检):
// · **去掉 rejected 提示的彩色 `border-left`** —— craft-floor 明令:卡片/列表项/提示上
// 不得出现 >1px 的彩色 border-left/right。改为整块浅底 + 圆角,危险语义由标题文字色承担。
// · **对比度**:所有必读文字从 `--dsw-alias-label-tertiary` 提到 `secondary`
// (tertiary 在亮色下约 3.5:1,低于 craft-floor 的 4.5:1 底线)。
// · **层次**:主视觉加 `fontWeight: 500`,与 12px 副行拉开 weight 台阶(原先只差 2px 字号,
// 属于 craft-floor 点名的「scale and weight steps 不明显」)。
// · **动效**:transition 由 `.15s ease` 改为 `.18s cubic-bezier(.16,1,.3,1)`(exponential ease-out)。
//
// v0.2.7(档案 75 维度 B 落地 · 2026-09-13 用户要求):
// · **卡片加内存预估**:每张卡片多一行「预估内存 ≈ N MiB」小徽章,卡片加高(minHeight 112)
// 让「用途说明 / 包名 / 预估内存 / 状态徽章」四层排得开。
// · **列表上方新增「内存预估」状态条**:实心段 = 当前已启用插件的预估占用,
// 半透明段 = 本次勾选带来的增量;超过单实例上限(384 MiB)时整条转红并提示。
// · **口径**:`MEM_BASE_MIB`(-dsh 本体+官方插件基线) + `MEM_TABLE`(逐插件实测加载成本);
// **是预估值,不是实时读数**(客户端拿不到 cgroup 实测值,需平台加只读路由才能做实时 —— 见档案 75)。
//
// 功能:在 dsh 设置面板注册「功能管理」section —— 列出候选池 + 每个插件启用状态,
// 批量勾选后点「应用更改」,确认弹窗(提示重启会中断会话)→ POST 门户
// /api/plugins/mine/apply(跨子域 fetch,credentials include;门户已加 CORS)。
// 门户端启用=复制 bundle 到该用户 profile 并加入 bundles,禁用=从 bundles 移除,
// 然后重启该用户实例生效;**启用会让实例起不来的插件会被门户单独摘掉并标记**。
window.__ModuleLoader__.load({
id: "@dsh-local/business-plugins",
factory: (require) => {
var module = { exports: {} };
var exports = module.exports;
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
var jsxRuntime = require("react/jsx-runtime");
var React = require("react");
// 门户主域:去掉实例子域 label(admin.alotbuy.com -> alotbuy.com)。
function portalHost() {
try {
var labels = window.location.hostname.split(".");
if (labels.length > 2) {
return window.location.protocol + "//" + labels.slice(1).join(".");
}
} catch (e) {}
return window.location.origin;
}
// 官方 design token(dsh 主题变量)。刻意**不硬编码颜色**:dsw-* 由主题提供,
// 亮/暗/第三方主题自动适配;fallback 仅在该 token 缺失时兜底。
// 注:06-工作台UI规范 §2.1 给的是亮色基线色值,本 section 嵌在 dsh 面板内,
// 跟随宿主主题优先,故取 token + 规范色值作 fallback。
var T = {
text: "var(--dsw-alias-label-primary, #24292f)",
sub: "var(--dsw-alias-label-secondary, #4e5969)",
dim: "var(--dsw-alias-label-tertiary, #6b7280)",
border: "var(--dsw-alias-border-l2, #e3e6ea)",
field: "var(--dsw-alias-bg-layer-3, #f5f6f8)",
primary: "var(--dsw-alias-brand-primary, #2f6fed)",
danger: "var(--dsw-alias-state-error-primary, #d93026)",
warn: "var(--dsw-alias-state-warning-primary, #bf8700)",
success: "var(--dsw-alias-state-success-primary, #1a7f37)"
};
// ── 内存模型(档案 84;口径**与平台编排器同源**)──────────────────────────
//
// 权威定义在 `src/supervisor/orchestrator.ts`:
// 配额 = 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": "用户实例与工作区",
"pa.p.keys": "模型管理",
"pa.d.keys": "全局 API 密钥",
"pa.p.users": "用户管理",
"pa.d.users": "审批 / 禁用 / 删除",
"pa.p.skills": "技能管理",
"pa.d.skills": "共享技能上传",
"pa.p.plugins": "插件管理",
"pa.d.plugins": "功能插件投放",
"pa.p.runtime": "运行环境",
"pa.d.runtime": "共享运行时与工具",
"pa.s.files": "按用户查看工作区、启动 / 停止其 DSH 实例",
"pa.s.keys": "平台共享密钥(未自配密钥的用户默认使用;仅管理员可改)",
"pa.s.users": "审批 / 禁用 / 删除用户",
"pa.s.skills": "共享技能(所有用户可用 · 只读)",
"pa.s.plugins": "功能插件(系统外插件)的投放与管理",
"pa.s.runtime": "实例共享的运行时与工具(服务器装一次,全部用户共享)",
"pa.k.name": "名称",
"pa.k.type": "类型",
"pa.k.size": "大小",
"pa.k.mtime": "修改时间",
"pa.k.op": "操作",
"pa.k.user": "用户名",
"pa.k.role": "角色",
"pa.k.reg": "注册时间",
"pa.k.skill": "技能名",
"pa.k.desc": "说明",
"pa.k.files": "文件",
"pa.k.updated": "更新时间",
"pa.k.plugin": "插件名",
"pa.k.ver": "版本",
"pa.k.deployed": "投放时间",
"pa.k.src": "来源",
"pa.k.script": "安装/升级脚本",
"pa.k.removable": "可卸载",
"pa.k.item": "项目",
"pa.k.value": "值",
"pa.k.used": "已用",
"pa.k.pluginDesc": "插件说明",
"pa.k.cat": "分类",
"pa.k.importKind": "导入方式",
"pa.k.downloads": "下载量",
"pa.k.detail": "详情",
"pa.st.running": "运行中",
"pa.st.stopped": "未运行",
"pa.st.dir": "文件夹",
"pa.st.file": "文件",
"pa.st.enabled": "启用中",
"pa.st.removable": "可卸载",
"pa.st.required": "平台必备",
"pa.role.admin": "管理员",
"pa.role.active": "正常",
"pa.role.pending": "待审核",
"pa.role.disabled": "已禁用",
"pa.op.approve": "通过",
"pa.op.disable": "禁用",
"pa.op.enable": "恢复",
"pa.op.del": "删除",
"pa.op.add": "添加",
"pa.op.select": "启用",
"pa.op.upload": "上传 / 替换",
"pa.op.newFolder": "新建文件夹",
"pa.op.uploadFile": "上传文件",
"pa.op.launch": "启动 DSH",
"pa.op.stop": "停止",
"pa.op.openDsh": "打开 DSH ↗",
"pa.op.search": "搜索",
"pa.op.refreshDir": "重新拉取目录",
"pa.op.import": "导入到平台",
"pa.op.detail": "详情 ↗",
"pa.op.repull": "正在重新拉取…",
"pa.f.launchDir": "启动目录:",
"pa.f.targetUser": "用户:",
"pa.f.selfOnly": "仅管理员可切换用户",
"pa.f.root": "根目录",
"pa.f.rootShort": "根",
"pa.f.newFolderPrompt": "文件夹名称:",
"pa.f.alreadyRunning": "已有运行中的 DSH",
"pa.f.launchFail": "启动失败",
"pa.f.launched": "DSH 已启动",
"pa.f.uploadFail": "上传失败:",
"pa.f.createFail": "创建失败:",
"pa.key.empty": "暂无全局密钥。添加后所有用户的 DSH 才能调用模型。",
"pa.key.namePh": "名称(如 家用 / 服务器)",
"pa.key.invalid": "名称或密钥格式无效",
"pa.key.needBoth": "请填名称和密钥",
"pa.key.confirmDel": "删除这个密钥?",
"pa.u.confirmDel": "删除后不可恢复。输入用户名「{name}」以确认:",
"pa.u.mismatch": "用户名不匹配,已取消",
"pa.u.delFail": "删除失败(admin 不可删除)",
"pa.u.noUsers": "暂无用户",
"pa.sk.hint": "仅支持 .zip:包内单个顶层目录且含 SKILL.md(name 小写连字符 + description)。上传先做安全检测;同名确认后整体替换。",
"pa.sk.empty": "暂无共享技能 —— 上传 .zip 投放第一个",
"pa.sk.loadFail": "加载失败",
"pa.sk.pickZip": "请先选择 .zip 文件",
"pa.sk.zipOnly": "仅支持 .zip 文件",
"pa.sk.tooBig": "文件过大(>150MB)",
"pa.sk.detecting": "检测中…",
"pa.sk.uploadFail": "上传失败:",
"pa.sk.installed": "✓ 已安装技能「{name}」",
"pa.sk.replaceConfirm": "检测到同名技能「{name}」已存在。确认整体替换?\n\n⚠️ 替换将删除旧技能全部文件,不可恢复。",
"pa.sk.canceled": "已取消",
"pa.sk.replaceFail": "替换失败:",
"pa.sk.replaced": "✓ 已整体替换技能「{name}」",
"pa.sk.delConfirm": "删除共享技能「{name}」?所有用户将立即不可用。",
"pa.sk.deleted": "✓ 已删除",
"pa.pl.tabOfficial": "官方推荐插件",
"pa.pl.tabManual": "手动添加 / 管理",
"pa.pl.searchPh": "搜索插件名 / 描述 / npm 包名",
"pa.pl.allCat": "全部分类",
"pa.pl.onlyImportable": "只看可导入",
"pa.pl.hintOfficial": "来源:awesome-dsh-plugin 官方插件目录(dsh 官方社区精选,按下载量排序,取前 300 条)。导入只接受预构建包:官方 npm 包(registry tarball)或作者发布的 release 资产 —— 平台不执行任何第三方构建脚本。标记「需源码构建」的插件暂不支持一键导入,可自行构建后到「手动添加 / 管理」上传。",
"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": "加载失败:",
// ── 档案 87「模型设置」分区 ────────────────────────────────────────────
// 为什么平台自己做这一页:官方「设置 → 模型」页在平台环境**必然报错**(它要
// Host settings 镜像,而平台是浏览器经域名访问远程服务器 ⇒ `isLoopback=false`
// ⇒ persistence 降级 memory ⇒ 页面报「加载提供方目录失败」)。所以官方那个分区
// 被 `ensure-role-profile-patch.cjs` 对**所有角色(含 admin)**隐藏。
// 三条口径(用户 2026-09-13 定,勿再当选择题):① 条目各自开关、可同时启用
// ② admin 配的共享模型**也列在这里**、用户可开关 ③ 用哪个模型在 dsh 对话框选。
"ms.title": "模型设置",
"ms.sub": "管理你自用的模型厂家:每条可单独开关、可同时启用;具体用哪个模型,在对话界面的模型选择器里选。保存后需重启实例生效。",
"ms.loadFailed": "加载失败,请刷新重试",
"ms.saveFailed": "保存失败,请重试",
"ms.saved": "已保存,重启实例后生效",
"ms.needNameKey": "请填写名称与 API Key",
"ms.needModels": "自定义厂家至少要填一个模型",
"ms.shared": "平台共享模型",
"ms.sharedOwner": "由 {who} 配置",
"ms.sharedHint": "关掉后,你的实例不再接收平台共享的模型条目(你自己配的仍照常生效)。",
"ms.available": "可用",
"ms.unavailable": "平台未配置",
"ms.sharedOn": "停用共享模型",
"ms.sharedOff": "启用共享模型",
"ms.add": "新增模型条目",
"ms.ph.name": "名称(如 我的中转网关)",
"ms.ph.key": "API Key",
"ms.ph.url": "Endpoint(留空 = 内置 DeepSeek)",
"ms.ph.route": "厂家标识(可选,英文小写)",
"ms.ph.models": "模型 id,一行一个",
"ms.formHint": "Endpoint 留空即使用平台内置 DeepSeek;一旦填写,就必须给至少一个模型 id。厂家标识会写进实例配置,同一个标识不能重复。",
"ms.save": "保存",
"ms.list": "我的模型条目",
"ms.col.name": "名称",
"ms.col.route": "厂家标识",
"ms.col.url": "Endpoint",
"ms.col.api": "协议",
"ms.col.models": "模型数",
"ms.col.state": "状态",
"ms.col.act": "操作",
"ms.empty": "还没有条目 —— 在上面添加一个即可",
"ms.builtin": "内置 DeepSeek",
"ms.builtinHint": "官方内置,无需 endpoint",
"ms.on": "已启用",
"ms.off": "已停用",
"ms.enable": "启用",
"ms.disable": "停用",
"ms.del": "删除",
"ms.e.name": "名称不合法",
"ms.e.key": "API Key 不合法(只允许字母数字与 - _ .)",
"ms.e.url": "Endpoint 必须以 http(s):// 开头",
"ms.e.route": "厂家标识不合法(小写字母/数字/短横线,字母开头)",
"ms.e.api": "不支持该协议",
"ms.e.models": "请至少填一个模型 id",
"ms.e.taken": "该厂家标识已被占用,换一个"
};
/** 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": "Instance management",
"pa.d.files": "User instances and workspaces",
"pa.p.keys": "Model management",
"pa.d.keys": "Global API keys",
"pa.p.users": "User management",
"pa.d.users": "Approve / disable / delete",
"pa.p.skills": "Skill management",
"pa.d.skills": "Shared skill upload",
"pa.p.plugins": "Plugin management",
"pa.d.plugins": "Feature plugin publishing",
"pa.p.runtime": "Runtime",
"pa.d.runtime": "Shared runtimes and tools",
"pa.s.files": "Browse a user's workspace and start/stop their DSH instance",
"pa.s.keys": "Platform-shared key (used by users who have not set their own; admin-only)",
"pa.s.users": "Approve / disable / delete users",
"pa.s.skills": "Shared skills (available to all users · read-only)",
"pa.s.plugins": "Publishing and managing feature (out-of-tree) plugins",
"pa.s.runtime": "Runtimes and tools shared by every instance (installed once, shared by all users)",
"pa.k.name": "Name",
"pa.k.type": "Type",
"pa.k.size": "Size",
"pa.k.mtime": "Modified",
"pa.k.op": "Actions",
"pa.k.user": "Username",
"pa.k.role": "Role",
"pa.k.reg": "Registered",
"pa.k.skill": "Skill",
"pa.k.desc": "Description",
"pa.k.files": "Files",
"pa.k.updated": "Updated",
"pa.k.plugin": "Plugin",
"pa.k.ver": "Version",
"pa.k.deployed": "Published",
"pa.k.src": "Source",
"pa.k.script": "Install/upgrade script",
"pa.k.removable": "Removable",
"pa.k.item": "Item",
"pa.k.value": "Value",
"pa.k.used": "Used",
"pa.k.pluginDesc": "Plugin",
"pa.k.cat": "Category",
"pa.k.importKind": "Import",
"pa.k.downloads": "Downloads",
"pa.k.detail": "Details",
"pa.st.running": "Running",
"pa.st.stopped": "Stopped",
"pa.st.dir": "Folder",
"pa.st.file": "File",
"pa.st.enabled": "Enabled",
"pa.st.removable": "Removable",
"pa.st.required": "Platform-required",
"pa.role.admin": "Admin",
"pa.role.active": "Active",
"pa.role.pending": "Pending",
"pa.role.disabled": "Disabled",
"pa.op.approve": "Approve",
"pa.op.disable": "Disable",
"pa.op.enable": "Restore",
"pa.op.del": "Delete",
"pa.op.add": "Add",
"pa.op.select": "Enable",
"pa.op.upload": "Upload / replace",
"pa.op.newFolder": "New folder",
"pa.op.uploadFile": "Upload file",
"pa.op.launch": "Launch DSH",
"pa.op.stop": "Stop",
"pa.op.openDsh": "Open DSH ↗",
"pa.op.search": "Search",
"pa.op.refreshDir": "Re-fetch catalog",
"pa.op.import": "Import to platform",
"pa.op.detail": "Details ↗",
"pa.op.repull": "Re-fetching…",
"pa.f.launchDir": "Launch folder:",
"pa.f.targetUser": "User:",
"pa.f.selfOnly": "Only an admin can switch users",
"pa.f.root": "root",
"pa.f.rootShort": "root",
"pa.f.newFolderPrompt": "Folder name:",
"pa.f.alreadyRunning": "A DSH instance is already running",
"pa.f.launchFail": "Launch failed",
"pa.f.launched": "DSH launched",
"pa.f.uploadFail": "Upload failed: ",
"pa.f.createFail": "Create failed: ",
"pa.key.empty": "No global key yet. Add one so every user's DSH can call the model.",
"pa.key.namePh": "Name (e.g. home / server)",
"pa.key.invalid": "Invalid name or key format",
"pa.key.needBoth": "Enter both name and key",
"pa.key.confirmDel": "Delete this key?",
"pa.u.confirmDel": "Deletion cannot be undone. Type the username \"{name}\" to confirm:",
"pa.u.mismatch": "Username mismatch — cancelled",
"pa.u.delFail": "Delete failed (admin cannot be deleted)",
"pa.u.noUsers": "No users",
"pa.sk.hint": ".zip only: a single top-level directory containing SKILL.md (lowercase-hyphenated name + description). Uploads are security-scanned first; a same-name skill is replaced as a whole after confirmation.",
"pa.sk.empty": "No shared skill yet — upload a .zip to publish the first one",
"pa.sk.loadFail": "Failed to load",
"pa.sk.pickZip": "Choose a .zip file first",
"pa.sk.zipOnly": ".zip files only",
"pa.sk.tooBig": "File too large (>150MB)",
"pa.sk.detecting": "Scanning…",
"pa.sk.uploadFail": "Upload failed: ",
"pa.sk.installed": "✓ Installed skill \"{name}\"",
"pa.sk.replaceConfirm": "A skill named \"{name}\" already exists. Replace it entirely?\n\n⚠️ Replacement deletes all of the old skill's files. This cannot be undone.",
"pa.sk.canceled": "Cancelled",
"pa.sk.replaceFail": "Replace failed: ",
"pa.sk.replaced": "✓ Replaced skill \"{name}\"",
"pa.sk.delConfirm": "Delete shared skill \"{name}\"? It becomes unavailable to all users immediately.",
"pa.sk.deleted": "✓ Deleted",
"pa.pl.tabOfficial": "Recommended plugins",
"pa.pl.tabManual": "Manual upload / manage",
"pa.pl.searchPh": "Search name / description / npm package",
"pa.pl.allCat": "All categories",
"pa.pl.onlyImportable": "Importable only",
"pa.pl.hintOfficial": "Source: the awesome-dsh-plugin official catalog (community picks, sorted by downloads, top 300). Import accepts prebuilt packages only — official npm tarballs or author release assets; the platform runs no third-party build scripts. Entries marked \"needs source build\" cannot be imported directly; build them yourself and upload under \"Manual upload / manage\".",
"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: ",
"ms.title": "Model settings",
"ms.sub": "Manage your own model providers: each entry has its own switch and several can be on at once. Pick which model to use in the chat model selector. Restart the instance for changes to take effect.",
"ms.loadFailed": "Failed to load — refresh and try again",
"ms.saveFailed": "Save failed — try again",
"ms.saved": "Saved — restart the instance to apply",
"ms.needNameKey": "Please fill in a name and an API key",
"ms.needModels": "A custom provider needs at least one model",
"ms.shared": "Platform-shared model",
"ms.sharedOwner": "Configured by {who}",
"ms.sharedHint": "When off, your instance stops receiving the platform-shared model entries (your own entries still apply).",
"ms.available": "Available",
"ms.unavailable": "Not configured",
"ms.sharedOn": "Disable shared model",
"ms.sharedOff": "Enable shared model",
"ms.add": "Add a model entry",
"ms.ph.name": "Name (e.g. My gateway)",
"ms.ph.key": "API key",
"ms.ph.url": "Endpoint (empty = built-in DeepSeek)",
"ms.ph.route": "Provider id (optional, lowercase)",
"ms.ph.models": "One model id per line",
"ms.formHint": "Leave Endpoint empty to use the platform's built-in DeepSeek; if you fill it in you must give at least one model id. The provider id is written into the instance config and must be unique.",
"ms.save": "Save",
"ms.list": "My model entries",
"ms.col.name": "Name",
"ms.col.route": "Provider id",
"ms.col.url": "Endpoint",
"ms.col.api": "Protocol",
"ms.col.models": "Models",
"ms.col.state": "State",
"ms.col.act": "Actions",
"ms.empty": "No entries yet — add one above",
"ms.builtin": "Built-in DeepSeek",
"ms.builtinHint": "Built in; no endpoint needed",
"ms.on": "Enabled",
"ms.off": "Disabled",
"ms.enable": "Enable",
"ms.disable": "Disable",
"ms.del": "Delete",
"ms.e.name": "Invalid name",
"ms.e.key": "Invalid API key (letters, digits, - _ . only)",
"ms.e.url": "Endpoint must start with http(s)://",
"ms.e.route": "Invalid provider id (lowercase letters, digits, dashes; must start with a letter)",
"ms.e.api": "Unsupported protocol",
"ms.e.models": "Add at least one model id",
"ms.e.taken": "That provider id is taken — pick another"
};
/**
* 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 });
}
// ═══════════════════════════════════════════════════════════════════════════
// 「模型设置」分区(档案 87 · 2026-09-13 第三轮口径)
//
// 为什么平台自己做这一页:官方「设置 → 模型」页在平台环境**必然报错** ——
// 它要求 Host settings 镜像,而平台是"浏览器经域名访问远程服务器"⇒
// `isLoopback = transport.ownsHost || pageLocation === undefined ||
// isLoopbackHostname(page)` 三条皆不成立 ⇒ persistence 降级为 `memory`
// ⇒ 页面必报「加载提供方目录失败: settings are unavailable in this browser」。
// 所以官方那个分区被 `ensure-role-profile-patch.cjs` 对**所有角色(含 admin)**
// 隐藏(见其 `DISABLE_MODELS_BLOCK` / `ADMIN_MODELS_BLOCK`),用户自配改走本页。
//
// 用户定下的三条口径(**已定,不要再拿去当选择题**):
// ① 条目**各自开关、可同时启用**(互斥已删);
// ② admin 配的**平台共享模型也列在这里**,用户可开关;
// ③ 具体用哪个模型**在 dsh 对话框的模型选择器里选** —— 本页只负责把
// 「已启用」的都配好(平台在 spawn 时写 `$DSH_HOME/.credentials.yaml`
// 与 `settings.yaml` 的 `llm-pi-ai.providers.<route>`)。
// ⚠️ 改动**重启实例后生效**(落地发生在 spawn 时),页面文案已如实说明。
//
// 接口:平台 `src/web/routes/auth.ts` 的 `/api/me/keys`(GET/POST)、
// `/api/me/keys/:id/toggle`、`/api/me/models/shared`、`DELETE /api/me/keys/:id`
// —— 与门户既有 API 一样走 `paReq`(跨子域 + credentials)。
// ═══════════════════════════════════════════════════════════════════════════
/** 后端错误码 → 词典键(认不出的码退回通用失败文案,不把码裸露给用户)。 */
var MS_ERR = {
invalid_name: "ms.e.name", invalid_api_key: "ms.e.key", invalid_base_url: "ms.e.url",
invalid_route: "ms.e.route", invalid_api: "ms.e.api", models_required: "ms.e.models",
route_taken: "ms.e.taken"
};
function ModelSettingsSection() {
var useState = React.useState;
var useEffect = React.useEffect;
var dataState = useState(null);
var data = dataState[0];
var setData = dataState[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 fName = useState("");
var fKey = useState("");
var fUrl = useState("");
var fRoute = useState("");
var fApi = useState("");
var fModels = useState("");
function load() {
setLoading(true);
paReq("/api/me/keys")
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (d) {
setData(d);
// 协议默认取官方表的第一项(`dsh-llm-pi-ai` 的 PROTOCOLS 顺序即默认优先级)。
if (!fApi[0]) fApi[1]((d.protocols || [])[0] || "");
}
setLoading(false);
})
.catch(function () { setLoading(false); setMsg(t("ms.loadFailed")); });
}
useEffect(function () { load(); }, []);
/** 统一收尾:跑一个写操作 → 刷新列表 / 或把后端错误码翻成文案。 */
function act(promise) {
if (busy) return;
setBusy(true);
setMsg("");
promise
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (res) {
setBusy(false);
if (!res.ok) {
var code = res.d && res.d.error;
setMsg(t(MS_ERR[code] || "ms.saveFailed"));
return;
}
if (res.d && res.d.saved) setMsg(t("ms.saved"));
load();
})
.catch(function () { setBusy(false); setMsg(t("ms.saveFailed")); });
}
function submit() {
var name = fName[0].trim();
var key = fKey[0].trim();
if (name === "" || key === "") { setMsg(t("ms.needNameKey")); return; }
var url = fUrl[0].trim();
var body = { name: name, apiKey: key };
if (url !== "") {
var ids = fModels[0].split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s !== ""; });
if (ids.length === 0) { setMsg(t("ms.needModels")); return; }
body.baseUrl = url;
body.models = ids;
if (fRoute[0].trim() !== "") body.route = fRoute[0].trim();
if (fApi[0]) body.api = fApi[0];
}
act(papostJson("/api/me/keys", body));
fName[1](""); fKey[1](""); fUrl[1](""); fRoute[1](""); fModels[1]("");
}
if (loading) {
return jsxRuntime.jsx("div", { className: "pa-page", children: jsxRuntime.jsx("div", { className: "pa-empty", children: t("loading") }) });
}
var keys = (data && data.keys) || [];
var shared = (data && data.shared) || {};
var sharedOn = !!(data && data.sharedModelEnabled);
var protocols = (data && data.protocols) || (fApi[0] ? [fApi[0]] : []);
var rows = keys.map(function (k) {
var custom = !!(k.baseUrl && k.baseUrl !== "");
var n = 0;
try { var arr = JSON.parse(k.models || "[]"); n = Array.isArray(arr) ? arr.length : 0; } catch (e) { n = 0; }
return jsxRuntime.jsxs("tr", { children: [
jsxRuntime.jsx("td", { children: k.name }),
jsxRuntime.jsx("td", { className: "pa-dim", children: custom ? (k.route || "—") : t("ms.builtin") }),
jsxRuntime.jsx("td", { className: "pa-dim", children: custom ? k.baseUrl : t("ms.builtinHint") }),
jsxRuntime.jsx("td", { className: "pa-dim", children: custom ? (k.api || "openai-completions") : "—" }),
jsxRuntime.jsx("td", { className: "pa-num", children: custom ? String(n) : "—" }),
jsxRuntime.jsx("td", { children: jsxRuntime.jsx("span", { className: "pa-badge " + (k.enabled ? "active" : "disabled"), children: k.enabled ? t("ms.on") : t("ms.off") }) }),
jsxRuntime.jsxs("td", { children: [
jsxRuntime.jsx("button", { key: "t", className: "pa-sm", disabled: busy, onClick: function () { act(papostJson("/api/me/keys/" + k.id + "/toggle", { enabled: !k.enabled })); }, children: k.enabled ? t("ms.disable") : t("ms.enable") }),
jsxRuntime.jsx("button", { key: "d", className: "pa-sm danger", style: { marginLeft: 6 }, disabled: busy, onClick: function () { act(paReq("/api/me/keys/" + k.id, { method: "DELETE" })); }, children: t("ms.del") })
] })
] }, k.id);
});
return jsxRuntime.jsxs("div", { className: "pa-page", children: [
jsxRuntime.jsx("h1", { className: "pa-hd", children: t("ms.title") }),
jsxRuntime.jsx("p", { className: "pa-sub", children: t("ms.sub") }),
// ── 平台共享模型(口径②:列入 + 可开关;开关只影响自己,不动 admin 的配置)──
jsxRuntime.jsxs("div", { className: "pa-box", style: { marginBottom: 16 }, children: [
jsxRuntime.jsxs("div", { className: "pa-card-h", children: [
jsxRuntime.jsx("span", { children: t("ms.shared") }),
shared.owner ? jsxRuntime.jsx("span", { className: "sub", children: t("ms.sharedOwner").replace("{who}", shared.owner) }) : null
] }),
jsxRuntime.jsx("p", { className: "pa-hint", children: t("ms.sharedHint") }),
jsxRuntime.jsxs("div", { className: "pa-row", style: { margin: 0 }, children: [
jsxRuntime.jsx("span", { className: "pa-badge " + (shared.available ? "active" : "disabled"), children: shared.available ? (shared.name || t("ms.available")) : t("ms.unavailable") }),
jsxRuntime.jsx("button", { className: "pa-btn" + (sharedOn ? "" : " pa-primary"), disabled: busy || !shared.available, onClick: function () { act(papostJson("/api/me/models/shared", { enabled: !sharedOn })); }, children: sharedOn ? t("ms.sharedOn") : t("ms.sharedOff") })
] })
] }),
// ── 新增条目(Endpoint 留空 = 内置 DeepSeek;填了就必须给模型清单)──
jsxRuntime.jsxs("div", { className: "pa-box", style: { marginBottom: 16 }, children: [
jsxRuntime.jsx("div", { className: "pa-card-h", children: jsxRuntime.jsx("span", { children: t("ms.add") }) }),
jsxRuntime.jsxs("div", { className: "pa-row", children: [
jsxRuntime.jsx("input", { className: "pa-input", placeholder: t("ms.ph.name"), value: fName[0], onChange: function (e) { fName[1](e.target.value); } }),
jsxRuntime.jsx("input", { className: "pa-input", type: "password", autoComplete: "off", placeholder: t("ms.ph.key"), value: fKey[0], onChange: function (e) { fKey[1](e.target.value); } })
] }),
jsxRuntime.jsxs("div", { className: "pa-row", children: [
jsxRuntime.jsx("input", { className: "pa-input", placeholder: t("ms.ph.url"), value: fUrl[0], onChange: function (e) { fUrl[1](e.target.value); } }),
jsxRuntime.jsx("input", { className: "pa-input", placeholder: t("ms.ph.route"), value: fRoute[0], onChange: function (e) { fRoute[1](e.target.value); } })
] }),
jsxRuntime.jsxs("div", { className: "pa-row", children: [
jsxRuntime.jsx("select", { className: "pa-input", value: fApi[0], onChange: function (e) { fApi[1](e.target.value); }, children: protocols.map(function (p) { return jsxRuntime.jsx("option", { value: p, children: p }, p); }) })
] }),
jsxRuntime.jsx("textarea", { className: "pa-input", rows: 3, style: { width: "100%", boxSizing: "border-box" }, placeholder: t("ms.ph.models"), value: fModels[0], onChange: function (e) { fModels[1](e.target.value); } }),
jsxRuntime.jsx("p", { className: "pa-hint", children: t("ms.formHint") }),
jsxRuntime.jsxs("div", { className: "pa-row", style: { marginBottom: 0 }, children: [
jsxRuntime.jsx("button", { className: "pa-btn pa-primary", disabled: busy, onClick: submit, children: t("ms.save") }),
msg ? jsxRuntime.jsx("span", { className: "pa-hint", style: { margin: 0 }, children: msg }) : null
] })
] }),
// ── 我的条目(口径①:每条独立开关,可同时启用)──
jsxRuntime.jsx("div", { className: "pa-sec", children: t("ms.list") }),
jsxRuntime.jsx("div", { className: "pa-wrap", children: jsxRuntime.jsxs("table", { className: "pa-tbl", children: [
jsxRuntime.jsx("thead", { children: jsxRuntime.jsxs("tr", { children: [
jsxRuntime.jsx("th", { children: t("ms.col.name") }),
jsxRuntime.jsx("th", { children: t("ms.col.route") }),
jsxRuntime.jsx("th", { children: t("ms.col.url") }),
jsxRuntime.jsx("th", { children: t("ms.col.api") }),
jsxRuntime.jsx("th", { children: t("ms.col.models") }),
jsxRuntime.jsx("th", { children: t("ms.col.state") }),
jsxRuntime.jsx("th", { children: t("ms.col.act") })
] }) }),
jsxRuntime.jsx("tbody", {
children: rows.length > 0
? rows
: jsxRuntime.jsx("tr", { children: jsxRuntime.jsx("td", { className: "pa-empty-cell", colSpan: 7, children: t("ms.empty") }) })
})
] }) })
] });
}
ctx.slots.inject("settings.section", function () {
return ctx.slots.register(
{
name: "settings.section",
id: "model-settings",
order: 100,
label: function () { return t("ms.title"); }
},
ModelSettingsSection
);
});
ctx.slots.inject("settings.section", function () {
return ctx.slots.register(
{
name: "settings.section",
id: "business-plugins",
order: 101,
label: function () { return t("section.label"); }
},
BusinessPluginsSection
);
});
// ═══════════════════════════════════════════════════════════════════════════
// 「系统管理」页面族 —— **逐函数移植门户 `web/portal.html` 的 <script>**(第 3 轮)
// 2026-09-13 用户要求:弹窗里点开的内容必须 = 门户点击功能后的**详细表格与功能**
// (此前是自造的 5 项只读摘要 —— 功能名与表格都与门户对不上)。
// 移植只做三处机械改写,**结构 / 表格列 / 文案逐字保持一致**:
// ① `document.getElementById(x)` → `$("x")`:`$` 是弹窗子树内的局部查询器,
// 避免与 dsh 面板里的同名 id 撞车(门户用全局 document,弹窗不能)。
// ② `fetch("/api/…")` → `paReq("/api/…")`:跨子域 + `credentials:"include"`,
// 与门户同源调用等价(实例子域已在门户 CORS 白名单内,含 GET/POST/DELETE)。
// ③ `location.hash` 路由 → 弹窗内的页签状态(门户靠 hash 深链,弹窗不需要)。
// ⚠️ 门户改了这些函数 ⇒ 本文件要按**同名函数**对照同步(唯一需人工对账的地方)。
// ═══════════════════════════════════════════════════════════════════════════
/** 占位符填充:`pafmt("成功 {ok} / {total}", { ok: 1, total: 2 })`。 */
function pafmt(text, a) {
return String(text).replace(/\{(\w+)\}/g, function (m, k) {
return a && Object.prototype.hasOwnProperty.call(a, k) ? String(a[k]) : m;
});
}
/** 门户 `esc()` 同名移植。 */
function paesc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
/** 门户 `fmtSize()` 同名移植。 */
function pafmtSize(n) {
if (!n) return "—";
if (n < 1024) return n + " B";
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
return (n / 1024 / 1024).toFixed(1) + " MB";
}
/** 门户 `when()` 同名移植。 */
function pawhen(ms) { return ms ? new Date(ms).toLocaleString() : "—"; }
/** 门户 `emptyRow()` 同名移植(视觉落在 `.pa-empty-cell`)。 */
function paemptyRow(cols, text) {
return '<tr><td colspan="' + cols + '" class="pa-empty-cell">' + paesc(text) + "</td></tr>";
}
/**
* 门户 `readAsBase64()` 同名移植,**但修掉门户侧的缺陷**:
* 门户原文 `return String(new Promise(…)).split(",")[1] ?? ""` 缺 `await` ⇒ 恒得
* `"[object Promise]"` ⇒ 返回空串 ⇒ 门户的「上传文件 / 上传技能 zip / 上传插件 tgz」
* 实际传的是**空数据**。弹窗侧改为返回真 Promise(门户侧待同修,见档案 82 §追加)。
*/
function pareadAsBase64(file) {
return new Promise(function (res, rej) {
var r = new FileReader();
r.onload = function () { res(String(r.result).split(",")[1] || ""); };
r.onerror = rej;
r.readAsDataURL(file);
});
}
/** 门户弱化色(`--bp-dim`)→ dsh token,供内联 style 复用。 */
var PA_DIM = "color:var(--dsw-alias-label-tertiary,#6b7280)";
/** 门户 `fetch` 的跨子域等价物。 */
function paReq(path, opt) {
return fetch(portalHost() + path, Object.assign({ credentials: "include" }, opt || {}));
}
/** 门户 `postJson()` 同名移植。 */
function papostJson(path, body) {
return fetch(portalHost() + path, {
method: "POST", credentials: "include",
headers: { "content-type": "application/json" }, body: JSON.stringify(body)
});
}
/** 门户 `relTime()` 同名移植(目录缓存时间展示用)。 */
function parelTime(ts) {
if (!ts || typeof ts !== "number") return "—";
var s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
if (s < 60) return "刚刚";
if (s < 3600) return Math.floor(s / 60) + " 分钟前";
if (s < 86400) return Math.floor(s / 3600) + " 小时前";
return Math.floor(s / 86400) + " 天前";
}
/** 6 个功能页的定义(门户 `PAGES` 的等价物;`html()` = `renderXxx`,`init()` = `initXxx`)。 */
var PA_PAGES = {};
/** 服务管理 ← 门户 `#/files`(`renderFiles` + `initFiles`)。 */
PA_PAGES.files = {
icon: "🖥️",
title: function () { return t("pa.p.files"); },
desc: function () { return t("pa.d.files"); },
sub: function () { return t("pa.s.files"); },
html: function () {
return '<div class="pa-dshbar">' +
// 档案 86:admin 可切换目标用户 —— 下方文件树 / 启停 / 打开 全部针对所选用户
'<span style="font-size:13px;' + PA_DIM + '">' + paesc(t("pa.f.targetUser")) + "</span>" +
'<select class="pa-input" id="svcUser" style="flex:none;min-width:130px"></select>' +
'<span class="pa-stat" id="dshState"><span class="pa-dot pa-off"></span>' + paesc(t("pa.st.stopped")) + "</span>" +
'<button class="pa-btn pa-primary" id="launchBtn">' + paesc(t("pa.op.launch")) + "</button>" +
'<button class="pa-btn" id="stopBtn" style="display:none">' + paesc(t("pa.op.stop")) + "</button>" +
'<button class="pa-btn" id="openDshBtn" style="display:none">' + paesc(t("pa.op.openDsh")) + "</button>" +
'<span style="flex:1"></span>' +
'<span style="font-size:13px;' + PA_DIM + '">' + paesc(t("pa.f.launchDir")) +
'<b id="dshFolder">' + paesc(t("pa.f.root")) + "</b></span>" +
"</div>" +
'<div class="pa-box"><div class="pa-pathbar">' +
'<button class="pa-sm" id="newFolderBtn">' + paesc(t("pa.op.newFolder")) + "</button>" +
'<button class="pa-sm" id="uploadBtn">' + paesc(t("pa.op.uploadFile")) + "</button>" +
'<span class="pa-crumbs" id="breadcrumb"></span>' +
"</div>" +
'<div class="pa-wrap"><table class="pa-tbl"><thead><tr>' +
"<th>" + paesc(t("pa.k.name")) + "</th>" +
"<th>" + paesc(t("pa.k.type")) + "</th>" +
'<th class="pa-num" style="text-align:right">' + paesc(t("pa.k.size")) + "</th>" +
"<th>" + paesc(t("pa.k.mtime")) + "</th>" +
'</tr></thead><tbody id="rows"></tbody></table></div></div>' +
'<input id="fileInput" type="file" style="display:none" />';
},
init: function ($, root) {
var cur = []; // 目录栈
var uid = ""; // 目标用户(档案 86:admin 可切到任意用户;不再是"自己")
/** admin 视角的基路径 —— 文件与实例操作全部打到这里(requireAdmin)。 */
function base() { return "/api/admin/users/" + encodeURIComponent(uid); }
function pathString() { return cur.join("/"); }
function load() {
var p = pathString();
return paReq(base() + "/fs/tree" + (p ? "?path=" + encodeURIComponent(p) : ""))
.then(function (r) { return r.json(); })
.then(function (body) {
var rows = $("rows");
rows.innerHTML = "";
if (p) rows.insertAdjacentHTML("beforeend", '<tr><td class="pa-dir" data-up="1">..</td><td></td><td></td><td></td></tr>');
var list = body.entries || [];
for (var i = 0; i < list.length; i++) {
var e = list[i];
var attr = e.type === "dir"
? ' class="pa-dir" data-path="' + encodeURIComponent(p ? p + "/" + e.name : e.name) + '"'
: "";
rows.insertAdjacentHTML("beforeend", "<tr><td" + attr + ">" + paesc(e.name) + "</td><td>" +
paesc(e.type === "dir" ? t("pa.st.dir") : t("pa.st.file")) + '</td><td class="pa-num">' +
pafmtSize(e.size) + "</td><td>" + new Date(e.mtimeMs).toLocaleString() + "</td></tr>");
}
// 面包屑
var parts = ['<span class="pa-crumb" data-path="">' + paesc(t("pa.f.rootShort")) + "</span>"];
var acc = [];
for (var j = 0; j < cur.length; j++) {
acc.push(cur[j]);
parts.push('<span class="pa-csep">/</span><span class="pa-crumb" data-path="' +
paesc(acc.join("/")) + '">' + paesc(cur[j]) + "</span>");
}
var bc = $("breadcrumb");
bc.innerHTML = parts.join("");
bc.querySelectorAll(".pa-crumb").forEach(function (c) {
c.addEventListener("click", function () {
cur = c.dataset.path ? c.dataset.path.split("/").filter(Boolean) : [];
load();
});
});
rows.querySelectorAll(".pa-dir[data-path]").forEach(function (el) {
el.addEventListener("click", function () {
cur = decodeURIComponent(el.dataset.path).split("/").filter(Boolean);
load();
});
});
var up = rows.querySelector(".pa-dir[data-up]");
if (up) up.addEventListener("click", function () { cur = cur.slice(0, -1); load(); });
});
}
function refreshDsh() {
return paReq(base() + "/dsh/status").then(function (r) { return r.ok ? r.json() : null; }).then(function (body) {
if (!body) return;
var running = body.running;
$("dshState").innerHTML = '<span class="pa-dot ' + (running ? "pa-on" : "pa-off") + '"></span>' +
paesc(running ? t("pa.st.running") : t("pa.st.stopped"));
$("stopBtn").style.display = running ? "" : "none";
$("openDshBtn").style.display = running ? "" : "none";
if (running) $("openDshBtn").onclick = function () { window.open(body.url, "_blank"); };
});
}
function doCreate(name, type) {
return papostJson(base() + "/fs/create", { path: pathString(), name: name, type: type }).then(function (res) {
if (!res.ok) {
return res.json().catch(function () { return {}; }).then(function (e) {
root.toast(t("pa.f.createFail") + (e.error || res.status));
});
}
return load();
});
}
$("launchBtn").onclick = function () {
return papostJson(base() + "/dsh/launch", { folder: pathString() }).then(function (res) {
if (!res.ok) {
return res.json().catch(function () { return {}; }).then(function (b) {
root.toast(b.error === "already_running" ? t("pa.f.alreadyRunning") : t("pa.f.launchFail"));
});
}
root.toast(t("pa.f.launched"));
return refreshDsh();
});
};
$("stopBtn").onclick = function () {
return paReq(base() + "/dsh/stop", { method: "POST" }).then(function () { return refreshDsh(); });
};
$("newFolderBtn").onclick = function () {
var name = window.prompt(t("pa.f.newFolderPrompt"));
if (name) doCreate(name, "dir");
};
$("uploadBtn").onclick = function () { $("fileInput").click(); };
$("fileInput").onchange = function (ev) {
var file = ev.target.files[0];
if (!file) return;
var name = file.name;
return pareadAsBase64(file).then(function (data) {
return papostJson(base() + "/fs/upload", { path: pathString(), name: name, data: data }).then(function (res) {
if (!res.ok) {
return res.json().catch(function () { return {}; }).then(function (e) {
root.toast(t("pa.f.uploadFail") + (e.error || res.status));
});
}
ev.target.value = "";
return load();
});
});
};
/** 填「用户」下拉(`/api/admin/users`,requireAdmin);默认选自己。 */
function loadUsers() {
return paReq("/api/admin/users")
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
var list = (d && d.users) || [];
var sel = $("svcUser");
if (!sel) return;
sel.innerHTML = list.map(function (u) {
return '<option value="' + paesc(u.id) + '">' + paesc(u.username) +
(u.role === "admin" ? "(" + paesc(t("pa.role.admin")) + ")" : "") + "</option>";
}).join("");
var mine = list.filter(function (u) { return u.role === "admin"; })[0] || list[0];
uid = mine ? mine.id : "";
sel.value = uid;
sel.onchange = function () { uid = sel.value; cur = []; load(); refreshDsh(); };
});
}
return loadUsers().then(function () { return Promise.all([load(), refreshDsh()]); });
}
};
/** 模型管理 ← 门户 `#/keys`(`renderKeys` + `initKeys`)。 */
PA_PAGES.keys = {
icon: "🔑",
title: function () { return t("pa.p.keys"); },
desc: function () { return t("pa.d.keys"); },
sub: function () { return t("pa.s.keys"); },
html: function () {
return '<div class="pa-box"><div id="keyList"></div>' +
'<div style="margin-top:14px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">' +
'<input id="keyNameInput" class="pa-input" placeholder="' + paesc(t("pa.key.namePh")) + '" />' +
'<input id="keyValueInput" class="pa-input" type="password" placeholder="sk-..." style="flex:1.4;min-width:160px" />' +
'<button class="pa-btn pa-primary" id="addKeyBtn">' + paesc(t("pa.op.add")) + "</button>" +
"</div></div>";
},
init: function ($, root) {
function load() {
return paReq("/api/me/keys").then(function (r) {
if (!r.ok) return;
return r.json().then(function (d) {
var keys = d.keys || [];
var list = $("keyList");
if (keys.length === 0) {
list.innerHTML = '<div class="pa-empty">' + paesc(t("pa.key.empty")) + "</div>";
return;
}
list.innerHTML = keys.map(function (k) {
return '<div class="pa-keyrow"><span class="pa-dot ' + (k.enabled ? "pa-on" : "pa-off") + '"></span>' +
"<strong>" + paesc(k.name) + '</strong><span style="flex:1"></span>' +
(k.enabled
? '<span style="font-size:12px;color:#1a7f37">' + paesc(t("pa.st.enabled")) + "</span>"
: '<button class="pa-sm" data-sel="' + paesc(k.id) + '">' + paesc(t("pa.op.select")) + "</button>") +
'<button class="pa-sm danger" data-del="' + paesc(k.id) + '">' + paesc(t("pa.op.del")) + "</button></div>";
}).join("");
list.querySelectorAll("[data-sel]").forEach(function (b) {
b.onclick = function () {
return paReq("/api/me/keys/" + encodeURIComponent(b.dataset.sel) + "/select", { method: "POST" }).then(load);
};
});
list.querySelectorAll("[data-del]").forEach(function (b) {
b.onclick = function () {
if (!window.confirm(t("pa.key.confirmDel"))) return;
return paReq("/api/me/keys/" + encodeURIComponent(b.dataset.del), { method: "DELETE" }).then(load);
};
});
});
});
}
$("addKeyBtn").onclick = function () {
var name = $("keyNameInput").value.trim();
var apiKey = $("keyValueInput").value.trim();
if (!name || !apiKey) { root.toast(t("pa.key.needBoth")); return; }
return papostJson("/api/me/keys", { name: name, apiKey: apiKey }).then(function (res) {
if (!res.ok) { root.toast(t("pa.key.invalid")); return; }
$("keyNameInput").value = "";
$("keyValueInput").value = "";
return load();
});
};
return load();
}
};
/** 用户管理 ← 门户 `#/users`(`renderUsers` + `initUsers`)。 */
PA_PAGES.users = {
icon: "👥",
title: function () { return t("pa.p.users"); },
desc: function () { return t("pa.d.users"); },
sub: function () { return t("pa.s.users"); },
html: function () {
return '<div class="pa-wrap"><table class="pa-tbl"><thead><tr>' +
"<th>" + paesc(t("pa.k.user")) + "</th><th>" + paesc(t("pa.k.role")) + "</th><th>" + paesc(t("pa.k.reg")) + "</th>" +
'<th class="pa-act">' + paesc(t("pa.k.op")) + "</th></tr></thead>" +
'<tbody id="usersTbody"></tbody></table></div>';
},
init: function ($, root) {
function load() {
return paReq("/api/admin/users").then(function (r) { return r.json(); }).then(function (d) {
var users = d.users || [];
var tbody = $("usersTbody");
var badge = {
admin: t("pa.role.admin"), active: t("pa.role.active"),
pending: t("pa.role.pending"), disabled: t("pa.role.disabled")
};
if (users.length === 0) { tbody.innerHTML = paemptyRow(4, t("pa.u.noUsers")); return; }
tbody.innerHTML = users.map(function (u) {
var actions = u.role === "pending"
? '<button class="pa-sm" data-act="approve" data-id="' + u.id + '">' + paesc(t("pa.op.approve")) + "</button>"
: u.role === "active"
? '<button class="pa-sm danger" data-act="disable" data-id="' + u.id + '">' + paesc(t("pa.op.disable")) + "</button>"
: u.role === "disabled"
? '<button class="pa-sm" data-act="enable" data-id="' + u.id + '">' + paesc(t("pa.op.enable")) + "</button>"
: "";
var del = u.role !== "admin"
? '<button class="pa-sm danger" data-act="del" data-id="' + u.id + '" data-name="' + paesc(u.username) + '">' + paesc(t("pa.op.del")) + "</button>"
: "";
return "<tr><td><strong>" + paesc(u.username) + '</strong></td><td><span class="pa-badge ' + paesc(u.role) + '">' +
paesc(badge[u.role] || u.role) + "</span></td><td>" + new Date(u.createdAt).toLocaleString() +
'</td><td class="pa-act">' + actions + (actions && del ? " " : "") + del + "</td></tr>";
}).join("");
// 事件委托挂在 tbody 上(门户写法;重渲染后无需重绑)
tbody.onclick = function (event) {
var btn = event.target.closest("button[data-act]");
if (!btn) return;
var act = btn.dataset.act;
var id = btn.dataset.id;
if (act === "del") {
var name = window.prompt(pafmt(t("pa.u.confirmDel"), { name: btn.dataset.name }));
if (name !== btn.dataset.name) { root.toast(t("pa.u.mismatch")); return; }
return paReq("/api/admin/users/" + encodeURIComponent(id), { method: "DELETE" }).then(function (res) {
if (res.ok) return load();
root.toast(t("pa.u.delFail"));
});
}
return paReq("/api/admin/users/" + encodeURIComponent(id) + "/" + act, { method: "POST" }).then(function (res) {
if (res.ok) return load();
root.toast(t("pa.failed"));
});
};
});
}
return load();
}
};
/** 技能管理 ← 门户 `#/skills`(`renderSkills` + `initSkills`)。 */
PA_PAGES.skills = {
icon: "🧩",
title: function () { return t("pa.p.skills"); },
desc: function () { return t("pa.d.skills"); },
sub: function () { return t("pa.s.skills"); },
html: function () {
return '<div class="pa-box">' +
'<div class="pa-row"><input type="file" id="skillFile" accept=".zip" />' +
'<button class="pa-btn pa-primary" id="skillUploadBtn">' + paesc(t("pa.op.upload")) + "</button></div>" +
'<p class="pa-hint">' + paesc(t("pa.sk.hint")) + "</p>" +
'<div class="pa-wrap"><table class="pa-tbl"><thead><tr>' +
"<th>" + paesc(t("pa.k.skill")) + "</th><th>" + paesc(t("pa.k.desc")) + "</th>" +
'<th class="pa-num" style="text-align:right">' + paesc(t("pa.k.files")) + "</th>" +
'<th class="pa-num" style="text-align:right">' + paesc(t("pa.k.size")) + "</th>" +
"<th>" + paesc(t("pa.k.updated")) + "</th>" +
'<th class="pa-act">' + paesc(t("pa.k.op")) + "</th>" +
'</tr></thead><tbody id="skillTbody"></tbody></table></div></div>';
},
init: function ($, root) {
function load() {
var tbody = $("skillTbody");
tbody.innerHTML = paemptyRow(6, t("pa.loading"));
return paReq("/api/skills/shared").then(function (r) { return r.json(); }).then(function (d) {
var skills = d.skills || [];
if (skills.length === 0) { tbody.innerHTML = paemptyRow(6, t("pa.sk.empty")); return; }
tbody.innerHTML = skills.map(function (s) {
return "<tr><td><strong>" + paesc(s.name) + '</strong></td><td class="pa-desc" title="' + paesc(s.description) + '">' +
(paesc(s.description) || "—") + '</td><td class="pa-num">' + (s.files == null ? "—" : s.files) +
'</td><td class="pa-num">' + pafmtSize(s.size) + "</td><td>" + pawhen(s.mtimeMs) +
'</td><td class="pa-act"><button class="pa-sm danger" data-del="' + paesc(s.name) + '">' + paesc(t("pa.op.del")) + "</button></td></tr>";
}).join("");
}).catch(function () { tbody.innerHTML = paemptyRow(6, t("pa.sk.loadFail")); });
}
$("skillUploadBtn").onclick = function () {
var input = $("skillFile");
var file = input.files[0];
if (!file) { root.toast(t("pa.sk.pickZip")); return; }
if (!file.name.toLowerCase().endsWith(".zip")) { root.toast(t("pa.sk.zipOnly")); return; }
if (file.size > 150 * 1024 * 1024) { root.toast(t("pa.sk.tooBig")); return; }
var btn = $("skillUploadBtn");
btn.disabled = true;
btn.textContent = t("pa.sk.detecting");
var fname = file.name;
return pareadAsBase64(file).then(function (base64) {
return papostJson("/api/skills/shared", { file: base64, filename: fname }).then(function (res) {
return res.json().catch(function () { return {}; }).then(function (data) {
if (!res.ok) { root.toast(t("pa.sk.uploadFail") + (data.error == null ? res.status : data.error)); return; }
if (!data.conflict) {
root.toast(pafmt(t("pa.sk.installed"), { name: data.skill.name }));
input.value = "";
return load();
}
if (!window.confirm(pafmt(t("pa.sk.replaceConfirm"), { name: data.skill.name }))) {
root.toast(t("pa.sk.canceled"));
return;
}
return papostJson("/api/skills/shared/apply", { stagedId: data.stagedId }).then(function (r2) {
return r2.json().catch(function () { return {}; }).then(function (d2) {
if (!r2.ok) { root.toast(t("pa.sk.replaceFail") + (d2.error == null ? r2.status : d2.error)); return; }
root.toast(pafmt(t("pa.sk.replaced"), { name: d2.skill.name }));
input.value = "";
return load();
});
});
});
});
}).catch(function () { root.toast(t("pa.sk.uploadFail")); })
.then(function () { btn.disabled = false; btn.textContent = t("pa.op.upload"); });
};
$("skillTbody").onclick = function (event) {
var btn = event.target.closest("button[data-del]");
if (!btn) return;
if (!window.confirm(pafmt(t("pa.sk.delConfirm"), { name: btn.dataset.del }))) return;
return paReq("/api/skills/shared/" + encodeURIComponent(btn.dataset.del), { method: "DELETE" }).then(function (res) {
if (res.ok) { root.toast(t("pa.sk.deleted")); return load(); }
root.toast(t("pa.sk.delFail"));
});
};
return load();
}
};
/** 插件管理 ← 门户 `#/plugins`(`renderPlugins` + `initPlugins`);两个页签同门户。 */
PA_PAGES.plugins = {
icon: "🔌",
title: function () { return t("pa.p.plugins"); },
desc: function () { return t("pa.d.plugins"); },
sub: function () { return t("pa.s.plugins"); },
html: function () {
return '<div class="pa-tabs" id="pluginTabs">' +
'<div class="pa-tab pa-on" data-tab="official">' + paesc(t("pa.pl.tabOfficial")) + "</div>" +
'<div class="pa-tab" data-tab="manual">' + paesc(t("pa.pl.tabManual")) + '<span class="pa-tcnt" id="poolCnt">0</span></div>' +
"</div>" +
'<div id="pane-official"><div class="pa-box">' +
'<div class="pa-row">' +
'<input id="wlQ" class="pa-input" placeholder="' + paesc(t("pa.pl.searchPh")) + '" />' +
'<select id="wlCat" class="pa-input" style="flex:none;min-width:130px"><option value="">' + paesc(t("pa.pl.allCat")) + "</option></select>" +
'<label class="pa-check"><input type="checkbox" id="wlOnlyImportable" checked />' + paesc(t("pa.pl.onlyImportable")) + "</label>" +
'<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;
}
});