档案 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 登录态)
1084 lines
46 KiB
JavaScript
1084 lines
46 KiB
JavaScript
// @dsh-local/business-plugins — client half (dsh 0.1.2-rc.1, v0.2.4)
|
||
//
|
||
// dsh client bundles 以普通脚本形式执行,向 window.__ModuleLoader__ 注册 factory;
|
||
// 首次 import 时惰性物化 factory(require) -> exports。require 解析平台 seed 词
|
||
// (react/jsx-runtime)与图行(其他 dsh.client 包)。**严格镜像官方 client bundle
|
||
// 的 CJS-style factory 形态,只导出 apply + inject,绝不 exports.default**(R3:
|
||
// exports.default 会让 loader 的 ESM/CJS interop 把 bare apply FUNCTION 当作插件体,
|
||
// 无 inject 声明点 → ctx.slots 抛 cannot get property "slots" without inject)。
|
||
//
|
||
// v0.2.0(档案 37 · 批次 3):
|
||
// · 配色全部改用官方 design token `--dsw-*`(原来硬编码深色)→ **自动跟随 dsh 主题**
|
||
// (亮/暗/第三方主题都适配),不再需要自己实现主题切换。
|
||
// · 文案走官方 locale:ctx.locale.register(NS,{zh,en}) + ctx.locale.bind(NS) → 中英双语。
|
||
// · 交互:搜索过滤 + 每行状态徽章(已启用/未启用)+ **被自动禁用插件的行内提示与重试**
|
||
// + 空态/加载态。
|
||
// · 应用成功且实例重启后,**自动探活并刷新页面**(重启会换端口,刷新即可接上新实例)。
|
||
//
|
||
// v0.2.4(档案 67 · 对齐 06-工作台UI规范.md):
|
||
// · **信息层次反转**:主视觉改为插件的**用途说明**(候选池入库时已优先取官方目录的中文),
|
||
// 插件名 / 版本退为副行小字 —— 与门户 plugins 页同一决策(「一眼知道这插件干什么」)。
|
||
// · 字号对齐规范 §2.2 阶梯(section 语境取 14 / 13 / 12,不照搬页面级 16px 基准);
|
||
// 徽章改 `2px 8px` r10、按钮改 `.btn-sm` 量级、行距与内边距按规范 §2.4 序列。
|
||
// · 空态/加载态按规范 §4.9 改为「标题 + 说明」层次,不再是一行灰字。
|
||
// · 新增注入式 CSS 提供行 hover 反馈与按钮 hover(规范 §4.3 / §5);disposer 随 fiber 移除。
|
||
//
|
||
// v0.2.5(档案 67 补 · 按 `impeccable` 的 craft-floor 复核后修正;检测器未随技能包提供,人工过检):
|
||
// · **去掉 rejected 提示的彩色 `border-left`** —— craft-floor 明令:卡片/列表项/提示上
|
||
// 不得出现 >1px 的彩色 border-left/right。改为整块浅底 + 圆角,危险语义由标题文字色承担。
|
||
// · **对比度**:所有必读文字从 `--dsw-alias-label-tertiary` 提到 `secondary`
|
||
// (tertiary 在亮色下约 3.5:1,低于 craft-floor 的 4.5:1 底线)。
|
||
// · **层次**:主视觉加 `fontWeight: 500`,与 12px 副行拉开 weight 台阶(原先只差 2px 字号,
|
||
// 属于 craft-floor 点名的「scale and weight steps 不明显」)。
|
||
// · **动效**:transition 由 `.15s ease` 改为 `.18s cubic-bezier(.16,1,.3,1)`(exponential ease-out)。
|
||
//
|
||
// v0.2.7(档案 75 维度 B 落地 · 2026-09-13 用户要求):
|
||
// · **卡片加内存预估**:每张卡片多一行「预估内存 ≈ N MiB」小徽章,卡片加高(minHeight 112)
|
||
// 让「用途说明 / 包名 / 预估内存 / 状态徽章」四层排得开。
|
||
// · **列表上方新增「内存预估」状态条**:实心段 = 当前已启用插件的预估占用,
|
||
// 半透明段 = 本次勾选带来的增量;超过单实例上限(384 MiB)时整条转红并提示。
|
||
// · **口径**:`MEM_BASE_MIB`(-dsh 本体+官方插件基线) + `MEM_TABLE`(逐插件实测加载成本);
|
||
// **是预估值,不是实时读数**(客户端拿不到 cgroup 实测值,需平台加只读路由才能做实时 —— 见档案 75)。
|
||
//
|
||
// 功能:在 dsh 设置面板注册「功能管理」section —— 列出候选池 + 每个插件启用状态,
|
||
// 批量勾选后点「应用更改」,确认弹窗(提示重启会中断会话)→ POST 门户
|
||
// /api/plugins/mine/apply(跨子域 fetch,credentials include;门户已加 CORS)。
|
||
// 门户端启用=复制 bundle 到该用户 profile 并加入 bundles,禁用=从 bundles 移除,
|
||
// 然后重启该用户实例生效;**启用会让实例起不来的插件会被门户单独摘掉并标记**。
|
||
|
||
window.__ModuleLoader__.load({
|
||
id: "@dsh-local/business-plugins",
|
||
factory: (require) => {
|
||
var module = { exports: {} };
|
||
var exports = module.exports;
|
||
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
||
var jsxRuntime = require("react/jsx-runtime");
|
||
var React = require("react");
|
||
|
||
// 门户主域:去掉实例子域 label(admin.alotbuy.com -> alotbuy.com)。
|
||
function portalHost() {
|
||
try {
|
||
var labels = window.location.hostname.split(".");
|
||
if (labels.length > 2) {
|
||
return window.location.protocol + "//" + labels.slice(1).join(".");
|
||
}
|
||
} catch (e) {}
|
||
return window.location.origin;
|
||
}
|
||
|
||
// 官方 design token(dsh 主题变量)。刻意**不硬编码颜色**:dsw-* 由主题提供,
|
||
// 亮/暗/第三方主题自动适配;fallback 仅在该 token 缺失时兜底。
|
||
// 注:06-工作台UI规范 §2.1 给的是亮色基线色值,本 section 嵌在 dsh 面板内,
|
||
// 跟随宿主主题优先,故取 token + 规范色值作 fallback。
|
||
var T = {
|
||
text: "var(--dsw-alias-label-primary, #24292f)",
|
||
sub: "var(--dsw-alias-label-secondary, #4e5969)",
|
||
dim: "var(--dsw-alias-label-tertiary, #6b7280)",
|
||
border: "var(--dsw-alias-border-l2, #e3e6ea)",
|
||
field: "var(--dsw-alias-bg-layer-3, #f5f6f8)",
|
||
primary: "var(--dsw-alias-brand-primary, #2f6fed)",
|
||
danger: "var(--dsw-alias-state-error-primary, #d93026)",
|
||
warn: "var(--dsw-alias-state-warning-primary, #bf8700)",
|
||
success: "var(--dsw-alias-state-success-primary, #1a7f37)"
|
||
};
|
||
|
||
// ── 内存预估模型(档案 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;
|
||
}
|
||
});
|