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