@kira905/dsh-ecosystem-panel 0.0.0-stage → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +85 -0
- package/LICENSE +661 -0
- package/README.en.md +220 -0
- package/README.md +215 -2
- package/cordis.patch.yml +7 -0
- package/docs/RELEASING.md +68 -0
- package/examples/breaking-symbols.example.json +9 -0
- package/examples/ecosystem-panel.config.example.json +28 -0
- package/examples/labels.example.json +6 -0
- package/examples/patch-manifest.example.json +53 -0
- package/examples/upgrade-rules.example.json +28 -0
- package/lib/client.js +417 -0
- package/lib/config.js +362 -0
- package/lib/host-protocol.js +33 -0
- package/lib/index.js +59 -0
- package/lib/state.js +775 -0
- package/lib/upgrade.js +487 -0
- package/package.json +71 -4
- package/scripts/test-fixture.mjs +251 -0
- package/scripts/verify-source.mjs +278 -0
- package/scripts/verify-state.mjs +103 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "升级规则表示例:包名 → { verdict, label, note }。verdict 只是**文案分类**(本面板不据此变色 —— 颜色一律来自档位 CLI 的判定);label/note 直接显示在面板行上。默认表为空。",
|
|
3
|
+
"example-plugin": {
|
|
4
|
+
"verdict": "upgradable",
|
|
5
|
+
"label": "可升(升后需重打补丁)",
|
|
6
|
+
"note": "该包被你打过本地补丁,升级后必须重跑对应的 patch 脚本,否则补丁会静默消失。"
|
|
7
|
+
},
|
|
8
|
+
"another-plugin": {
|
|
9
|
+
"verdict": "blocked",
|
|
10
|
+
"label": "需先升宿主主包",
|
|
11
|
+
"note": "新版本依赖宿主主包新线才拆出的子包;主包不升级就不能单独升它。"
|
|
12
|
+
},
|
|
13
|
+
"frozen-plugin": {
|
|
14
|
+
"verdict": "frozen",
|
|
15
|
+
"label": "已转自建 fork,升级 = 降级",
|
|
16
|
+
"note": "本地版本是自维护 fork,npm 上的版本更旧,不要升。"
|
|
17
|
+
},
|
|
18
|
+
"local-only-plugin": {
|
|
19
|
+
"verdict": "self",
|
|
20
|
+
"label": "本地包(无 npm 来源)",
|
|
21
|
+
"note": "本地开发或私有分发,不涉升版。"
|
|
22
|
+
},
|
|
23
|
+
"some-host-builtin": {
|
|
24
|
+
"verdict": "internal",
|
|
25
|
+
"label": "宿主内置,随主包",
|
|
26
|
+
"note": "远端 npm latest 标签可能落后于宿主线,忽略远端提示。"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
// dsh-ecosystem-panel / lib/client.js(浏览器端)
|
|
2
|
+
// 侧边栏底部「设置」齿轮上方注入图标按钮;点击展开只读生态状态面板。
|
|
3
|
+
// 零 import(不 require react / primitives —— rc.2 主包没有拆分包,import 即崩)。
|
|
4
|
+
// 姿势:entry.className 复制宿主按钮 className(本按钮复制齿轮按钮),
|
|
5
|
+
// self-heal MutationObserver 抗前端重渲染;折叠态规则的选择器**不加前缀**
|
|
6
|
+
// (折叠属性挂在 frame div 上而不是 html 根,加前缀会失效)。
|
|
7
|
+
window.__ModuleLoader__.load({
|
|
8
|
+
// 登记 id 必须等于包名:0.2.0 主包的 client-modules 按「条目 id = 包名」找 factory
|
|
9
|
+
// (`graphRow(id /* package name */)`),id 写旧的无 scope 名字会 HTTP 200 却
|
|
10
|
+
// "loaded without registering",插件永不挂载(S16 装验实测)。
|
|
11
|
+
id: "@kira905/dsh-ecosystem-panel",
|
|
12
|
+
factory: (require) => {
|
|
13
|
+
var module = { exports: {} };
|
|
14
|
+
var exports = module.exports;
|
|
15
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
16
|
+
//#region consts
|
|
17
|
+
const ENTRY_ATTR = "data-dsh-ecosystem-entry";
|
|
18
|
+
const ENTRY_SELECTOR = "[" + ENTRY_ATTR + "]";
|
|
19
|
+
const PANEL_ATTR = "data-dsh-ecosystem-panel";
|
|
20
|
+
const API = "/api/dsh-ecosystem-panel/state";
|
|
21
|
+
const inject = [];
|
|
22
|
+
// 面板图标(grid 仪表盘,16px 描边风格,对齐 shell 18px 导航图标视觉)
|
|
23
|
+
const ICON = "<svg viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"2\" width=\"5.2\" height=\"5.2\" rx=\"1\"/><rect x=\"8.8\" y=\"2\" width=\"5.2\" height=\"5.2\" rx=\"1\"/><rect x=\"2\" y=\"8.8\" width=\"5.2\" height=\"5.2\" rx=\"1\"/><rect x=\"8.8\" y=\"8.8\" width=\"5.2\" height=\"5.2\" rx=\"1\"/></svg>";
|
|
24
|
+
const CSS_ID = "dsh-ecosystem-panel-css";
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region css
|
|
27
|
+
const CSS = `
|
|
28
|
+
[${ENTRY_ATTR}]{flex:none}
|
|
29
|
+
[${ENTRY_ATTR}] .ep-ecosystem-label{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
30
|
+
/* svg 裸放时 block 化:消除行内 baseline 造成的视觉偏上(居中问题) */
|
|
31
|
+
[${ENTRY_ATTR}] svg{display:block}
|
|
32
|
+
/* 折叠态:data-sidebar-collapsed 挂在 AppFrame frame div(非 html!),
|
|
33
|
+
必须无前缀匹配(加 html[...] 前缀会失效、折叠时文字仍然显示) */
|
|
34
|
+
[data-sidebar-collapsed] [${ENTRY_ATTR}] .ep-ecosystem-label{display:none}
|
|
35
|
+
/* 下区按钮跟「设置」齿轮一样:打开面板不给按钮加高亮(齿轮开设置也无高亮)。
|
|
36
|
+
data-active 属性保留(语义/未来用)但不给视觉,避免出现不居中的选中圈 */
|
|
37
|
+
[${PANEL_ATTR}]{position:fixed;inset:0;z-index:1400;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.32);backdrop-filter:blur(2px);font-size:14px;line-height:1.5;color:var(--dsw-alias-label-primary,#e6e6e6)}
|
|
38
|
+
[${PANEL_ATTR}] .ep-card{box-sizing:border-box;width:min(780px,calc(100vw - 48px));max-height:min(84vh,900px);display:flex;flex-direction:column;background:var(--dsw-alias-bg-layer-3,#1d1f24);border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.12));border-radius:14px;box-shadow:0 18px 60px rgba(0,0,0,.45);overflow:hidden}
|
|
39
|
+
[${PANEL_ATTR}] .ep-head{display:flex;align-items:center;gap:10px;padding:14px 18px;border-bottom:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.1))}
|
|
40
|
+
[${PANEL_ATTR}] .ep-title{font-size:15px;font-weight:600;flex:1;min-width:0}
|
|
41
|
+
[${PANEL_ATTR}] .ep-sub{font-size:12px;color:var(--dsw-alias-label-tertiary,#9aa0a8)}
|
|
42
|
+
[${PANEL_ATTR}] .ep-btn{appearance:none;cursor:pointer;background:var(--dsw-alias-bg-layer-2,rgba(128,128,128,.16));color:inherit;border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.12));border-radius:8px;padding:4px 12px;font:inherit;font-size:13px}
|
|
43
|
+
[${PANEL_ATTR}] .ep-btn:hover{background:var(--dsw-alias-bg-layer-4,rgba(128,128,128,.28))}
|
|
44
|
+
[${PANEL_ATTR}] .ep-tabs{display:flex;gap:2px;padding:8px 12px 0;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.07));overflow-x:auto;overflow-y:hidden;flex:none}
|
|
45
|
+
[${PANEL_ATTR}] .ep-tab{appearance:none;cursor:pointer;background:none;border:none;color:var(--dsw-alias-label-tertiary,#9aa0a8);font:inherit;font-size:12.5px;padding:5px 9px 8px;border-bottom:2px solid transparent;border-radius:6px 6px 0 0;white-space:nowrap;display:flex;align-items:center;gap:5px}
|
|
46
|
+
[${PANEL_ATTR}] .ep-tab:hover{color:var(--dsw-alias-label-primary,#e6e6e6);background:rgba(128,128,128,.08)}
|
|
47
|
+
[${PANEL_ATTR}] .ep-tab.ep-on{color:var(--dsw-alias-label-primary,#e6e6e6);border-bottom-color:var(--dsw-alias-accent,#5b9dff);font-weight:600}
|
|
48
|
+
[${PANEL_ATTR}] .ep-tdot{flex:none;width:6px;height:6px;border-radius:50%}
|
|
49
|
+
[${PANEL_ATTR}] .ep-body{overflow:auto;padding:12px 18px 16px}
|
|
50
|
+
[${PANEL_ATTR}] .ep-summary{display:flex;gap:8px;flex-wrap:wrap;margin:2px 0 10px}
|
|
51
|
+
[${PANEL_ATTR}] .ep-chip{font-size:12px;border-radius:999px;padding:2px 10px;background:var(--dsw-alias-bg-layer-2,rgba(128,128,128,.14))}
|
|
52
|
+
[${PANEL_ATTR}] .ep-group{margin:10px 0 2px;font-size:12px;font-weight:600;letter-spacing:.05em;color:var(--dsw-alias-label-tertiary,#9aa0a8);text-transform:uppercase}
|
|
53
|
+
[${PANEL_ATTR}] .ep-row{display:flex;align-items:flex-start;gap:8px;padding:5px 4px;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.06))}
|
|
54
|
+
[${PANEL_ATTR}] .ep-row:last-child{border-bottom:none}
|
|
55
|
+
[${PANEL_ATTR}] .ep-dot{flex:none;width:10px;height:10px;border-radius:50%;margin-top:5px}
|
|
56
|
+
[${PANEL_ATTR}] .ep-main{flex:1;min-width:0}
|
|
57
|
+
[${PANEL_ATTR}] .ep-name{font-weight:500;word-break:break-all}
|
|
58
|
+
[${PANEL_ATTR}] .ep-desc{font-size:12px;color:var(--dsw-alias-label-tertiary,#9aa0a8);margin-top:1px}
|
|
59
|
+
[${PANEL_ATTR}] .ep-meta{font-size:12px;color:var(--dsw-alias-label-tertiary,#9aa0a8)}
|
|
60
|
+
[${PANEL_ATTR}] .ep-badge{font-size:10.5px;border-radius:5px;padding:0 6px;margin-left:6px;vertical-align:1px;background:var(--dsw-alias-bg-module-platform,rgba(128,128,128,.18))}
|
|
61
|
+
[${PANEL_ATTR}] .ep-prob{font-size:12px;color:var(--dsw-alias-state-warn-primary,#e5b35c);margin-top:2px;word-break:break-all}
|
|
62
|
+
[${PANEL_ATTR}] .ep-note{font-size:12px;color:var(--dsw-alias-label-tertiary,#9aa0a8);margin-top:8px}
|
|
63
|
+
[${PANEL_ATTR}] .ep-err{color:var(--dsw-alias-label-error,#e06c6c);font-size:13px;padding:10px 0}
|
|
64
|
+
.dot-green{background:#3ecf6e}.dot-yellow{background:#e5b35c}.dot-red{background:#e5484d}.dot-unknown{background:#8a8a8a}`;
|
|
65
|
+
function injectCss() {
|
|
66
|
+
if (typeof document === "undefined") return;
|
|
67
|
+
if (document.querySelector("style[data-plugin-css=\"" + CSS_ID + "\"]") !== null) return;
|
|
68
|
+
const tag = document.createElement("style");
|
|
69
|
+
tag.setAttribute("data-plugin-css", CSS_ID);
|
|
70
|
+
tag.textContent = CSS;
|
|
71
|
+
document.head.appendChild(tag);
|
|
72
|
+
}
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region 侧边栏按钮(self-heal 注入)
|
|
75
|
+
function sidebarRoot() {
|
|
76
|
+
const column = document.querySelector("[data-pane=\"sidebar\"], [class*=\"sidebarCol\"]");
|
|
77
|
+
if (column === null) return void 0;
|
|
78
|
+
return column.querySelector("[class*=\"logoRow\"]")?.parentElement ?? column.firstElementChild;
|
|
79
|
+
}
|
|
80
|
+
/** 底部「设置」齿轮按钮(trigger)所在容器 + 按钮本身。 */
|
|
81
|
+
function settingsAnchor(root) {
|
|
82
|
+
if (!root) return { area: void 0, button: void 0 };
|
|
83
|
+
const foot = root.querySelector("[class*=\"footArea\"]");
|
|
84
|
+
const holder = foot !== null ? foot : root;
|
|
85
|
+
const area = holder.querySelector("[class*=\"settingsArea\"]");
|
|
86
|
+
if (area === null) return { area: void 0, button: void 0 };
|
|
87
|
+
const button = area.querySelector("button");
|
|
88
|
+
return { area, button };
|
|
89
|
+
}
|
|
90
|
+
function buildEntry(onToggle) {
|
|
91
|
+
const entry = document.createElement("button");
|
|
92
|
+
entry.type = "button";
|
|
93
|
+
entry.setAttribute(ENTRY_ATTR, "");
|
|
94
|
+
entry.setAttribute("aria-label", "生态面板(插件·技能状态)");
|
|
95
|
+
entry.setAttribute("title", "生态面板(插件·技能状态)");
|
|
96
|
+
// 图标直接平铺(与齿轮按钮同构:svg 裸放 + label span)——不要给 svg 套 span,
|
|
97
|
+
// 行内 span 会让 svg 走 baseline 对齐,图标在按钮里视觉偏上(实测 logo 会对不准
|
|
98
|
+
// 圆心)。若要 aria-hidden 用 svg 自带属性。
|
|
99
|
+
entry.innerHTML = ICON + "<span class=\"ep-ecosystem-label\">生态面板</span>";
|
|
100
|
+
entry.addEventListener("click", (e) => {
|
|
101
|
+
// 点完立刻 blur:否则 focus 残留,宿主按钮样式会持续画圆形选中标记
|
|
102
|
+
// (实测:面板关了标记还在,要再点一次才消)
|
|
103
|
+
entry.blur();
|
|
104
|
+
onToggle(e);
|
|
105
|
+
});
|
|
106
|
+
return entry;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* 把按钮插到 settingsArea 之前(= 齿轮正上方),视觉完全复制齿轮按钮
|
|
110
|
+
* className(展开=行按钮带文字,折叠=纯图标小方块——随宿主渲染自愈)。
|
|
111
|
+
*/
|
|
112
|
+
function placeEntry(root, entry, opts) {
|
|
113
|
+
if (root === null || typeof root.isConnected === "boolean" && !root.isConnected) return false;
|
|
114
|
+
const { area, button } = settingsAnchor(root);
|
|
115
|
+
if (button === void 0 || area === void 0) return false;
|
|
116
|
+
entry.className = button.className;
|
|
117
|
+
if (entry.parentElement !== area.parentElement) {
|
|
118
|
+
area.parentElement.insertBefore(entry, area);
|
|
119
|
+
}
|
|
120
|
+
if (opts.onPlaced) opts.onPlaced();
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
function mountEntry(onToggle, setActive) {
|
|
124
|
+
if (document.querySelector(ENTRY_SELECTOR) !== null) return () => {};
|
|
125
|
+
const entry = buildEntry(onToggle);
|
|
126
|
+
let root = void 0;
|
|
127
|
+
let placed = false;
|
|
128
|
+
let rootObserver = void 0;
|
|
129
|
+
const tryPlace = () => {
|
|
130
|
+
if (root !== void 0 && !root.isConnected) {
|
|
131
|
+
rootObserver?.disconnect();
|
|
132
|
+
root = void 0;
|
|
133
|
+
rootObserver = void 0;
|
|
134
|
+
placed = false;
|
|
135
|
+
}
|
|
136
|
+
if (placed) {
|
|
137
|
+
if (document.body.contains(entry)) return;
|
|
138
|
+
rootObserver?.disconnect();
|
|
139
|
+
root = void 0;
|
|
140
|
+
rootObserver = void 0;
|
|
141
|
+
placed = false;
|
|
142
|
+
}
|
|
143
|
+
root ??= sidebarRoot();
|
|
144
|
+
if (root === void 0) return;
|
|
145
|
+
if (placeEntry(root, entry, {})) {
|
|
146
|
+
placed = true;
|
|
147
|
+
if (rootObserver === void 0 && typeof MutationObserver !== "undefined") {
|
|
148
|
+
rootObserver = new MutationObserver(() => {
|
|
149
|
+
// shell 重渲染/折叠切换 → 重新对齐 className 与位置
|
|
150
|
+
if (root === void 0 || !root.isConnected) { placed = false; tryPlace(); return; }
|
|
151
|
+
if (!root.contains(entry)) placed = false;
|
|
152
|
+
if (!placed) tryPlace();
|
|
153
|
+
else placeEntry(root, entry, {});
|
|
154
|
+
});
|
|
155
|
+
rootObserver.observe(root, { childList: true, subtree: true });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
const waitObserver = new MutationObserver(() => { tryPlace(); });
|
|
160
|
+
waitObserver.observe(document.body, { childList: true, subtree: true });
|
|
161
|
+
tryPlace();
|
|
162
|
+
return () => {
|
|
163
|
+
waitObserver.disconnect();
|
|
164
|
+
rootObserver?.disconnect();
|
|
165
|
+
entry.remove();
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
//#endregion
|
|
169
|
+
//#region 面板
|
|
170
|
+
const MARK = { green: "🟢", yellow: "🟡", red: "🔴" };
|
|
171
|
+
/** 内置中性说明文案(服务端 meta.ui.note 可覆盖;见 lib/config.js 的 text 配置) */
|
|
172
|
+
const DEFAULT_NOTE = "只读总览(静态判定):bundle 加载看 boot client 条目;服务端 patch insert 插件实体在即绿。补丁健康 = 特征串扫描(清单由配置提供)。升级体检 = 本地实体版本 vs npm latest(24h 缓存)+ 规则表文案;本面板**只诊断不升级**,升级永远由人决定。解耦健康 = 本地包 R1(禁运行时静态 import 宿主主包符号)/ R2(dependencies · client.inject 归零)扫描(脚本路径由配置提供)。🔴 = 静态 import,宿主主包删符号时会启动崩。";
|
|
173
|
+
const KIND_LABEL = {
|
|
174
|
+
bundle: "npm/GitHub 插件", bundles: "npm/GitHub 插件",
|
|
175
|
+
insert: "patch insert", inserts: "patch insert",
|
|
176
|
+
patch: "补丁健康", patches: "补丁健康",
|
|
177
|
+
skill: "技能", skills: "技能",
|
|
178
|
+
decoupling: "解耦健康",
|
|
179
|
+
upgrade: "升级体检", upgrades: "升级体检"
|
|
180
|
+
};
|
|
181
|
+
// 文案可覆盖:服务端 meta.ui.tabs 可整体替换上表(见 lib/config.js 的 text 配置)
|
|
182
|
+
let TAB_LABELS = { ...KIND_LABEL };
|
|
183
|
+
|
|
184
|
+
/** 应用服务端下发的文案覆盖(没有下发就用内置中性文案) */
|
|
185
|
+
function applyUiText(st, refs) {
|
|
186
|
+
const ui = st && st.meta ? st.meta.ui : null;
|
|
187
|
+
if (!ui || typeof ui !== "object") return;
|
|
188
|
+
if (typeof ui.title === "string" && ui.title) refs.title.textContent = ui.title;
|
|
189
|
+
if (typeof ui.note === "string" && ui.note) refs.note.textContent = ui.note;
|
|
190
|
+
if (ui.tabs && typeof ui.tabs === "object") TAB_LABELS = { ...KIND_LABEL, ...ui.tabs };
|
|
191
|
+
}
|
|
192
|
+
function el(tag, className, text) {
|
|
193
|
+
const node = document.createElement(tag);
|
|
194
|
+
if (className) node.className = className;
|
|
195
|
+
if (text !== void 0) node.textContent = text;
|
|
196
|
+
return node;
|
|
197
|
+
}
|
|
198
|
+
function groupTitle(text) {
|
|
199
|
+
return el("div", "ep-group", text);
|
|
200
|
+
}
|
|
201
|
+
/** 渲染一个 item 行。中文简介:patch 用 title(=中文名);其余用服务端 description。 */
|
|
202
|
+
function renderRow(item) {
|
|
203
|
+
const row = el("div", "ep-row");
|
|
204
|
+
row.appendChild(el("span", "ep-dot dot-" + item.status));
|
|
205
|
+
const main = el("div", "ep-main");
|
|
206
|
+
const nameLine = el("div");
|
|
207
|
+
const displayName = item.title ?? item.name ?? item.id ?? "(unnamed)";
|
|
208
|
+
const name = el("span", "ep-name", displayName);
|
|
209
|
+
nameLine.appendChild(name);
|
|
210
|
+
if (item.source && !item.title) nameLine.appendChild(el("span", "ep-badge", item.source));
|
|
211
|
+
if (item.isCore) nameLine.appendChild(el("span", "ep-badge", "内置"));
|
|
212
|
+
main.appendChild(nameLine);
|
|
213
|
+
// 一句话中文简介(bundle/insert/skill = description;patch = title 已作名称,附 id 小注)
|
|
214
|
+
if (item.description) main.appendChild(el("div", "ep-desc", item.description));
|
|
215
|
+
const metas = [];
|
|
216
|
+
if (item.kind === "upgrade") {
|
|
217
|
+
if (item.local) metas.push("本地 " + item.local + (item.latest ? " → " + item.latest : ""));
|
|
218
|
+
if (item.verdictLabel) metas.push(item.verdictLabel);
|
|
219
|
+
if (item.patchCount > 0) metas.push("补丁 ×" + item.patchCount);
|
|
220
|
+
}
|
|
221
|
+
if (item.kind === "decoupling") {
|
|
222
|
+
metas.push("R1 静态 " + (item.r1Hard ?? 0) + " · 动态 " + (item.r1Soft ?? 0));
|
|
223
|
+
metas.push("deps " + (item.depCount ?? 0) + " · inject " + (item.injectCount ?? 0));
|
|
224
|
+
if (item.scope) metas.push("范围 " + item.scope);
|
|
225
|
+
if (item.version && item.version !== "n/a") metas.push("v" + item.version);
|
|
226
|
+
}
|
|
227
|
+
if (item.kind === "patch" && item.id) metas.push(item.id);
|
|
228
|
+
if (item.entityVersion) metas.push("实体 v" + item.entityVersion);
|
|
229
|
+
if (item.declared && typeof item.declared === "string") metas.push("声明 " + item.declared);
|
|
230
|
+
if (item.script) metas.push(item.script);
|
|
231
|
+
if (item.bootLoaded === true) metas.push("boot 已加载");
|
|
232
|
+
else if (item.bootLoaded === false) metas.push("boot 无 client 条目");
|
|
233
|
+
if (item.healthy === false) metas.push("特征丢失");
|
|
234
|
+
if (metas.length > 0) main.appendChild(el("div", "ep-meta", metas.join(" · ")));
|
|
235
|
+
for (const p of item.problems ?? []) main.appendChild(el("div", "ep-prob", "⚠ " + p));
|
|
236
|
+
row.appendChild(main);
|
|
237
|
+
return row;
|
|
238
|
+
}
|
|
239
|
+
function appendGroup(container, title, items) {
|
|
240
|
+
if (items.length === 0) return;
|
|
241
|
+
container.appendChild(groupTitle(title));
|
|
242
|
+
for (const it of items) container.appendChild(renderRow(it));
|
|
243
|
+
}
|
|
244
|
+
/** 面板主体。返回 { root, refresh, close } */
|
|
245
|
+
function mountPanel(onDismiss) {
|
|
246
|
+
const overlay = el("div");
|
|
247
|
+
overlay.setAttribute(PANEL_ATTR, "");
|
|
248
|
+
const card = el("div", "ep-card");
|
|
249
|
+
const head = el("div", "ep-head");
|
|
250
|
+
const titleEl = el("div", "ep-title", "插件 · 技能生态状态"); head.appendChild(titleEl);
|
|
251
|
+
const refreshBtn = el("button", "ep-btn", "刷新");
|
|
252
|
+
const closeBtn = el("button", "ep-btn", "关闭");
|
|
253
|
+
head.appendChild(refreshBtn);
|
|
254
|
+
head.appendChild(closeBtn);
|
|
255
|
+
card.appendChild(head);
|
|
256
|
+
const body = el("div", "ep-body");
|
|
257
|
+
card.appendChild(body);
|
|
258
|
+
// Tab 栏:每类各占一张标签卡(内容多了以后全堆一列会很难找)
|
|
259
|
+
const TAB_KEYS = ["bundles", "inserts", "patches", "skills", "decoupling", "upgrades"];
|
|
260
|
+
const tabsRow = el("div", "ep-tabs");
|
|
261
|
+
card.insertBefore(tabsRow, body);
|
|
262
|
+
const tabBtns = {};
|
|
263
|
+
let currentTab = TAB_KEYS[0];
|
|
264
|
+
// 切换 Tab:所有页都已渲染,切 Tab 只切 display(DOM 量级 ~70 行,够轻,不必重渲染)
|
|
265
|
+
function switchTab(key) {
|
|
266
|
+
currentTab = key;
|
|
267
|
+
for (const k of TAB_KEYS) {
|
|
268
|
+
tabBtns[k].classList.toggle("ep-on", k === key);
|
|
269
|
+
groups[k].style.display = k === key ? "" : "none";
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
for (const key of TAB_KEYS) {
|
|
273
|
+
const b = el("button", "ep-tab");
|
|
274
|
+
b.type = "button";
|
|
275
|
+
b.addEventListener("click", () => switchTab(key));
|
|
276
|
+
tabBtns[key] = b;
|
|
277
|
+
tabsRow.appendChild(b);
|
|
278
|
+
}
|
|
279
|
+
overlay.appendChild(card);
|
|
280
|
+
// 状态子标题(异步更新)
|
|
281
|
+
const summaryRow = el("div", "ep-summary");
|
|
282
|
+
const errRow = el("div", "ep-err");
|
|
283
|
+
errRow.style.display = "none";
|
|
284
|
+
body.appendChild(summaryRow);
|
|
285
|
+
body.appendChild(errRow);
|
|
286
|
+
// 分组容器:每类一个面板页,由 switchTab 决定显示哪个
|
|
287
|
+
const groups = {};
|
|
288
|
+
for (const key of TAB_KEYS) {
|
|
289
|
+
const g = el("div");
|
|
290
|
+
g.style.display = "none";
|
|
291
|
+
body.appendChild(g);
|
|
292
|
+
groups[key] = g;
|
|
293
|
+
}
|
|
294
|
+
const note = el("div", "ep-note", DEFAULT_NOTE);
|
|
295
|
+
body.appendChild(note);
|
|
296
|
+
let lastState = null;
|
|
297
|
+
const render = (st) => {
|
|
298
|
+
const s = st?.summary;
|
|
299
|
+
summaryRow.replaceChildren();
|
|
300
|
+
if (!s) {
|
|
301
|
+
summaryRow.appendChild(el("span", "ep-chip", "无数据"));
|
|
302
|
+
} else {
|
|
303
|
+
summaryRow.appendChild(el("span", "ep-chip", "🟢 " + s.green + " 正常"));
|
|
304
|
+
summaryRow.appendChild(el("span", "ep-chip", "🟡 " + s.yellow + " 风险"));
|
|
305
|
+
summaryRow.appendChild(el("span", "ep-chip", "🔴 " + s.red + " 异常"));
|
|
306
|
+
summaryRow.appendChild(el("span", "ep-chip", "共 " + s.total + " 项"));
|
|
307
|
+
const boot = st?.meta?.bootEntries;
|
|
308
|
+
const up = st?.upgradesMeta?.summary;
|
|
309
|
+
if (up && up.total > 0) summaryRow.appendChild(el("span", "ep-chip", "升级体检 🟢" + up.green + " 🟡" + up.yellow + " 🔴" + up.red + (up.unknown > 0 ? " ⬜" + up.unknown + "不可用" : "")));
|
|
310
|
+
const dec = st?.decouplingMeta?.summary;
|
|
311
|
+
if (dec && dec.total > 0) summaryRow.appendChild(el("span", "ep-chip", "解耦健康 🟢" + dec.green + " 🟡" + dec.yellow + " 🔴" + dec.red));
|
|
312
|
+
if (typeof boot === "number") summaryRow.appendChild(el("span", "ep-chip", "boot client " + boot + " 个"));
|
|
313
|
+
if (st?.meta?.generatedAt) summaryRow.appendChild(el("span", "ep-chip", "更新于 " + new Date(st.meta.generatedAt).toLocaleTimeString()));
|
|
314
|
+
}
|
|
315
|
+
errRow.style.display = "none";
|
|
316
|
+
for (const key of TAB_KEYS) {
|
|
317
|
+
const list = st?.groups?.[key];
|
|
318
|
+
const n = Array.isArray(list) ? list.length : 0;
|
|
319
|
+
// Tab 按钮:状态点(红>黄>绿)+ 类名 + 计数 —— 哪个类目有问题一眼可见
|
|
320
|
+
const worst = n === 0 ? "" : (list.some((x) => x.status === "red") ? "red" : (list.some((x) => x.status === "yellow") ? "yellow" : "green"));
|
|
321
|
+
const b = tabBtns[key];
|
|
322
|
+
b.replaceChildren();
|
|
323
|
+
if (worst) b.appendChild(el("span", "ep-tdot dot-" + worst));
|
|
324
|
+
b.appendChild(el("span", undefined, TAB_LABELS[key] + (n > 0 ? " " + n : "")));
|
|
325
|
+
// 面板页:照常渲染,显示与否交 switchTab
|
|
326
|
+
const g = groups[key];
|
|
327
|
+
g.replaceChildren();
|
|
328
|
+
if (n > 0) appendGroup(g, TAB_LABELS[key] + "(" + n + ")", list);
|
|
329
|
+
}
|
|
330
|
+
switchTab(currentTab);
|
|
331
|
+
};
|
|
332
|
+
const refresh = async () => {
|
|
333
|
+
refreshBtn.disabled = true;
|
|
334
|
+
refreshBtn.textContent = "刷新中…";
|
|
335
|
+
errRow.style.display = "none";
|
|
336
|
+
try {
|
|
337
|
+
const res = await fetch(API, { cache: "no-store" });
|
|
338
|
+
const data = await res.json();
|
|
339
|
+
if (!res.ok) throw new Error(data?.error ?? ("HTTP " + res.status));
|
|
340
|
+
lastState = data;
|
|
341
|
+
applyUiText(data, { title: titleEl, note }); render(data);
|
|
342
|
+
} catch (e) {
|
|
343
|
+
errRow.textContent = "加载失败:" + (e?.message ?? e);
|
|
344
|
+
errRow.style.display = "";
|
|
345
|
+
} finally {
|
|
346
|
+
refreshBtn.disabled = false;
|
|
347
|
+
refreshBtn.textContent = "刷新";
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
/** 统一关闭:移除 overlay + 解绑键盘 + 通知 apply 清面板状态(data-active)。
|
|
351
|
+
* 2026-09-08 血泪:此前 ×/遮罩/Esc 只 overlay.remove(),apply 的 panel 与
|
|
352
|
+
* data-active 不知情 → 关闭后按钮深色圆形选中标记残留,要点第二次按钮才清。 */
|
|
353
|
+
const onKey = (e) => { if (e.key === "Escape") fireClose(); };
|
|
354
|
+
const fireClose = () => {
|
|
355
|
+
document.removeEventListener("keydown", onKey);
|
|
356
|
+
overlay.remove();
|
|
357
|
+
onDismiss?.();
|
|
358
|
+
};
|
|
359
|
+
refreshBtn.addEventListener("click", refresh);
|
|
360
|
+
closeBtn.addEventListener("click", fireClose);
|
|
361
|
+
overlay.addEventListener("click", (e) => { if (e.target === overlay) fireClose(); });
|
|
362
|
+
document.addEventListener("keydown", onKey);
|
|
363
|
+
refresh();
|
|
364
|
+
const cleanup = () => {
|
|
365
|
+
document.removeEventListener("keydown", onKey);
|
|
366
|
+
overlay.remove();
|
|
367
|
+
};
|
|
368
|
+
return { root: overlay, refresh, close: fireClose, cleanup };
|
|
369
|
+
}
|
|
370
|
+
//#endregion
|
|
371
|
+
//#region apply
|
|
372
|
+
function apply(ctx) {
|
|
373
|
+
ctx.effect(() => {
|
|
374
|
+
injectCss();
|
|
375
|
+
let panel = null; // 当前面板实例({root, close})
|
|
376
|
+
let active = false;
|
|
377
|
+
const setActive = (v) => {
|
|
378
|
+
active = v;
|
|
379
|
+
const entry = document.querySelector(ENTRY_SELECTOR);
|
|
380
|
+
if (entry) {
|
|
381
|
+
if (v) entry.setAttribute("data-active", "true");
|
|
382
|
+
else entry.removeAttribute("data-active");
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
const onDismiss = () => {
|
|
386
|
+
// 面板任何方式关闭(×/遮罩/Esc/按钮 toggle)都走这里清状态
|
|
387
|
+
panel = null;
|
|
388
|
+
setActive(false);
|
|
389
|
+
};
|
|
390
|
+
const close = () => {
|
|
391
|
+
if (panel === null) return;
|
|
392
|
+
const p = panel;
|
|
393
|
+
p.close(); // fireClose → remove + onDismiss
|
|
394
|
+
onDismiss(); // 保险(fireClose 已调,幂等)
|
|
395
|
+
};
|
|
396
|
+
const open = () => {
|
|
397
|
+
if (panel !== null) return;
|
|
398
|
+
const ui = mountPanel(onDismiss);
|
|
399
|
+
document.body.appendChild(ui.root);
|
|
400
|
+
panel = ui;
|
|
401
|
+
setActive(true);
|
|
402
|
+
};
|
|
403
|
+
const toggle = () => { if (panel !== null) close(); else open(); };
|
|
404
|
+
const disposers = [];
|
|
405
|
+
disposers.push(mountEntry(toggle, setActive));
|
|
406
|
+
return () => {
|
|
407
|
+
for (const d of disposers.splice(0)) { try { d(); } catch {} }
|
|
408
|
+
close();
|
|
409
|
+
};
|
|
410
|
+
}, "ecosystem-panel: ui");
|
|
411
|
+
}
|
|
412
|
+
//#endregion
|
|
413
|
+
exports.apply = apply;
|
|
414
|
+
exports.inject = inject;
|
|
415
|
+
return module.exports;
|
|
416
|
+
}
|
|
417
|
+
});
|