2026-09-13 16:18:10 +08:00
// @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)"
};
2026-09-13 22:27:49 +08:00
// ── 内存模型(档案 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 计。 */
2026-09-13 16:18:10 +08:00
var MEM_TABLE = {
2026-09-13 22:27:49 +08:00
"dsh-univer-office" : 512 ,
"dsh-plugin-mcn-suite" : 128
2026-09-13 16:18:10 +08:00
};
2026-09-13 22:27:49 +08:00
/** 与编排器 `HEAP_HEADROOM_MB` 一致。 */
var MEM_HEAP_HEADROOM_MIB = 96 ;
/** 越过该比例即提前警示(相对**硬顶**),给页缓存与用户自己的任务留余量。 */
2026-09-13 16:18:10 +08:00
var MEM_TIGHT_RATIO = 0.85 ;
2026-09-13 22:27:49 +08:00
/** 单个插件的加载成本(MiB);未识别 = 0(与平台同口径:宁可少给也不虚高)。 */
2026-09-13 16:18:10 +08:00
function memMiB ( p ) {
if ( p && p . name && Object . prototype . hasOwnProperty . call ( MEM_TABLE , p . name )) {
return MEM_TABLE [ p . name ];
}
2026-09-13 22:27:49 +08:00
return 0 ;
2026-09-13 16:18:10 +08:00
}
2026-09-13 22:27:49 +08:00
/** 未 clamp 的原始需求(MiB) = 基线 + Σ命中 `key` 为真的插件。 */
function rawMiB ( list , key ) {
var total = MEM_BASE_MIB ;
2026-09-13 16:18:10 +08:00
for ( var i = 0 ; i < list . length ; i ++ ) {
if ( list [ i ][ key ]) total += memMiB ( list [ i ]);
}
return total ;
}
2026-09-13 22:27:49 +08:00
/** 按平台同款规则把原始需求折算成实际配额(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 ));
}
2026-09-13 16:18:10 +08:00
/** 本插件的 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" : "确认应用" ,
2026-09-13 22:27:49 +08:00
"confirm.overTitle" : "⚠ 内存需求已顶到单实例硬顶" ,
2026-09-13 16:18:10 +08:00
"nochange" : "没有需要更改的插件" ,
"queued" : "排队中…" ,
"installing" : "正在安装" ,
"configuring" : "正在配置" ,
"restarting" : "正在重启实例" ,
"probing" : "正在检查实例" ,
"isolating" : "正在定位问题插件" ,
"applied" : "✓ 已应用" ,
"reloading" : "实例重启中,页面将自动刷新…" ,
"rejected.title" : "以下插件与实例不兼容,已自动禁用" ,
"retry" : "重试" ,
"requestFailed" : "请求失败" ,
"loadFailed" : "加载失败" ,
2026-09-13 22:27:49 +08:00
"mem.title" : "内存配额" ,
"mem.est" : "估算值(未读到平台配额,按平台同款算式推导)" ,
"mem.real" : "实际配额" ,
"mem.heap" : "V8 堆" ,
2026-09-13 16:18:10 +08:00
"mem.now" : "当前" ,
"mem.planned" : "勾选后" ,
2026-09-13 22:27:49 +08:00
"mem.limit" : "硬顶" ,
2026-09-13 16:18:10 +08:00
"mem.left" : "剩余" ,
2026-09-13 22:27:49 +08:00
"mem.over" : "顶到硬顶" ,
"mem.tight" : "接近硬顶" ,
2026-09-13 16:18:10 +08:00
"mem.safe" : "余量充足" ,
2026-09-13 22:27:49 +08:00
"mem.overWarn" : "勾选后的内存需求已顶到单实例硬顶(1024 MiB),配额不会再增加,实例可能起不来。建议先停用部分插件再应用。" ,
2026-09-13 17:48:33 +08:00
"mem.perCard" : "预估内存" ,
2026-09-13 22:27:49 +08:00
// ── 「系统管理」分区(第 3 轮 · 2026-09-13)──────────────────────────────
// 用户要求:弹窗里的**功能名称 / 表格 / 功能**必须与门户 `web/portal.html` 一致。
// ⇒ 分区内不再是自造的 5 项摘要,而是门户的 **6 个功能页**,成对命名:
// `pa.p.*` = 卡片名 / `pa.d.*` = 卡片说明 / `pa.s.*` = 页头副标题
// (三者的中文逐字取自门户 `renderHome()` 与 `pageHead()`)
// 命名规则:`pa.<域>.<项>`; `{x}` 为占位符,由 `pafmt()` 填充。
2026-09-13 19:46:23 +08:00
"pa.label" : "系统管理" ,
2026-09-13 22:27:49 +08:00
"pa.homeSub" : "与门户同一批功能;点开任一功能,在弹窗内直接管理。" ,
"pa.group.svc" : "服务" ,
"pa.group.mgmt" : "管理" ,
"pa.back" : "← 返回" ,
"pa.close" : "关闭" ,
"pa.retry" : "重试" ,
2026-09-13 17:48:33 +08:00
"pa.loading" : "加载中…" ,
"pa.loadingDesc" : "正在读取平台状态…" ,
2026-09-13 22:27:49 +08:00
"pa.loadFailed" : "平台状态读取失败" ,
2026-09-13 19:46:23 +08:00
"pa.notAdminTitle" : "系统管理仅对管理员显示" ,
2026-09-13 17:48:33 +08:00
"pa.notAdminDesc" : "当前账号不是管理员,此处不展示平台信息。" ,
2026-09-13 22:27:49 +08:00
"pa.empty" : "暂无数据" ,
2026-09-13 19:46:23 +08:00
"pa.done" : "已更新" ,
"pa.failed" : "操作失败" ,
2026-09-13 20:45:36 +08:00
"pa.working" : "处理中…" ,
2026-09-13 22:27:49 +08:00
"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" : "加载失败:"
2026-09-13 16:18:10 +08:00
};
/** 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" ,
2026-09-13 22:27:49 +08:00
"mem.title" : "Memory quota" ,
"mem.est" : "estimate (platform quota unavailable; same formula as the orchestrator)" ,
"mem.real" : "actual quota" ,
"mem.heap" : "V8 heap" ,
2026-09-13 16:18:10 +08:00
"mem.now" : "now" ,
"mem.planned" : "after selection" ,
2026-09-13 22:27:49 +08:00
"mem.limit" : "hard cap" ,
2026-09-13 16:18:10 +08:00
"mem.left" : "left" ,
2026-09-13 22:27:49 +08:00
"mem.over" : "hit the hard cap" ,
"mem.tight" : "close to the hard cap" ,
2026-09-13 16:18:10 +08:00
"mem.safe" : "headroom is fine" ,
2026-09-13 22:27:49 +08:00
"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" ,
2026-09-13 19:46:23 +08:00
"pa.label" : "System management" ,
2026-09-13 22:27:49 +08:00
"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" ,
2026-09-13 17:48:33 +08:00
"pa.loading" : "Loading…" ,
"pa.loadingDesc" : "Reading platform status…" ,
2026-09-13 22:27:49 +08:00
"pa.loadFailed" : "Failed to read platform status" ,
2026-09-13 19:46:23 +08:00
"pa.notAdminTitle" : "System management is admin-only" ,
2026-09-13 17:48:33 +08:00
"pa.notAdminDesc" : "This account is not an admin, so platform info is hidden here." ,
2026-09-13 22:27:49 +08:00
"pa.empty" : "No data" ,
2026-09-13 19:46:23 +08:00
"pa.done" : "Updated" ,
"pa.failed" : "Action failed" ,
2026-09-13 20:45:36 +08:00
"pa.working" : "Working…" ,
2026-09-13 22:27:49 +08:00
"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: "
2026-09-13 16:18:10 +08:00
};
/**
* 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)}" ,
2026-09-13 20:15:33 +08:00
".bp-row input[type=checkbox]{cursor:pointer;margin:0}" ,
2026-09-13 20:45:36 +08:00
// 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)}" ,
2026-09-13 22:27:49 +08:00
// 官方推荐插件列表:**拉高到「离弹窗底部约 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))}" ,
2026-09-13 20:45:36 +08:00
".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}}" ,
2026-09-13 22:27:49 +08:00
// ── 第 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}" ,
2026-09-13 20:45:36 +08:00
"@media (prefers-reduced-motion: reduce){.pa-card,.pa-modal{animation:none;transition:none}.pa-card:hover{transform:none}}"
2026-09-13 16:18:10 +08:00
]. 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 ];
2026-09-13 22:27:49 +08:00
// 平台**实际下发**的内存配额(档案 84)—— 来自 `/api/dsh/status` 的 `quota`
// ( `{memMb, heapMb}`,与编排器 spawn 时用的是同一个值)。有它就以它为准显示;
// 拿不到(老平台 / k8s 模式)才退回本地算式,并在文案里如实标注为估算。
var quotaState = useState ( null );
var quotaInfo = quotaState [ 0 ];
var setQuotaInfo = quotaState [ 1 ];
2026-09-13 16:18:10 +08:00
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" )); });
2026-09-13 22:27:49 +08:00
// 并行取「平台实际配了多少内存」(档案 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 ); });
2026-09-13 16:18:10 +08:00
}
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 ) {
2026-09-13 22:27:49 +08:00
// 进度条的 100% 基准 = **硬顶**( `MEM_MAX_MIB`)。
// ⚠️ 不能拿「配额」当基准:配额本身是 clamp 出来的(≥384),当基准会让所有实例都满格。
2026-09-13 16:18:10 +08:00
var limit = props . limit ;
2026-09-13 22:27:49 +08:00
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 ;
2026-09-13 16:18:10 +08:00
var accent = over ? T . danger : ( tight ? T . warn : T . success );
2026-09-13 22:27:49 +08:00
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 ;
2026-09-13 16:18:10 +08:00
var stateText = over ? t ( "mem.over" ) : ( tight ? t ( "mem.tight" ) : t ( "mem.safe" ));
2026-09-13 22:27:49 +08:00
var line = t ( "mem.now" ) + " " + quotaNow + " MiB" ;
2026-09-13 16:18:10 +08:00
if ( delta !== 0 ) {
2026-09-13 22:27:49 +08:00
line += " → " + t ( "mem.planned" ) + " " + quotaPlanned + " MiB" ;
2026-09-13 16:18:10 +08:00
} else {
line += " / " + t ( "mem.limit" ) + " " + limit + " MiB" ;
}
2026-09-13 22:27:49 +08:00
// 事实行:平台**实际下发**的配额与 V8 堆(来自 /api/dsh/status;文案由调用方传入)。
var fact = factText ;
2026-09-13 16:18:10 +08:00
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%" },
2026-09-13 22:27:49 +08:00
children : fact
2026-09-13 16:18:10 +08:00
})
]
}),
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 ;
2026-09-13 22:27:49 +08:00
// 内存配额推演(档案 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" );
2026-09-13 16:18:10 +08:00
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 22:27:49 +08:00
// 内存配额状态条:**卡片列表上方**(2026-09-13 用户要求)——
// 当前配额 / 勾选后配额 / 是否顶到硬顶,让用户在点「应用」之前就知道后果。
2026-09-13 16:18:10 +08:00
rows . push ( jsxRuntime . jsx ( MemBar , {
key : "__membar" ,
2026-09-13 22:27:49 +08:00
limit : MEM_MAX_MIB ,
quotaNow : quotaNow ,
quotaPlanned : quotaPlanned ,
rawNow : rawNow ,
rawPlanned : rawPlanned ,
factText : memFactText
2026-09-13 16:18:10 +08:00
}));
// 工具行:搜索 + 全选/清空 + 计数(规范 §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 22:27:49 +08:00
// 加载成本(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"
})
2026-09-13 16:18:10 +08:00
})
2026-09-13 22:27:49 +08:00
: null
2026-09-13 16:18:10 +08:00
]
}),
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 ) {
2026-09-13 22:27:49 +08:00
var overNow = rawPlanned > MEM_MAX_MIB ;
2026-09-13 16:18:10 +08:00
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" },
2026-09-13 22:27:49 +08:00
children : t ( "mem.now" ) + " " + quotaNow + " MiB → " + t ( "mem.planned" ) + " " + quotaPlanned
+ " MiB / " + t ( "mem.limit" ) + " " + MEM_MAX_MIB + " MiB"
2026-09-13 16:18:10 +08:00
}),
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 },
2026-09-13 22:27:49 +08:00
children : memFactText
2026-09-13 16:18:10 +08:00
})
]
}),
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
);
});
2026-09-13 17:48:33 +08:00
2026-09-13 22:27:49 +08:00
// ═══════════════════════════════════════════════════════════════════════════
// 「系统管理」页面族 —— **逐函数移植门户 `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 ]);
2026-09-13 17:48:33 +08:00
return jsxRuntime . jsx ( "div" , {
2026-09-13 22:27:49 +08:00
className : "pa-page" ,
ref : function ( n ) { host = n ; },
dangerouslySetInnerHTML : { __html : props . html + '<div class="pa-toast"></div>' }
2026-09-13 17:48:33 +08:00
});
}
2026-09-13 22:27:49 +08:00
// ── 「系统管理」就地管理面(第 3 轮 · 2026-09-13 · 见档案 82 §追加)────────────
// 形态:**原生渲染**(非 iframe)——复用 dsh 官方 token 与 06-工作台UI规范 字号阶梯。
// 范围:与门户 `web/portal.html` **同一批功能**(服务管理 / 密钥管理 / 用户管理 /
// 技能管理 / 插件管理 / 运行环境),点开每一项即在弹窗内完成该页的全部操作。
// ⚠️ 这**推翻了 R2 初版「只读子集 + 写操作回跳门户」**的口径:2026-09-13 用户明确
// 要求「点开弹窗里面展示的内容,要和门户点击功能后那种详细的表格和功能一致」。
// 可见性:**仅 admin**(先 `/api/auth/me` 判角色;非 admin 连分区都不注册)。
2026-09-13 17:48:33 +08:00
function PlatformAdminSection () {
var useState = React . useState ;
var useEffect = React . useEffect ;
2026-09-13 22:27:49 +08:00
var st = useState ({ loading : true , role : null });
2026-09-13 17:48:33 +08:00
var s = st [ 0 ];
var setS = st [ 1 ];
2026-09-13 22:27:49 +08:00
var pv = useState ( null ); // 当前打开的功能页 key(null = 系统管理首页)
var pageKey = pv [ 0 ];
var setPageKey = pv [ 1 ];
2026-09-13 17:48:33 +08:00
2026-09-13 22:27:49 +08:00
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" ) })
] });
2026-09-13 19:46:23 +08:00
}
2026-09-13 22:27:49 +08:00
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 () })
]
});
}) }));
2026-09-13 17:48:33 +08:00
});
2026-09-13 19:46:23 +08:00
return jsxRuntime . jsx ( "div" , {
2026-09-13 22:27:49 +08:00
style : { padding : "4px 2px" , display : "flex" , flexDirection : "column" , gap : "12px" },
children : kids
2026-09-13 19:46:23 +08:00
});
}
2026-09-13 22:27:49 +08:00
/** 功能页 ← 门户 `.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" ,
2026-09-13 19:46:23 +08:00
onClick : function ( e ) { e . stopPropagation (); },
children : [
2026-09-13 22:27:49 +08:00
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" )
})
2026-09-13 20:45:36 +08:00
] }),
2026-09-13 22:27:49 +08:00
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 ) })
2026-09-13 19:46:23 +08:00
]
})
2026-09-13 22:27:49 +08:00
});
2026-09-13 19:46:23 +08:00
}
2026-09-13 22:27:49 +08:00
return ( pageKey && PA_PAGES [ pageKey ]) ? detail ( PA_PAGES [ pageKey ]) : home ();
2026-09-13 17:48:33 +08:00
}
2026-09-13 22:27:49 +08:00
2026-09-13 20:19:01 +08:00
// ── 「系统管理」分区:**仅 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 () {});
2026-09-13 16:18:10 +08:00
}
exports . apply = apply ;
exports . inject = inject ;
return module . exports ;
}
});