Files
dsh_shenxian/poc/business-plugins/lib/client.js
T
admin 8d5597b3f9 feat(R2): 插件新增原生「平台管理」只读分区(仅 admin)
档案 82 / 81 §9.2b:形态=原生弹窗(非 iframe),只读优先,写操作回跳平台域。
- client.js 895→1083 行:新增 PaCard + PlatformAdminSection + settings.section 注册(id=platform-admin, order=102)
- 数据源全部为平台既有只读路由(/api/auth/me、/api/dsh/status、/api/admin/{users,storage,runtime}、/api/plugins/business)
- i18n:zh/en 各 +29 个 pa.* 键
- 版本 0.2.8 → 0.2.9;tgz 已出(结构校验通过)
- 未验证:渲染/交互/权限门禁(需投放 + 启用,卡在需 admin 登录态)
2026-09-13 17:48:33 +08:00

1084 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @dsh-local/business-plugins — client half (dsh 0.1.2-rc.1, v0.2.4)
//
// dsh client bundles 以普通脚本形式执行,向 window.__ModuleLoader__ 注册 factory;
// 首次 import 时惰性物化 factory(require) -> exports。require 解析平台 seed 词
// (react/jsx-runtime)与图行(其他 dsh.client 包)。**严格镜像官方 client bundle
// 的 CJS-style factory 形态,只导出 apply + inject,绝不 exports.default**(R3:
// exports.default 会让 loader 的 ESM/CJS interop 把 bare apply FUNCTION 当作插件体,
// 无 inject 声明点 → ctx.slots 抛 cannot get property "slots" without inject)。
//
// v0.2.0(档案 37 · 批次 3):
// · 配色全部改用官方 design token `--dsw-*`(原来硬编码深色)→ **自动跟随 dsh 主题**
// (亮/暗/第三方主题都适配),不再需要自己实现主题切换。
// · 文案走官方 locale:ctx.locale.register(NS,{zh,en}) + ctx.locale.bind(NS) → 中英双语。
// · 交互:搜索过滤 + 每行状态徽章(已启用/未启用)+ **被自动禁用插件的行内提示与重试**
// + 空态/加载态。
// · 应用成功且实例重启后,**自动探活并刷新页面**(重启会换端口,刷新即可接上新实例)。
//
// v0.2.4(档案 67 · 对齐 06-工作台UI规范.md):
// · **信息层次反转**:主视觉改为插件的**用途说明**(候选池入库时已优先取官方目录的中文),
// 插件名 / 版本退为副行小字 —— 与门户 plugins 页同一决策(「一眼知道这插件干什么」)。
// · 字号对齐规范 §2.2 阶梯(section 语境取 14 / 13 / 12,不照搬页面级 16px 基准);
// 徽章改 `2px 8px` r10、按钮改 `.btn-sm` 量级、行距与内边距按规范 §2.4 序列。
// · 空态/加载态按规范 §4.9 改为「标题 + 说明」层次,不再是一行灰字。
// · 新增注入式 CSS 提供行 hover 反馈与按钮 hover(规范 §4.3 / §5);disposer 随 fiber 移除。
//
// v0.2.5(档案 67 补 · 按 `impeccable` 的 craft-floor 复核后修正;检测器未随技能包提供,人工过检):
// · **去掉 rejected 提示的彩色 `border-left`** —— craft-floor 明令:卡片/列表项/提示上
// 不得出现 >1px 的彩色 border-left/right。改为整块浅底 + 圆角,危险语义由标题文字色承担。
// · **对比度**:所有必读文字从 `--dsw-alias-label-tertiary` 提到 `secondary`
// (tertiary 在亮色下约 3.5:1,低于 craft-floor 的 4.5:1 底线)。
// · **层次**:主视觉加 `fontWeight: 500`,与 12px 副行拉开 weight 台阶(原先只差 2px 字号,
// 属于 craft-floor 点名的「scale and weight steps 不明显」)。
// · **动效**:transition 由 `.15s ease` 改为 `.18s cubic-bezier(.16,1,.3,1)`(exponential ease-out)。
//
// v0.2.7(档案 75 维度 B 落地 · 2026-09-13 用户要求):
// · **卡片加内存预估**:每张卡片多一行「预估内存 ≈ N MiB」小徽章,卡片加高(minHeight 112)
// 让「用途说明 / 包名 / 预估内存 / 状态徽章」四层排得开。
// · **列表上方新增「内存预估」状态条**:实心段 = 当前已启用插件的预估占用,
// 半透明段 = 本次勾选带来的增量;超过单实例上限(384 MiB)时整条转红并提示。
// · **口径**:`MEM_BASE_MIB`(-dsh 本体+官方插件基线) + `MEM_TABLE`(逐插件实测加载成本);
// **是预估值,不是实时读数**(客户端拿不到 cgroup 实测值,需平台加只读路由才能做实时 —— 见档案 75)。
//
// 功能:在 dsh 设置面板注册「功能管理」section —— 列出候选池 + 每个插件启用状态,
// 批量勾选后点「应用更改」,确认弹窗(提示重启会中断会话)→ POST 门户
// /api/plugins/mine/apply(跨子域 fetch,credentials include;门户已加 CORS)。
// 门户端启用=复制 bundle 到该用户 profile 并加入 bundles,禁用=从 bundles 移除,
// 然后重启该用户实例生效;**启用会让实例起不来的插件会被门户单独摘掉并标记**。
window.__ModuleLoader__.load({
id: "@dsh-local/business-plugins",
factory: (require) => {
var module = { exports: {} };
var exports = module.exports;
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
var jsxRuntime = require("react/jsx-runtime");
var React = require("react");
// 门户主域:去掉实例子域 label(admin.alotbuy.com -> alotbuy.com)。
function portalHost() {
try {
var labels = window.location.hostname.split(".");
if (labels.length > 2) {
return window.location.protocol + "//" + labels.slice(1).join(".");
}
} catch (e) {}
return window.location.origin;
}
// 官方 design token(dsh 主题变量)。刻意**不硬编码颜色**:dsw-* 由主题提供,
// 亮/暗/第三方主题自动适配;fallback 仅在该 token 缺失时兜底。
// 注:06-工作台UI规范 §2.1 给的是亮色基线色值,本 section 嵌在 dsh 面板内,
// 跟随宿主主题优先,故取 token + 规范色值作 fallback。
var T = {
text: "var(--dsw-alias-label-primary, #24292f)",
sub: "var(--dsw-alias-label-secondary, #4e5969)",
dim: "var(--dsw-alias-label-tertiary, #6b7280)",
border: "var(--dsw-alias-border-l2, #e3e6ea)",
field: "var(--dsw-alias-bg-layer-3, #f5f6f8)",
primary: "var(--dsw-alias-brand-primary, #2f6fed)",
danger: "var(--dsw-alias-state-error-primary, #d93026)",
warn: "var(--dsw-alias-state-warning-primary, #bf8700)",
success: "var(--dsw-alias-state-success-primary, #1a7f37)"
};
// ── 内存预估模型(档案 75「维度 B 资源成本」的前端落地)────────────────────
// 口径:**预估值** = 空载基线 + Σ(已启用插件的加载成本)。客户端拿不到 cgroup 实测值,
// 因此这里是估算而非实时读数;数值来源 = 2026-09-13 隔离 cgroup 实测
//(`node --expose-gc` 逐项 import 的 rss 增量,见 `.workbuddy/memory/2026-09-13.md`)。
/** 单实例 cgroup 上限(MiB)—— 档案 58 定档;改它必须同时改编排器的 `MemoryMax`。 */
var MEM_LIMIT_MIB = 384;
/** 空载基线(MiB):dsh 本体 + 148 个官方插件 + 平台自研 ×3(smaps 实测 ≈ 285)。 */
var MEM_BASE_MIB = 285;
/** 逐插件加载成本(MiB,实测 rss 增量)。不在此表的按 MEM_FALLBACK_MIB 计。 */
var MEM_TABLE = {
"dsh-univer-office": 64,
"dsh-plugin-mcn-suite": 39
};
/** 未实测插件的兜底值 —— 平台自研轻量插件实测合计仅 1.7 MiB。 */
var MEM_FALLBACK_MIB = 2;
/** 越过该比例即提前警示,给页缓存与用户自己的任务留余量。 */
var MEM_TIGHT_RATIO = 0.85;
function memMiB(p) {
if (p && p.name && Object.prototype.hasOwnProperty.call(MEM_TABLE, p.name)) {
return MEM_TABLE[p.name];
}
return MEM_FALLBACK_MIB;
}
/** 把一组插件里 `key` 为真的那些的预估内存求和。 */
function sumMiB(list, key) {
var total = 0;
for (var i = 0; i < list.length; i++) {
if (list[i][key]) total += memMiB(list[i]);
}
return total;
}
/** 本插件的 locale 命名空间(官方 i18n)。 */
var NS = "business-plugins";
/** 简体中文字典(key 集的事实来源)。 */
var zh = {
"section.label": "功能管理",
"loading": "加载中…",
"loadingDesc": "正在从门户读取候选池…",
"emptyTitle": "暂无功能插件",
"empty": "需管理员先在门户「插件管理」里投放,之后可在此启用",
"noMatch": "没有匹配的插件",
"search": "搜索插件名或用途",
"selectAll": "全选",
"clear": "清空",
"enabled": "已启用",
"disabled": "未启用",
"selected": "已选",
"apply": "应用更改",
"applying": "应用中…",
"confirm.title": "确认应用更改?",
"confirm": "将重启当前 dsh 实例以生效。重启会中断当前会话(正在进行的对话会断开)。",
"confirm.cancel": "取消",
"confirm.ok": "确认应用",
"confirm.overTitle": "⚠ 本次勾选将超出单实例内存上限",
"nochange": "没有需要更改的插件",
"queued": "排队中…",
"installing": "正在安装",
"configuring": "正在配置",
"restarting": "正在重启实例",
"probing": "正在检查实例",
"isolating": "正在定位问题插件",
"applied": "✓ 已应用",
"reloading": "实例重启中,页面将自动刷新…",
"rejected.title": "以下插件与实例不兼容,已自动禁用",
"retry": "重试",
"requestFailed": "请求失败",
"loadFailed": "加载失败",
"mem.title": "内存预估",
"mem.est": "预估(实测基准,非实时读数)",
"mem.now": "当前",
"mem.planned": "勾选后",
"mem.limit": "上限",
"mem.left": "剩余",
"mem.over": "将超出上限",
"mem.tight": "接近上限",
"mem.safe": "余量充足",
"mem.overWarn": "勾选后将超出单实例内存上限,实例可能起不来。建议先停用部分插件再应用。",
"mem.perCard": "预估内存",
"pa.label": "平台管理",
"pa.readonly": "只读",
"pa.loading": "加载中…",
"pa.loadingDesc": "正在读取平台状态…",
"pa.notAdminTitle": "平台管理仅对管理员显示",
"pa.notAdminDesc": "当前账号不是管理员,此处不展示平台信息。",
"pa.loadFailed": "平台状态读取失败",
"pa.retry": "重试",
"pa.openPortal": "打开完整管理台",
"pa.readonlyHint": "本分区为只读;写操作请在平台管理台完成。",
"pa.inst": "当前实例",
"pa.running": "运行中",
"pa.stopped": "未运行",
"pa.port": "端口",
"pa.restarts": "自动重启",
"pa.breaker": "熔断",
"pa.breakerOpen": "已熔断(冷却中)",
"pa.breakerOk": "正常",
"pa.users": "用户",
"pa.total": "合计",
"pa.pending": "待审核",
"pa.disabled": "已禁用",
"pa.storage": "存储",
"pa.storageUsers": "有用量的用户",
"pa.storageNote": "报告每小时刷新",
"pa.pool": "候选池",
"pa.poolCount": "已投放插件",
"pa.runtime": "运行时",
"pa.items": "条目"
};
/** English dictionary, key-set complete against zh. */
var en = {
"section.label": "Feature management",
"loading": "Loading…",
"loadingDesc": "Reading the candidate pool from the portal…",
"emptyTitle": "No feature plugins yet",
"empty": "An admin must publish them in the portal's plugin management first",
"noMatch": "No matching plugins",
"search": "Search by name or purpose",
"selectAll": "Select all",
"clear": "Clear",
"enabled": "Enabled",
"disabled": "Disabled",
"selected": "selected",
"apply": "Apply changes",
"applying": "Applying…",
"confirm.title": "Apply the changes?",
"confirm": "The dsh instance will restart, which interrupts the current session (any running conversation is disconnected).",
"confirm.cancel": "Cancel",
"confirm.ok": "Apply",
"confirm.overTitle": "⚠ This selection exceeds the per-instance memory limit",
"nochange": "No changes to apply",
"queued": "Queued…",
"installing": "Installing",
"configuring": "Configuring",
"restarting": "Restarting instance",
"probing": "Checking instance health",
"isolating": "Locating the failing plugin",
"applied": "✓ Applied",
"reloading": "Instance is restarting — the page will refresh automatically…",
"rejected.title": "These plugins are incompatible with the instance and were disabled automatically",
"retry": "Retry",
"requestFailed": "Request failed",
"loadFailed": "Failed to load",
"mem.title": "Memory estimate",
"mem.est": "estimate from measured baselines, not a live reading",
"mem.now": "now",
"mem.planned": "after selection",
"mem.limit": "limit",
"mem.left": "left",
"mem.over": "over the limit",
"mem.tight": "close to the limit",
"mem.safe": "headroom is fine",
"mem.overWarn": "The selection exceeds the per-instance memory limit and the instance may fail to start. Disable some plugins first.",
"mem.perCard": "est. memory",
"pa.label": "Platform admin",
"pa.readonly": "read-only",
"pa.loading": "Loading…",
"pa.loadingDesc": "Reading platform status…",
"pa.notAdminTitle": "Platform admin is admin-only",
"pa.notAdminDesc": "This account is not an admin, so platform info is hidden here.",
"pa.loadFailed": "Failed to read platform status",
"pa.retry": "Retry",
"pa.openPortal": "Open full admin console",
"pa.readonlyHint": "This panel is read-only; do write actions in the admin console.",
"pa.inst": "Current instance",
"pa.running": "Running",
"pa.stopped": "Stopped",
"pa.port": "Port",
"pa.restarts": "Auto-restarts",
"pa.breaker": "Circuit breaker",
"pa.breakerOpen": "Open (cooling down)",
"pa.breakerOk": "OK",
"pa.users": "Users",
"pa.total": "Total",
"pa.pending": "Pending",
"pa.disabled": "Disabled",
"pa.storage": "Storage",
"pa.storageUsers": "Users with usage",
"pa.storageNote": "report refreshes hourly",
"pa.pool": "Candidate pool",
"pa.poolCount": "Published plugins",
"pa.runtime": "Runtime",
"pa.items": "items"
};
/**
* Required services (cordis fiber inject). `locale` is required for the
* dictionaries; the slot registry for the settings section.
*/
var inject = ["slots", "locale"];
/** 应用成功后探活并刷新(重启会换端口;探通了再 reload,避免白屏)。 */
function waitAndReload(attempts) {
if (attempts <= 0) {
window.location.reload();
return;
}
fetch(window.location.origin + "/", { cache: "no-store" })
.then(function (r) {
if (r.ok) window.location.reload();
else setTimeout(function () { waitAndReload(attempts - 1); }, 1500);
})
.catch(function () { setTimeout(function () { waitAndReload(attempts - 1); }, 1500); });
}
function apply(ctx) {
ctx.effect(function () {
return ctx.locale.register(NS, { zh: zh, en: en });
}, "business-plugins: dictionaries");
// 内联样式表达不了 `:hover`,而 06-工作台UI规范要求「列表行 hover 反馈」(§4.3)
// 与「hover 才提示可点」(§5)。这里注入一小段 CSS,disposer 随 fiber 移除 ——
// 不引外部样式表,也不污染宿主全局(选择器统一带 bp- 前缀)。
ctx.effect(function () {
var el = document.createElement("style");
el.setAttribute("data-bp-style", "1");
el.textContent = [
".bp-row{transition:background .18s cubic-bezier(.16,1,.3,1),border-color .18s cubic-bezier(.16,1,.3,1),box-shadow .18s cubic-bezier(.16,1,.3,1)}",
".bp-row:hover{border-color:var(--dsw-alias-brand-primary, #2f6fed);box-shadow:0 4px 12px rgba(47,111,237,.15)}",
".bp-btn{transition:border-color .18s cubic-bezier(.16,1,.3,1),color .18s cubic-bezier(.16,1,.3,1)}",
".bp-btn:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary, #2f6fed);color:var(--dsw-alias-brand-primary, #2f6fed)}",
".bp-row input[type=checkbox]{cursor:pointer;margin:0}"
].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];
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")); });
}
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) {
var limit = props.limit;
var nowMiB = props.nowMiB;
var plannedMiB = props.plannedMiB;
var over = plannedMiB > limit;
var tight = !over && plannedMiB > limit * MEM_TIGHT_RATIO;
var accent = over ? T.danger : (tight ? T.warn : T.success);
var nowPct = Math.max(0, Math.min(100, (nowMiB / limit) * 100));
var planPct = Math.max(0, Math.min(100, (plannedMiB / limit) * 100));
var delta = plannedMiB - nowMiB;
var stateText = over ? t("mem.over") : (tight ? t("mem.tight") : t("mem.safe"));
var line = t("mem.now") + " " + nowMiB + " MiB";
if (delta !== 0) {
line += " → " + t("mem.planned") + " " + plannedMiB + " MiB";
} else {
line += " / " + t("mem.limit") + " " + limit + " MiB";
}
return jsxRuntime.jsxs("div", {
style: {
display: "flex", flexDirection: "column", gap: "6px", padding: "10px 12px",
borderRadius: "10px", background: T.field,
border: "1px solid " + (over ? T.danger : T.border)
},
children: [
jsxRuntime.jsxs("div", {
key: "h",
style: { display: "flex", alignItems: "baseline", gap: "8px", flexWrap: "wrap" },
children: [
jsxRuntime.jsx("span", {
key: "t",
style: { fontSize: "13px", fontWeight: 500, color: T.text },
children: t("mem.title")
}),
jsxRuntime.jsx("span", {
key: "n",
style: { fontSize: "13px", color: T.sub, fontVariantNumeric: "tabular-nums" },
children: line
}),
jsxRuntime.jsx("span", {
key: "s",
style: { fontSize: "12px", color: accent, marginLeft: "auto", flexShrink: 0 },
children: (over ? "⚠ " : "") + stateText
}),
jsxRuntime.jsx("span", {
key: "e",
style: { fontSize: "12px", color: T.dim, flexBasis: "100%" },
children: t("mem.est")
})
]
}),
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;
// 内存预估:基线 + Σ 插件成本。
// · nowMiB = 服务端快照里「已启用」的那批(savedIds)→ 代表**当前**占用;
// · plannedMiB = 当前勾选状态(用户点一下就变)→ 代表**应用后会是多少**。
var nowMiB = MEM_BASE_MIB + plugins.reduce(function (sum, p) {
return sum + (savedIds.indexOf(p.id) >= 0 ? memMiB(p) : 0);
}, 0);
var plannedMiB = MEM_BASE_MIB + sumMiB(plugins, "enabled");
var memOver = plannedMiB > MEM_LIMIT_MIB;
var inputStyle = {
flex: "1",
minWidth: "180px",
padding: "6px 10px",
borderRadius: "8px",
border: "1px solid " + T.border,
background: T.field,
color: T.text,
fontSize: "14px",
outline: "none"
};
// 规范 §4.1:次要动作用白底 + border(`.btn` / `.btn-sm` 量级),非主色实心。
var ghostBtnStyle = {
padding: "5px 12px", borderRadius: "8px", cursor: "pointer",
border: "1px solid " + T.border, background: "transparent", color: T.sub, fontSize: "13px"
};
var rows = [];
var cards = [];
// 内存预估状态条:**卡片列表上方**(2026-09-13 用户要求)——
// 当前占用 / 勾选后预估 / 是否超上限,让用户在点「应用」之前就知道后果。
rows.push(jsxRuntime.jsx(MemBar, {
key: "__membar",
limit: MEM_LIMIT_MIB,
nowMiB: nowMiB,
plannedMiB: plannedMiB
}));
// 工具行:搜索 + 全选/清空 + 计数(规范 §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 用户要求):数值越大越"贵",用文字色分级
// (≥60 MiB 红 / ≥30 MiB 黄 / 其余次要色),一眼能看出谁是大头。
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) >= 60 ? T.danger : (memMiB(p) >= 30 ? T.warn : T.sub)
},
children: t("mem.perCard") + " ≈ " + memMiB(p) + " MiB"
})
})
]
}),
jsxRuntime.jsx("span", {
key: "bd",
style: {
fontSize: "12px", padding: "2px 8px", borderRadius: "10px", flexShrink: 0,
color: p.enabled ? T.success : T.dim,
border: "1px solid " + (p.enabled ? T.success : T.border)
},
children: p.enabled ? t("enabled") : t("disabled")
}),
bad
? jsxRuntime.jsx("span", {
key: "bad",
style: { fontSize: "12px", color: T.danger, flexShrink: 0 },
title: String(bad.reason || ""),
children: "⚠"
})
: null
]
}));
})(shown[i]);
}
// 卡片网格:一行 2 个(用户 2026-09-12 要求「插件列表改为卡片形式,一行放 2 个卡片」)
// 单独包一层 grid —— 不能直接改外层 wrap(它同时装标题/搜索/按钮/提示等纵向块)。
rows.push(jsxRuntime.jsx("div", {
key: "__grid",
style: { display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: "10px" },
children: cards
}));
rows.push(jsxRuntime.jsxs("div", {
key: "__actions",
style: { display: "flex", alignItems: "center", gap: "10px", marginTop: "4px" },
children: [
// 规范 §4.1:主行动用 primary 实心(`.btn` 量级,section 内 14px)
jsxRuntime.jsx("button", {
key: "apply",
type: "button",
onClick: askApply,
disabled: busy,
style: {
display: "inline-flex", alignItems: "center", justifyContent: "center",
padding: "7px 16px", borderRadius: "8px", border: "1px solid " + T.primary,
background: T.primary, color: "#ffffff", cursor: busy ? "default" : "pointer",
opacity: busy ? 0.5 : 1, fontSize: "14px"
},
children: busy ? t("applying") : t("apply")
}),
rejected.length > 0
? jsxRuntime.jsx("button", {
key: "retry",
type: "button",
className: "bp-btn",
onClick: askApply,
disabled: busy,
style: Object.assign({}, ghostBtnStyle, { opacity: busy ? 0.5 : 1 }),
children: t("retry")
})
: null
]
}));
if (rejected.length > 0) {
// impeccable craft-floor 明令禁止「卡片/列表项/提示上 >1px 的彩色 border-left/right」
// —— 改为整块浅底 + 圆角,危险语义由标题文字色承担(不靠侧边色条)。
rows.push(jsxRuntime.jsxs("div", {
key: "__rejected",
style: {
padding: "10px 12px", borderRadius: "8px",
color: T.sub, fontSize: "13px", lineHeight: "1.6", background: T.field
},
children: [
jsxRuntime.jsxs("div", {
key: "h",
style: { fontWeight: 500, color: T.danger, marginBottom: "2px" },
children: ["⚠ ", t("rejected.title")]
}),
jsxRuntime.jsx("div", { key: "b", children: rejected.map(function (r) { return r.id; }).join("、") })
]
}));
}
if (msg) {
rows.push(jsxRuntime.jsx("div", { key: "__msg", style: { color: T.sub, fontSize: "13px" }, children: msg }));
}
// ── 页内确认弹窗(替代 window.confirm;视觉对齐 MCN 工作台弹窗)──────────
// 结构照 MCN:遮罩(fixed inset:0 + rgba(0,0,0,.35) + zIndex 2000 + 居中)→ 面板
// (radius 12 / padding 18·22 / maxHeight 82vh / 阴影)→ 标题行(✕ 关闭)→ 正文 →
// 内存块(**超限时红底 + 警告**,用户 2026-09-13 明确要求)→ 按钮行(取消 / 确认)。
// 点遮罩关闭、点面板 stopPropagation(与 MCN 同款交互)。
if (confirmOpen) {
var overNow = plannedMiB > MEM_LIMIT_MIB;
rows.push(jsxRuntime.jsxs("div", {
key: "__confirm",
style: {
position: "fixed", inset: 0, background: "rgba(0,0,0,.35)", zIndex: 2000,
display: "flex", alignItems: "center", justifyContent: "center", padding: "24px"
},
onClick: function () { setConfirmOpen(false); },
children: jsxRuntime.jsxs("div", {
style: {
background: "var(--dsw-alias-bg-layer-1, #ffffff)",
borderRadius: "12px", maxWidth: "480px", width: "100%", maxHeight: "82vh",
overflow: "auto", padding: "18px 22px",
boxShadow: "0 8px 30px rgba(0,0,0,.18)",
display: "flex", flexDirection: "column", gap: "10px"
},
onClick: function (e) { e.stopPropagation(); },
children: [
jsxRuntime.jsxs("div", {
key: "t",
style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" },
children: [
jsxRuntime.jsx("span", {
key: "s",
style: { fontSize: "15px", fontWeight: 500, color: T.text },
children: t("confirm.title")
}),
jsxRuntime.jsx("button", {
key: "x",
type: "button",
title: t("confirm.cancel"),
onClick: function () { setConfirmOpen(false); },
style: {
border: "none", background: "none", fontSize: "18px", lineHeight: 1,
cursor: "pointer", color: T.dim, padding: "2px 4px"
},
children: "✕"
})
]
}),
jsxRuntime.jsx("div", {
key: "b",
style: { fontSize: "13px", color: T.sub, lineHeight: 1.6 },
children: t("confirm")
}),
jsxRuntime.jsxs("div", {
key: "m",
style: {
padding: "10px 12px", borderRadius: "10px",
background: overNow ? "rgba(217,48,38,.06)" : T.field,
border: "1px solid " + (overNow ? T.danger : T.border),
display: "flex", flexDirection: "column", gap: "4px"
},
children: [
jsxRuntime.jsx("div", {
key: "h",
style: { fontSize: "13px", fontWeight: 500, color: overNow ? T.danger : T.text },
children: overNow ? t("confirm.overTitle") : t("mem.title")
}),
jsxRuntime.jsx("div", {
key: "n",
style: { fontSize: "13px", color: T.sub, fontVariantNumeric: "tabular-nums" },
children: t("mem.now") + " " + nowMiB + " MiB → " + t("mem.planned") + " " + plannedMiB
+ " MiB / " + t("mem.limit") + " " + MEM_LIMIT_MIB + " MiB"
}),
overNow
? jsxRuntime.jsx("div", {
key: "w",
style: { fontSize: "12px", color: T.danger, lineHeight: 1.6 },
children: t("mem.overWarn")
})
: null,
jsxRuntime.jsx("div", {
key: "e",
style: { fontSize: "12px", color: T.dim },
children: t("mem.est")
})
]
}),
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
);
});
// ── R2 · 平台管理(只读就地化;档案 81 §9.2b「原生弹窗」形态)────────────────
// 形态:**原生渲染**(非 iframe)——复用本插件的 token 与 06-工作台UI规范 字号阶梯。
// 数据:直接调平台只读 API(跨子域 fetch + credentials,门户已加 CORS 白名单)。
// 范围:**只读子集**(实例 / 用户 / 存储 / 候选池 / 运行时);
// **写操作一律回跳平台管理台** —— 不复制门户的写 UI(否则双源维护 + 扩大执行面)。
// 可见性:**仅 admin**(先 /api/auth/me 判角色;非 admin 只渲染一行说明)。
function PaCard(props) {
return jsxRuntime.jsx("div", {
style: {
border: "1px solid " + T.border, borderRadius: "10px", background: T.field,
padding: "12px 14px", display: "flex", flexDirection: "column", gap: "4px",
minWidth: "0", flex: "1 1 150px"
},
children: [
jsxRuntime.jsx("div", { key: "t", style: { fontSize: "12px", color: T.dim }, children: props.title }),
jsxRuntime.jsx("div", { key: "v", style: { fontSize: "20px", fontWeight: 500, color: props.color || T.text, fontVariantNumeric: "tabular-nums" }, children: props.value }),
jsxRuntime.jsx("div", { key: "d", style: { fontSize: "12px", color: T.dim, lineHeight: 1.6 }, children: props.desc })
]
});
}
function PlatformAdminSection() {
var useState = React.useState;
var useEffect = React.useEffect;
var st = useState({ loading: true, err: "", me: null, status: null, users: null, storage: null, pool: null, runtime: null });
var s = st[0];
var setS = st[1];
function load() {
setS(function (old) { return Object.assign({}, old, { loading: true, err: "" }); });
var host = portalHost();
function get(path) {
return fetch(host + path, { credentials: "include" }).then(function (r) {
return r.ok ? r.json() : null;
}).catch(function () { return null; });
}
get("/api/auth/me").then(function (me) {
var role = me && me.user ? me.user.role : null;
if (role !== "admin") {
setS(function (old) { return Object.assign({}, old, { loading: false, me: me }); });
return;
}
Promise.all([
get("/api/dsh/status"), get("/api/admin/users"),
get("/api/admin/storage"), get("/api/plugins/business"), get("/api/admin/runtime")
]).then(function (r) {
setS({
loading: false, err: "", me: me,
status: r[0], users: r[1], storage: r[2], pool: r[3], runtime: r[4]
});
});
}).catch(function () {
setS(function (old) { return Object.assign({}, old, { loading: false, err: t("pa.loadFailed") }); });
});
}
useEffect(function () { load(); }, []);
var wrap = { padding: "4px 2px", fontSize: "13px", color: T.text, display: "flex", flexDirection: "column", gap: "12px" };
if (s.loading) {
return jsxRuntime.jsx("div", { style: wrap, children: [
jsxRuntime.jsx("div", { key: "h", style: { fontSize: "15px", fontWeight: 500 }, children: t("pa.loading") }),
jsxRuntime.jsx("div", { key: "d", style: { fontSize: "13px", color: T.sub }, children: t("pa.loadingDesc") })
] });
}
if (s.err) {
return jsxRuntime.jsx("div", { style: wrap, children: [
jsxRuntime.jsx("div", { key: "h", style: { fontSize: "15px", fontWeight: 500, color: T.danger }, children: s.err }),
jsxRuntime.jsx("button", {
key: "b", type: "button", onClick: load,
style: { alignSelf: "flex-start", padding: "6px 14px", borderRadius: "8px", cursor: "pointer", border: "1px solid " + T.border, background: "transparent", color: T.text, fontSize: "13px" },
children: t("pa.retry")
})
] });
}
if (!s.me || !s.me.user || s.me.user.role !== "admin") {
return jsxRuntime.jsx("div", { style: wrap, children: [
jsxRuntime.jsx("div", { key: "h", style: { fontSize: "15px", fontWeight: 500 }, children: t("pa.notAdminTitle") }),
jsxRuntime.jsx("div", { key: "d", style: { fontSize: "13px", color: T.sub, lineHeight: 1.6 }, children: t("pa.notAdminDesc") })
] });
}
var inst = s.status && s.status.running ? t("pa.running") : t("pa.stopped");
var instDesc = s.status && s.status.instance
? t("pa.port") + " " + s.status.instance.port + " · " + t("pa.restarts") + " " + (s.status.instance.restarts || 0)
: "—";
var brk = s.status && s.status.breaker;
var users = (s.users && s.users.users) || [];
var nTotal = users.length;
var nPending = users.filter(function (u) { return u.role === "pending"; }).length;
var nDisabled = users.filter(function (u) { return u.role === "disabled"; }).length;
var stUsers = (s.storage && s.storage.users) || [];
var stDesc = s.storage && s.storage.generatedAt ? t("pa.storageNote") : (s.storage && s.storage.note) || "—";
var pool = (s.pool && (s.pool.plugins || s.pool.items)) || [];
var rt = (s.runtime && (s.runtime.items || s.runtime.groups)) || [];
return jsxRuntime.jsx("div", { style: wrap, children: [
jsxRuntime.jsx("div", { key: "hd", style: { display: "flex", alignItems: "baseline", gap: "8px", flexWrap: "wrap" }, children: [
jsxRuntime.jsx("span", { key: "a", style: { fontSize: "15px", fontWeight: 500 }, children: t("pa.label") }),
jsxRuntime.jsx("span", { key: "b", style: { fontSize: "12px", color: T.dim }, children: t("pa.readonly") }),
jsxRuntime.jsx("span", { key: "c", style: { fontSize: "12px", color: T.dim, flexBasis: "100%" }, children: t("pa.readonlyHint") })
] }),
jsxRuntime.jsx("div", { key: "cards", style: { display: "flex", gap: "10px", flexWrap: "wrap" }, children: [
jsxRuntime.jsx(PaCard, { key: "i", title: t("pa.inst"), value: inst, color: s.status && s.status.running ? T.success : T.sub, desc: instDesc }),
jsxRuntime.jsx(PaCard, { key: "b", title: t("pa.breaker"), value: brk ? t("pa.breakerOpen") : t("pa.breakerOk"), color: brk ? T.warn : T.success, desc: brk && brk.until ? String(brk.until) : "—" }),
jsxRuntime.jsx(PaCard, { key: "u", title: t("pa.users"), value: nTotal, desc: t("pa.pending") + " " + nPending + " · " + t("pa.disabled") + " " + nDisabled }),
jsxRuntime.jsx(PaCard, { key: "s", title: t("pa.storage"), value: stUsers.length, desc: stDesc }),
jsxRuntime.jsx(PaCard, { key: "p", title: t("pa.pool"), value: pool.length, desc: t("pa.poolCount") }),
jsxRuntime.jsx(PaCard, { key: "r", title: t("pa.runtime"), value: rt.length, desc: t("pa.items") })
] }),
jsxRuntime.jsx("button", {
key: "open", type: "button",
onClick: function () { window.open(portalHost() + "/portal.html", "_blank", "noopener"); },
style: { alignSelf: "flex-start", padding: "7px 16px", borderRadius: "8px", cursor: "pointer", border: "1px solid " + T.primary, background: T.primary, color: "#ffffff", fontSize: "14px" },
children: t("pa.openPortal")
})
] });
}
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
);
});
}
exports.apply = apply;
exports.inject = inject;
return module.exports;
}
});