@sidady/dsh-steward 0.0.0-stage → 0.9.14
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/ACCEPTANCE.md +204 -0
- package/LICENSE +21 -0
- package/README.md +417 -2
- package/cordis.patch.yml +9 -0
- package/icon.svg +4 -0
- package/lib/actions.js +534 -0
- package/lib/brain.js +167 -0
- package/lib/client.js +1605 -0
- package/lib/index.js +827 -0
- package/lib/intent.js +298 -0
- package/lib/models.js +212 -0
- package/lib/report.js +1696 -0
- package/lib/settings.js +286 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +63 -4
- package/smoke.mjs +1432 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,1605 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-steward · client entry — v0.9.14 (model-mode conversational entry + L1 briefing;
|
|
3
|
+
* ChatBar rebuilt as a mature chat composer: one rounded input bar, mode chip on the
|
|
4
|
+
* left, round send button on the right, settings panel below the bar;
|
|
5
|
+
* v0.7.5 collapse: a collapsed group lists EVERY peer as a two-line summary row
|
|
6
|
+
* instead of showing only the first two full cards;
|
|
7
|
+
* tabbed IA, sticky KPI header, actions on demand)
|
|
8
|
+
*
|
|
9
|
+
* 事故二分定案沿用(红线):不注入 <style>、不碰 document.head、不用 locale、
|
|
10
|
+
* 样式全内联、main/右侧栏根节点自声明滚动、apply() 整体 try/catch。
|
|
11
|
+
* 注:「挂载不自动扫描」是 v0.1.1 排障期的临时限制,已在 v0.3.6 解除
|
|
12
|
+
* (无缓存→挂载即体检一次;缓存 >10 分钟后台刷新),见文件内 scan 相关注释。
|
|
13
|
+
* v0.3.1 信息架构(m01771,参考 dsh-context 的骨架不搬其构建链):
|
|
14
|
+
* - sticky 顶栏:级别 + 计数 + 体检按钮,永不滚丢;
|
|
15
|
+
* - 三 Tab:体检总览 / 动作中心 / 审计趋势——动作不再垫底,一步直达;
|
|
16
|
+
* - finding → 动作直达按钮(dsh-context 没有的一步);
|
|
17
|
+
* - 动作卡默认收成一行,展开才出计划/指纹/确认;
|
|
18
|
+
* - 新增右侧栏入口 sidebar.right.pane.tab(契约取自 dsh-context 源码,全程 fail-soft)。
|
|
19
|
+
*/
|
|
20
|
+
window.__ModuleLoader__.load({
|
|
21
|
+
// v0.9.2:id 必须与包名一致。包已改成 scoped(@sidady/dsh-steward),
|
|
22
|
+
// 这里若仍写裸名 'dsh-steward',一旦 node_modules 下同时存在裸名与 scoped
|
|
23
|
+
// 两个目录,宿主会插两次同一个 bundle,渲染端直接抛
|
|
24
|
+
// "duplicate factory registration for dsh-steward" 让 web boot 挂掉。
|
|
25
|
+
id: '@sidady/dsh-steward',
|
|
26
|
+
factory(require) {
|
|
27
|
+
// >>> dsh-design-baseline v1.1.0 sha256 76858a2ccf04445daf6a05a19bd8903ff09a8b34789dde945e4ac0cbd47a0a7a
|
|
28
|
+
// Generated by dsh-design-baseline/sync.mjs — edit baseline.css, run build.mjs,
|
|
29
|
+
// then sync.mjs. Everything between these two markers is overwritten.
|
|
30
|
+
const THEME = {
|
|
31
|
+
"text": "var(--dsw-alias-label-primary, #0f1115)",
|
|
32
|
+
"textSecondary": "var(--dsw-alias-label-secondary, #61666b)",
|
|
33
|
+
"textTertiary": "var(--dsw-alias-label-tertiary, #81858c)",
|
|
34
|
+
"textCaption": "var(--dsw-alias-label-caption, #adb2b8)",
|
|
35
|
+
"surface": "var(--dsw-alias-bg-layer-1, #ffffff)",
|
|
36
|
+
"surface2": "var(--dsw-alias-bg-layer-2, #fafafa)",
|
|
37
|
+
"surface3": "var(--dsw-alias-bg-layer-3, #f5f5f5)",
|
|
38
|
+
"card": "var(--dsw-alias-bg-module-platform, #f9fafb)",
|
|
39
|
+
"track": "var(--dsw-alias-bg-layer-2, #f5f5f5)",
|
|
40
|
+
"line": "var(--dsw-alias-border-l1, rgba(0,0,0,0.04))",
|
|
41
|
+
"lineStrong": "var(--dsw-alias-border-l2, rgba(0,0,0,0.10))",
|
|
42
|
+
"hover": "var(--dsw-alias-interactive-bg-hover, rgba(38,49,72,0.06))",
|
|
43
|
+
"invertedBg": "var(--dsw-alias-label-primary, #0f1115)",
|
|
44
|
+
"invertedFg": "var(--dsw-alias-label-primary-foreground, #ffffff)",
|
|
45
|
+
"primaryFill": "var(--dsw-alias-button-primary-fill, #3b82f6)",
|
|
46
|
+
"primaryFg": "var(--dsw-alias-label-primary-foreground, #ffffff)",
|
|
47
|
+
"accentStability": "#22c55e",
|
|
48
|
+
"accentSpeed": "#3b82f6",
|
|
49
|
+
"accentIntelligence": "#a855f7",
|
|
50
|
+
"accentWarn": "#f59e0b",
|
|
51
|
+
"accentError": "#f43f5e",
|
|
52
|
+
"accentInfo": "#06b6d4",
|
|
53
|
+
"radius": "12px",
|
|
54
|
+
"radiusSm": "8px",
|
|
55
|
+
"radiusPill": "9999px",
|
|
56
|
+
"gap": "12px"
|
|
57
|
+
}
|
|
58
|
+
const wash = (accent) => `color-mix(in srgb, ${accent} 15%, transparent)`
|
|
59
|
+
// <<< dsh-design-baseline
|
|
60
|
+
const React = require('react');
|
|
61
|
+
const h = React.createElement;
|
|
62
|
+
const { useCallback, useEffect, useRef, useState } = React;
|
|
63
|
+
|
|
64
|
+
const PANEL_ID = 'dsh-steward';
|
|
65
|
+
const ROUTE = '/api/dsh-steward/';
|
|
66
|
+
|
|
67
|
+
// Colours come from the shared baseline block inlined above. It is the same
|
|
68
|
+
// palette the FreeLLMAPI panel paints with, so this panel follows the host
|
|
69
|
+
// theme without owning a private set of literals.
|
|
70
|
+
const C = {
|
|
71
|
+
text: THEME.text,
|
|
72
|
+
sub: THEME.textSecondary,
|
|
73
|
+
faint: THEME.textTertiary,
|
|
74
|
+
line: THEME.lineStrong,
|
|
75
|
+
fill: THEME.hover,
|
|
76
|
+
bg: THEME.surface,
|
|
77
|
+
// Selected surfaces invert (the baseline rule: selection must not be a
|
|
78
|
+
// colour, or colour stops meaning data).
|
|
79
|
+
solid: THEME.invertedBg,
|
|
80
|
+
on: THEME.invertedFg,
|
|
81
|
+
// The six house accents. A severity colour is data, so it comes from the
|
|
82
|
+
// same set the router dashboard uses rather than a private palette.
|
|
83
|
+
critical: THEME.accentError,
|
|
84
|
+
warning: THEME.accentWarn,
|
|
85
|
+
info: THEME.accentInfo,
|
|
86
|
+
ok: THEME.accentStability,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** A share of an accent, for borders, rings and washes other than the 15%. */
|
|
90
|
+
const mix = (accent, pct) => `color-mix(in srgb, ${accent} ${pct}%, transparent)`;
|
|
91
|
+
|
|
92
|
+
const SEV = {
|
|
93
|
+
critical: { label: '严重', color: C.critical },
|
|
94
|
+
warning: { label: '留意', color: C.warning },
|
|
95
|
+
info: { label: '提示', color: C.info },
|
|
96
|
+
ok: { label: '正常', color: C.ok },
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// finding → 动作直达映射(点按钮 = 切到动作中心并展开该动作)
|
|
100
|
+
const ACTION_FOR = {
|
|
101
|
+
'data-at-risk': 'backup',
|
|
102
|
+
'boot-crash-latest': 'disable',
|
|
103
|
+
'disk-advice': 'clean',
|
|
104
|
+
'knowledge': 'knowledge',
|
|
105
|
+
'model-health': 'model-default',
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// v0.9.8: every request used to be able to hang forever. The server is allowed
|
|
109
|
+
// to take minutes (12 model probes at 45 s each, now 3 at a time), and a fetch
|
|
110
|
+
// with no AbortSignal means the panel just spins with no explanation — the user
|
|
111
|
+
// cannot tell "still working" from "already dead". Fast actions get 30 s; the
|
|
112
|
+
// three slow ones get a budget generous enough for the server's own worst case.
|
|
113
|
+
// Default probe budget is 12 models / 3 at a time * 45 s = 180 s worst case;
|
|
114
|
+
// 240 s covers that plus the report's own collectors. Raising model.maxTotal
|
|
115
|
+
// past ~20 can outlast this budget, and the timeout message says so instead of
|
|
116
|
+
// pretending the request failed.
|
|
117
|
+
const SLOW_ACTIONS = { report: 240000, 'model-scan': 240000, ask: 120000 };
|
|
118
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
119
|
+
function timeoutFor(action) {
|
|
120
|
+
return SLOW_ACTIONS[action] || DEFAULT_TIMEOUT_MS;
|
|
121
|
+
}
|
|
122
|
+
async function call(action, extra, attempt) {
|
|
123
|
+
const tries = typeof attempt === 'number' ? attempt : 0;
|
|
124
|
+
const name = action || 'report';
|
|
125
|
+
const budget = timeoutFor(name);
|
|
126
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
127
|
+
let timedOut = false;
|
|
128
|
+
const timer = controller
|
|
129
|
+
? setTimeout(() => { timedOut = true; try { controller.abort(); } catch { /* noop */ } }, budget)
|
|
130
|
+
: null;
|
|
131
|
+
let response;
|
|
132
|
+
try {
|
|
133
|
+
response = await fetch(ROUTE, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: { 'content-type': 'application/json' },
|
|
136
|
+
body: JSON.stringify({ action: name, ...(extra || {}) }),
|
|
137
|
+
...(controller ? { signal: controller.signal } : {}),
|
|
138
|
+
});
|
|
139
|
+
} catch (err) {
|
|
140
|
+
if (timedOut) {
|
|
141
|
+
throw new Error('等了 ' + Math.round(budget / 1000) + ' 秒还没回来。'
|
|
142
|
+
+ '服务端可能还在跑(模型巡检本身就是慢活),这次先不等了——稍后回到这一页再试一次。');
|
|
143
|
+
}
|
|
144
|
+
throw new Error((err && err.message) ? err.message : '请求失败');
|
|
145
|
+
} finally {
|
|
146
|
+
if (timer) clearTimeout(timer);
|
|
147
|
+
}
|
|
148
|
+
let data;
|
|
149
|
+
try { data = await response.json(); } catch { data = null; }
|
|
150
|
+
// v0.9.13: a 404 on our OWN route is a different failure from a 404 the
|
|
151
|
+
// server sent on purpose. It means this plugin's fiber is loaded (the
|
|
152
|
+
// panel rendered) but its route is not mounted. v0.9.14: there are TWO
|
|
153
|
+
// causes and the earlier wording only named the first — a host hot-reload
|
|
154
|
+
// disposes the old fiber, AND a cold boot can have webServer apply after
|
|
155
|
+
// this plugin, which is what the deferred registration now handles.
|
|
156
|
+
if (response.status === 404 && tries === 0) {
|
|
157
|
+
await new Promise((r) => setTimeout(r, 700));
|
|
158
|
+
return call(action, extra, tries + 1);
|
|
159
|
+
}
|
|
160
|
+
if (response.status === 404) {
|
|
161
|
+
throw new Error('接口没有挂上(HTTP 404)——面板加载了,但 /api/dsh-steward/ 这段路由没注册。'
|
|
162
|
+
+ '两种常见原因:宿主热重载(改了插件文件,旧路由被销毁、新的没接上),'
|
|
163
|
+
+ '或冷启动时 webServer 比插件晚就绪。'
|
|
164
|
+
+ '在设置里把这个插件停用再启用一次通常就能恢复;也可以重启 DSH Web。'
|
|
165
|
+
+ '恢复后 ping 会带 wiring.route,值是 registered 就说明路由真的挂上了。');
|
|
166
|
+
}
|
|
167
|
+
if (!response.ok || !data || data.ok !== true) {
|
|
168
|
+
throw new Error((data && data.error) || 'HTTP ' + response.status);
|
|
169
|
+
}
|
|
170
|
+
return data;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function PanelIcon(props) {
|
|
174
|
+
const size = (props && props.size) || 18;
|
|
175
|
+
// v0.4 主动出口兜底:现行 critical 报告存在时,侧栏图标挂红点
|
|
176
|
+
// (systemPrompt 注入不可用时的被动提醒;cache 在渲染期已初始化,TDZ 安全)。
|
|
177
|
+
let alert = false;
|
|
178
|
+
try { alert = !!(cache.report && cache.report.summary && cache.report.summary.level === 'critical'); } catch { alert = false; }
|
|
179
|
+
return h('span', { style: { position: 'relative', display: 'inline-flex' } },
|
|
180
|
+
h('svg', {
|
|
181
|
+
width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
|
|
182
|
+
stroke: 'currentColor', 'stroke-width': 1.6, 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
|
|
183
|
+
},
|
|
184
|
+
h('path', { d: 'M12 2.6l7.4 2.8v5.9c0 4.7-3.1 8.2-7.4 10.1-4.3-1.9-7.4-5.4-7.4-10.1V5.4z' }),
|
|
185
|
+
h('path', { d: 'M7 12.2h2.5l1.4-2.8 2 5.4 1.3-2.6H17' }),
|
|
186
|
+
),
|
|
187
|
+
alert ? h('span', {
|
|
188
|
+
style: { position: 'absolute', top: -1, right: -1, width: 7, height: 7, borderRadius: '50%', background: C.critical, boxShadow: '0 0 0 1.5px ' + THEME.surface },
|
|
189
|
+
title: '体检发现严重问题',
|
|
190
|
+
}) : null);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function Badge(props) {
|
|
194
|
+
return h('span', {
|
|
195
|
+
style: {
|
|
196
|
+
display: 'inline-block', fontSize: 10, lineHeight: '16px', padding: '0 6px',
|
|
197
|
+
borderRadius: 8, border: '1px solid ' + props.color, color: props.color,
|
|
198
|
+
marginRight: 8, verticalAlign: 'middle', whiteSpace: 'nowrap',
|
|
199
|
+
},
|
|
200
|
+
}, props.children);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function Btn(props) {
|
|
204
|
+
return h('button', {
|
|
205
|
+
onClick: props.onClick,
|
|
206
|
+
disabled: props.disabled,
|
|
207
|
+
style: {
|
|
208
|
+
cursor: props.disabled ? 'default' : 'pointer', padding: '5px 14px', borderRadius: 6, fontSize: 12,
|
|
209
|
+
// baseline `.dsb-btn--primary`: emphasis is inverted, not coloured —
|
|
210
|
+
// a green primary button reads as "this is healthy", which is data.
|
|
211
|
+
border: '1px solid ' + (props.primary ? 'transparent' : C.line),
|
|
212
|
+
background: props.primary && !props.disabled ? C.solid : C.fill,
|
|
213
|
+
color: props.tone || (props.primary && !props.disabled ? C.on : C.text), marginRight: 8,
|
|
214
|
+
},
|
|
215
|
+
}, props.children);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function TinyBtn(props) {
|
|
219
|
+
return h('button', {
|
|
220
|
+
onClick: props.onClick,
|
|
221
|
+
style: {
|
|
222
|
+
cursor: 'pointer', padding: '2px 9px', borderRadius: 6, fontSize: 11,
|
|
223
|
+
border: '1px solid ' + C.line, background: 'transparent', color: props.tone || C.sub,
|
|
224
|
+
},
|
|
225
|
+
}, props.children);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function Finding(props) {
|
|
229
|
+
const f = props.finding;
|
|
230
|
+
const sev = SEV[f.severity] || SEV.info;
|
|
231
|
+
|
|
232
|
+
// 折叠形态(v0.7.5):每组默认只把这些并列项压成「一条两行」的摘要行,
|
|
233
|
+
// 全部并列项都在、都点得开,但不再是一张张占满屏的高卡片。
|
|
234
|
+
// 之前的问题:折叠只按「露出前 2 张完整卡」来算,等于漏掉了其余项,
|
|
235
|
+
// 而露出的那两张又是全尺寸卡片——既看不全又占地方。
|
|
236
|
+
if (props.compact) {
|
|
237
|
+
return h('div', {
|
|
238
|
+
onClick: props.onToggle,
|
|
239
|
+
title: '点击展开整组的完整说明',
|
|
240
|
+
style: {
|
|
241
|
+
display: 'flex', flexDirection: 'column', gap: 2, cursor: 'pointer',
|
|
242
|
+
padding: '7px 10px', borderRadius: 6,
|
|
243
|
+
border: '1px solid ' + C.line, borderLeft: '3px solid ' + (f.muted ? C.faint : sev.color),
|
|
244
|
+
background: C.fill, opacity: f.muted ? 0.55 : 1,
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
h('div', { style: { display: 'flex', alignItems: 'baseline', gap: 6, minWidth: 0 } },
|
|
248
|
+
h('span', { style: { flex: '0 0 auto', fontSize: 10.5, fontWeight: 600, color: f.muted ? C.faint : sev.color } },
|
|
249
|
+
f.muted ? '静音' : sev.label),
|
|
250
|
+
typeof f.score === 'number' ? h('span', { style: { flex: '0 0 auto', fontSize: 10.5, color: C.faint } }, '★' + f.score) : null,
|
|
251
|
+
h('span', { style: { flex: '1 1 auto', minWidth: 0, fontSize: 12.5, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
|
|
252
|
+
f.title || '')),
|
|
253
|
+
f.detail ? h('div', {
|
|
254
|
+
style: { fontSize: 11.5, color: C.sub, lineHeight: 1.5, overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' },
|
|
255
|
+
}, f.detail) : null,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return h('div', {
|
|
260
|
+
style: {
|
|
261
|
+
border: '1px solid ' + C.line, borderLeft: '3px solid ' + (f.muted ? C.faint : sev.color),
|
|
262
|
+
borderRadius: 8, padding: '10px 12px', margin: '0 0 10px',
|
|
263
|
+
background: C.fill, opacity: f.muted ? 0.55 : 1,
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
h('div', { style: { fontSize: 13, fontWeight: 600, marginBottom: 4 } },
|
|
267
|
+
f.muted ? h(Badge, { color: C.faint }, '已静音') : h(Badge, { color: sev.color }, sev.label),
|
|
268
|
+
typeof f.score === 'number' ? h('span', {
|
|
269
|
+
title: (f.scoreReasons || []).join(';') || '评分构成见体检报告',
|
|
270
|
+
style: { fontSize: 11, color: C.faint, marginLeft: 6, cursor: 'help' },
|
|
271
|
+
}, '★' + f.score) : null,
|
|
272
|
+
f.title || ''),
|
|
273
|
+
h('div', { style: { fontSize: 12.5, color: C.text, lineHeight: 1.65, whiteSpace: 'pre-wrap' } }, f.detail || ''),
|
|
274
|
+
f.evidence ? h('div', {
|
|
275
|
+
style: { fontSize: 11, color: C.faint, marginTop: 6, wordBreak: 'break-all' },
|
|
276
|
+
}, '证据 · ' + f.evidence) : null,
|
|
277
|
+
f.recommendation ? h('div', {
|
|
278
|
+
style: { fontSize: 12, marginTop: 6, paddingLeft: 8, borderLeft: '2px solid ' + C.line, color: C.text, whiteSpace: 'pre-wrap' },
|
|
279
|
+
}, '建议 · ' + f.recommendation) : null,
|
|
280
|
+
f.recall ? h('div', {
|
|
281
|
+
style: { fontSize: 11.5, marginTop: 6, padding: '5px 8px', borderRadius: 6, background: C.fill, border: '1px dashed ' + C.info, color: C.sub, whiteSpace: 'pre-wrap' },
|
|
282
|
+
}, '🧠 上次也出现过 · ' + localStamp(f.recall.at) + ':「' + (f.recall.headline || '') + '」(当时严重 ' + ((f.recall.counts && f.recall.counts.critical) || 0) + ' / 留意 ' + ((f.recall.counts && f.recall.counts.warning) || 0) + '),详情见「审计趋势」的记录)') : null,
|
|
283
|
+
h('div', { style: { marginTop: 8, display: 'flex', gap: 0, flexWrap: 'wrap' } },
|
|
284
|
+
props.onGo ? h(Btn, { onClick: props.onGo, primary: true, tone: C.text }, props.goLabel || '去处置 →') : null,
|
|
285
|
+
props.onAck ? h(TinyBtn, { onClick: props.onAck }, '已知晓(静音此项)') : null,
|
|
286
|
+
props.onUnack ? h(TinyBtn, { onClick: props.onUnack, tone: C.info }, '恢复显示此项') : null,
|
|
287
|
+
),
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* 紧凑动作行:默认一行(名称+风险徽标+[预览计划]);展开后才出
|
|
293
|
+
* 明细/human 计划/指纹/口令框/确认执行。autoOpenNonce 变化且 autoOpen
|
|
294
|
+
* 匹配时自动展开并预览(finding 直达链路)。
|
|
295
|
+
*/
|
|
296
|
+
function ActionRow(props) {
|
|
297
|
+
const [open, setOpen] = useState(false);
|
|
298
|
+
const [stage, setStage] = useState('idle');
|
|
299
|
+
const [plan, setPlan] = useState(null);
|
|
300
|
+
const [hash, setHash] = useState('');
|
|
301
|
+
const [outcome, setOutcome] = useState(null);
|
|
302
|
+
const [error, setError] = useState('');
|
|
303
|
+
const [busy, setBusy] = useState(false);
|
|
304
|
+
const pwRef = useRef(null);
|
|
305
|
+
const extraRef = useRef({});
|
|
306
|
+
// planFn 是每次渲染新建的内联箭头(且部分捕获 disabledBundles/suspectNames
|
|
307
|
+
// 等会变的值)。用 ref 承载「最新引用」,让 preview/effect 保持依赖稳定,
|
|
308
|
+
// 同时调用时永远读到本轮渲染最新的 planFn,不冻结成陈旧闭包。
|
|
309
|
+
const planFnRef = useRef(props.planFn);
|
|
310
|
+
planFnRef.current = props.planFn;
|
|
311
|
+
|
|
312
|
+
const preview = useCallback(async () => {
|
|
313
|
+
setOpen(true);
|
|
314
|
+
setBusy(true); setError(''); setOutcome(null);
|
|
315
|
+
try {
|
|
316
|
+
const res = await planFnRef.current(extraRef.current);
|
|
317
|
+
if (res.ok === false) throw new Error(res.error || 'plan 失败');
|
|
318
|
+
setPlan(res.plan); setHash(res.planHash); setStage('planned');
|
|
319
|
+
} catch (e) { setError(e && e.message ? e.message : String(e)); setStage('error'); }
|
|
320
|
+
finally { setBusy(false); }
|
|
321
|
+
}, []);
|
|
322
|
+
|
|
323
|
+
const exec = useCallback(async () => {
|
|
324
|
+
if (!hash) return;
|
|
325
|
+
setBusy(true); setError('');
|
|
326
|
+
const extra = { action: 'execute', planHash: hash, confirm: true };
|
|
327
|
+
if (props.needPassword && pwRef.current && pwRef.current.value) extra.password = pwRef.current.value;
|
|
328
|
+
try {
|
|
329
|
+
const res = await call('execute', extra);
|
|
330
|
+
setOutcome(res); setStage(res.ok === false ? 'error' : 'done');
|
|
331
|
+
if (pwRef.current) pwRef.current.value = '';
|
|
332
|
+
} catch (e) {
|
|
333
|
+
setError(e && e.message ? e.message : String(e)); setStage('error');
|
|
334
|
+
if (pwRef.current) pwRef.current.value = '';
|
|
335
|
+
} finally { setBusy(false); }
|
|
336
|
+
}, [hash, props]);
|
|
337
|
+
|
|
338
|
+
const tone = props.risk === 'mid' ? C.warning : C.info;
|
|
339
|
+
const blocked = props.blocked === true;
|
|
340
|
+
|
|
341
|
+
// 外部直达:nonce 变化即展开+预览(被门挡住的仍只展开显示原因)。
|
|
342
|
+
// preview 现为稳定引用,故不进依赖:只有 nonce/autoOpen/actionKey/blocked
|
|
343
|
+
// 变化才会重跑,消除「每次渲染都刷新一次性指纹」的抖动。
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
if (!props.autoOpen || props.nonce === undefined || props.nonce <= 0) return;
|
|
346
|
+
if (props.actionKey !== props.autoOpen) return;
|
|
347
|
+
if (blocked) { setOpen(true); return; }
|
|
348
|
+
setOpen(true);
|
|
349
|
+
void preview();
|
|
350
|
+
}, [props.nonce, props.autoOpen, props.actionKey, blocked]);
|
|
351
|
+
|
|
352
|
+
return h('div', {
|
|
353
|
+
style: {
|
|
354
|
+
border: '1px solid ' + C.line, borderLeft: '3px solid ' + (blocked ? C.faint : tone),
|
|
355
|
+
borderRadius: 8, padding: open ? '10px 12px' : '7px 12px', margin: '0 0 8px',
|
|
356
|
+
background: C.fill, opacity: blocked ? 0.62 : 1,
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
|
|
360
|
+
h(Badge, { color: blocked ? C.faint : tone }, blocked ? '暂不适用' : props.risk === 'mid' ? '中风险·可逆' : '低风险'),
|
|
361
|
+
h('span', { style: { fontSize: 13, fontWeight: 600, flex: '1 1 auto' } }, props.title),
|
|
362
|
+
blocked ? null : h(Btn, { onClick: preview, disabled: busy }, stage === 'idle' && !open ? '预览计划' : '重新预览'),
|
|
363
|
+
),
|
|
364
|
+
open ? h('div', { style: { marginTop: 6 } },
|
|
365
|
+
h('div', { style: { fontSize: 11.5, color: C.sub, marginBottom: 6, whiteSpace: 'pre-wrap' } },
|
|
366
|
+
blocked && props.blockedHint ? props.blockedHint : props.hint),
|
|
367
|
+
blocked ? null : h('div', { style: { display: 'flex', gap: 0, alignItems: 'center', flexWrap: 'wrap', marginBottom: 2 } },
|
|
368
|
+
h(Btn, { onClick: exec, disabled: busy || !hash || stage === 'done', primary: true },
|
|
369
|
+
busy ? '执行中…' : '确认执行'),
|
|
370
|
+
hash ? h('span', { style: { fontSize: 10.5, color: C.faint } }, '指纹 ' + hash + ' · 一次性') : null,
|
|
371
|
+
),
|
|
372
|
+
props.extraUI ? props.extraUI(extraRef) : null,
|
|
373
|
+
props.needPassword && !blocked ? h('div', { style: { marginTop: 6 } },
|
|
374
|
+
h('input', {
|
|
375
|
+
ref: pwRef, type: 'password', placeholder: '加密口令(只进本次请求,不落盘不回显)',
|
|
376
|
+
style: { width: '100%', boxSizing: 'border-box', fontSize: 12, padding: '5px 8px', borderRadius: 6, border: '1px solid ' + C.line, background: 'transparent', color: C.text },
|
|
377
|
+
}),
|
|
378
|
+
) : null,
|
|
379
|
+
plan ? h('div', {
|
|
380
|
+
style: { fontSize: 12, color: C.text, marginTop: 8, padding: '7px 9px', border: '1px dashed ' + C.line, borderRadius: 6, lineHeight: 1.6 },
|
|
381
|
+
},
|
|
382
|
+
...(Array.isArray(plan.human) ? plan.human : [plan.note || '(无摘要)']).map((line, i) => h('div', { key: i }, line)),
|
|
383
|
+
) : null,
|
|
384
|
+
outcome && stage === 'done' ? h('div', { style: { fontSize: 12, color: C.ok, marginTop: 6, lineHeight: 1.6 } },
|
|
385
|
+
...(Array.isArray(outcome.human) ? outcome.human : ['已完成']).map((line, i) => h('div', { key: i }, line))) : null,
|
|
386
|
+
error ? h('div', { style: { fontSize: 12, color: C.critical, marginTop: 6, whiteSpace: 'pre-wrap' } }, '失败 · ' + error) : null,
|
|
387
|
+
) : null,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function AuditTab() {
|
|
392
|
+
const [data, setData] = useState(null);
|
|
393
|
+
const [error, setError] = useState('');
|
|
394
|
+
const [loading, setLoading] = useState(false);
|
|
395
|
+
const [sort, setSort] = useState(cache.auditSort || 'newest');
|
|
396
|
+
const changeSort = (v) => { cache.auditSort = v; setSort(v); };
|
|
397
|
+
const load = useCallback(async () => {
|
|
398
|
+
setLoading(true); setError('');
|
|
399
|
+
try { setData(await call('audit')); }
|
|
400
|
+
catch (e) { setError(e && e.message ? e.message : String(e)); }
|
|
401
|
+
finally { setLoading(false); }
|
|
402
|
+
}, []);
|
|
403
|
+
useEffect(() => { void load(); }, [load]);
|
|
404
|
+
const ts = (row) => { const t = Date.parse(String(row.at || row.generatedAt || '')); return Number.isFinite(t) ? t : 0; };
|
|
405
|
+
const sortRows = (rows) => {
|
|
406
|
+
const arr = rows.slice();
|
|
407
|
+
if (sort === 'newest') arr.sort((a, b) => ts(b) - ts(a));
|
|
408
|
+
else if (sort === 'oldest') arr.sort((a, b) => ts(a) - ts(b));
|
|
409
|
+
else if (sort === 'okFirst') arr.sort((a, b) => (b.ok === true ? 1 : 0) - (a.ok === true ? 1 : 0) || ts(b) - ts(a));
|
|
410
|
+
else if (sort === 'rejectedFirst') arr.sort((a, b) => (b.ok === true ? 0 : 1) - (a.ok === true ? 0 : 1) || ts(b) - ts(a));
|
|
411
|
+
return arr;
|
|
412
|
+
};
|
|
413
|
+
// 台账行必须说人话:内部 kind/error 码是机器字段,直接 dump 给用户等于没做呈现层。
|
|
414
|
+
const KIND_ZH = { clean: '磁盘清理', backup: '加密备份', disable: '停用插件', enable: '启用插件', knowledge: '沉淀知识', ack: '静音一项发现', unack: '恢复一项发现' };
|
|
415
|
+
const ERR_ZH = {
|
|
416
|
+
'unknown-or-expired-plan': '计划已过期或指纹不对——指纹是一次性的,请重新「预览计划」',
|
|
417
|
+
'confirm-missing': '缺少授权确认——执行必须带 confirm:true',
|
|
418
|
+
};
|
|
419
|
+
const countsZh = (c) => (c ? ['严重 ' + (c.critical || 0), '留意 ' + (c.warning || 0), '提示 ' + (c.info || 0)].join(' · ') + ((c.muted || 0) > 0 ? ' · 静音 ' + c.muted : '') : '');
|
|
420
|
+
return h('div', {},
|
|
421
|
+
h('div', { style: { fontSize: 12, color: C.sub, lineHeight: 1.7, margin: '2px 0 10px', padding: '8px 10px', borderLeft: '2px solid ' + C.info, background: C.fill, borderRadius: 6 } },
|
|
422
|
+
'这里是管家的操作台账:它经你授权动过系统的每一次记录都写在这里,包括被拒绝的尝试。', h('br'),
|
|
423
|
+
'用途:核对「上次到底清了什么、什么时候、成没成」,以及看体检结论随时间是变好还是变坏。'),
|
|
424
|
+
h('div', { style: { marginBottom: 10, display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' } },
|
|
425
|
+
h(Btn, { onClick: load, disabled: loading }, loading ? '读取中…' : '刷新台账'),
|
|
426
|
+
// 不用原生 <select>:Windows 下拉弹层由系统绘制,无视宿主主题(白底浅字不可读)。分段按钮全部内联样式,跟随面板配色。
|
|
427
|
+
h('span', { style: { fontSize: 11, color: C.faint, marginLeft: 4 } }, '排序'),
|
|
428
|
+
...[['newest', '最近在前'], ['oldest', '最早在前'], ['okFirst', '已执行在前'], ['rejectedFirst', '被拒绝在前']].map(([k, label]) => h('button', {
|
|
429
|
+
key: k, onClick: () => changeSort(k),
|
|
430
|
+
style: {
|
|
431
|
+
cursor: 'pointer', fontSize: 11, padding: '4px 9px', borderRadius: 999,
|
|
432
|
+
border: '1px solid ' + (sort === k ? 'transparent' : C.line),
|
|
433
|
+
background: sort === k ? C.solid : 'transparent',
|
|
434
|
+
color: sort === k ? C.on : C.sub, fontWeight: sort === k ? 600 : 400,
|
|
435
|
+
},
|
|
436
|
+
}, label))),
|
|
437
|
+
error ? h('div', { style: { fontSize: 12, color: C.critical } }, '读取失败 · ' + error) : null,
|
|
438
|
+
data && Array.isArray(data.audit) && data.audit.length ? h('div', {},
|
|
439
|
+
h('div', { style: { fontSize: 12, fontWeight: 600, color: C.sub, margin: '6px 0' } }, '授权动作记录(最近 ' + data.audit.length + ' 条)'),
|
|
440
|
+
...sortRows(data.audit).map((row, i) => h('div', {
|
|
441
|
+
key: i,
|
|
442
|
+
style: { fontSize: 11.5, padding: '5px 8px', borderRadius: 6, background: C.fill, marginBottom: 4, display: 'flex', gap: 8, flexWrap: 'wrap', color: C.text },
|
|
443
|
+
},
|
|
444
|
+
h('span', { style: { color: C.faint } }, localStamp(row.at)),
|
|
445
|
+
h('span', { style: { fontWeight: 600 } }, KIND_ZH[row.kind] || row.kind || row.op || '执行动作'),
|
|
446
|
+
h('span', { style: { color: row.ok ? C.ok : C.critical } }, row.ok ? '✓ 已执行' : '✗ 被拒绝 · ' + (ERR_ZH[row.error] || row.error || '失败')),
|
|
447
|
+
row.plugin ? h('span', { style: { color: C.sub } }, '对象 ' + row.plugin) : null,
|
|
448
|
+
row.hash ? h('span', { style: { color: C.faint, fontSize: 10 }, title: '当时那份计划的指纹(一次性,执行即失效)' }, '计划 ' + String(row.hash).slice(0, 8)) : null,
|
|
449
|
+
)),
|
|
450
|
+
) : (data && !data.audit.length ? h('div', { style: { fontSize: 12, color: C.faint } }, '还没有执行记录——你授权过的每个动作(以及被门拒绝的尝试)都会记在这里。') : null),
|
|
451
|
+
data && Array.isArray(data.trend) && data.trend.length ? h('div', { style: { marginTop: 12 } },
|
|
452
|
+
h('div', { style: { fontSize: 12, fontWeight: 600, color: C.sub, margin: '6px 0' } }, '体检快照趋势(每次沉淀知识时留下的当时的结论)'),
|
|
453
|
+
...sortRows(data.trend).map((t, i) => h('div', {
|
|
454
|
+
key: i, style: { fontSize: 11.5, padding: '5px 8px', borderRadius: 6, background: C.fill, marginBottom: 4, display: 'flex', gap: 8, flexWrap: 'wrap', color: C.text },
|
|
455
|
+
},
|
|
456
|
+
h('span', { style: { color: C.faint } }, localStamp(t.at || t.generatedAt)),
|
|
457
|
+
h('span', {}, t.level === 'critical' ? '有严重问题' : t.level === 'warning' ? '有留意项' : t.level === 'ok' ? '正常' : (t.level || '-')),
|
|
458
|
+
h('span', { style: { color: C.sub } }, countsZh(t.counts)),
|
|
459
|
+
typeof t.cFreeBytes === 'number' ? h('span', { style: { color: C.faint }, title: '当时系统盘(C:)剩余空间' }, '剩 ' + (t.cFreeBytes >= 1073741824 ? (t.cFreeBytes / 1073741824).toFixed(1) + ' GB' : (t.cFreeBytes / 1048576).toFixed(1) + ' MB')) : null,
|
|
460
|
+
t.executed ? h('span', { style: { color: C.faint } }, '当时已执行 ' + t.executed + ' 个动作') : null)),
|
|
461
|
+
) : null,
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// 模块级缓存:Panel 切走即卸载;报告与插件变更留在内存(刷新清空属预期)。
|
|
466
|
+
const cache = { report: null, error: '', hidden: {}, auditSort: 'newest', mode: 'rules', model: null };
|
|
467
|
+
// v0.8.4:当前生效的入口重挂函数,由 apply() 填、由设置页调用。
|
|
468
|
+
// 存在它就不需要「改完要 Ctrl+R」——那前提在桌面版里根本不成立。
|
|
469
|
+
// ---------- 右下角临时弹窗(v0.9.1) ----------
|
|
470
|
+
// 与 dsh-our-free-model 的做法同源:自己起一个 body 级 fixed 容器,不等任何
|
|
471
|
+
// 第三方 slot 的语义。区别是这里全部内联样式,不注入 <style>(项目红线)。
|
|
472
|
+
//
|
|
473
|
+
// 必须挂在 frame-wide 的 shell.overlay 上,而不是挂在管家面板里:面板只在
|
|
474
|
+
// 用户打开它时才存在,挂在那里等于「你盯着看的时候才提醒你」。
|
|
475
|
+
const toastStore = { items: [], seq: 0 };
|
|
476
|
+
const toastListeners = new Set();
|
|
477
|
+
const reportListeners = new Set();
|
|
478
|
+
// 一次会话里同一条发现只弹一次。反复弹同一条,等于训练用户无视它。
|
|
479
|
+
const toastedIds = new Set();
|
|
480
|
+
let toastConfig = { mode: 'critical', seconds: 12 };
|
|
481
|
+
|
|
482
|
+
function notifyToasts() {
|
|
483
|
+
for (const fn of toastListeners) { try { fn(); } catch { /* noop */ } }
|
|
484
|
+
}
|
|
485
|
+
function dismissToast(id) {
|
|
486
|
+
toastStore.items = toastStore.items.filter((t) => t.id !== id);
|
|
487
|
+
notifyToasts();
|
|
488
|
+
}
|
|
489
|
+
function pushToast(item) {
|
|
490
|
+
toastStore.seq += 1;
|
|
491
|
+
const id = 't' + toastStore.seq;
|
|
492
|
+
// 最多同时 3 条:堆到半个屏幕高就变成打扰,而不是提醒。
|
|
493
|
+
toastStore.items = toastStore.items.concat([{ ...item, id }]).slice(-3);
|
|
494
|
+
notifyToasts();
|
|
495
|
+
}
|
|
496
|
+
function publishReport(report) {
|
|
497
|
+
for (const fn of reportListeners) { try { fn(report); } catch { /* noop */ } }
|
|
498
|
+
}
|
|
499
|
+
// 阈值来自设置:critical 只弹「严重」,warning 连「留意」一起弹,off 不弹。
|
|
500
|
+
function toastWorthy(report) {
|
|
501
|
+
if (!report || !Array.isArray(report.findings)) return [];
|
|
502
|
+
if (toastConfig.mode === 'off') return [];
|
|
503
|
+
const want = toastConfig.mode === 'warning' ? ['critical', 'warning'] : ['critical'];
|
|
504
|
+
return report.findings.filter((f) => f && !f.muted && want.includes(f.severity));
|
|
505
|
+
}
|
|
506
|
+
function firstLine(text, max) {
|
|
507
|
+
const t = String(text || '').replace(/\s+/g, ' ').trim();
|
|
508
|
+
if (!t) return '';
|
|
509
|
+
return t.length > max ? t.slice(0, max - 1) + '…' : t;
|
|
510
|
+
}
|
|
511
|
+
// 一条发现 → 一条弹窗文案。短到能在 12 秒里读懂,长到能判断要不要理它。
|
|
512
|
+
function toastOf(finding) {
|
|
513
|
+
return {
|
|
514
|
+
findingId: finding.id,
|
|
515
|
+
actionKey: ACTION_FOR[finding.id] || 'model-default',
|
|
516
|
+
severity: finding.severity,
|
|
517
|
+
title: firstLine(finding.title, 42),
|
|
518
|
+
body: firstLine(finding.detail || finding.recommendation || '', 110),
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
function evaluateToasts(report) {
|
|
522
|
+
for (const f of toastWorthy(report)) {
|
|
523
|
+
if (toastedIds.has(f.id)) continue;
|
|
524
|
+
toastedIds.add(f.id);
|
|
525
|
+
pushToast(toastOf(f));
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// 弹窗住在 shell.overlay 里,不在面板的树里 —— 点击「去看看」需要一条通道
|
|
530
|
+
// 把意图送回面板。面板挂载时把自己的跳转函数填进来,卸载时清空;
|
|
531
|
+
// 面板没开时点「去看看」只是关掉弹窗,不报错。
|
|
532
|
+
let PANEL_NAVIGATOR = null;
|
|
533
|
+
let PANEL_ACK = null;
|
|
534
|
+
|
|
535
|
+
let ENTRY_APPLIER = null;
|
|
536
|
+
|
|
537
|
+
// ── v0.5.0 L1:拟人化外壳 ────────────────────────────────────────────
|
|
538
|
+
// 把已有 report 翻译成「一句人话 + 三件要紧事」。纯前端模板渲染:零 token、
|
|
539
|
+
// 零额外请求、零新依赖,也不碰红线(无 <style> 注入、无 locale、内联样式)。
|
|
540
|
+
function greetingWord(hour) {
|
|
541
|
+
if (hour < 5 || hour >= 23) return '夜深了';
|
|
542
|
+
if (hour < 9) return '早上好';
|
|
543
|
+
if (hour < 12) return '上午好';
|
|
544
|
+
if (hour < 14) return '中午好';
|
|
545
|
+
if (hour < 18) return '下午好';
|
|
546
|
+
return '晚上好';
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// 审计/趋势里存的是 UTC ISO 串(toISOString)。直接贴给用户看会差 8 小时,
|
|
550
|
+
// 所以本地化一律走这个函数,不要在渲染处自己做字符串切片的时区假设。
|
|
551
|
+
function localStamp(iso) {
|
|
552
|
+
const t = Date.parse(String(iso || ''));
|
|
553
|
+
if (!Number.isFinite(t)) return String(iso || '');
|
|
554
|
+
const d = new Date(t);
|
|
555
|
+
const p = (n) => (n < 10 ? '0' + n : String(n));
|
|
556
|
+
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate())
|
|
557
|
+
+ ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function agoText(iso) {
|
|
561
|
+
const t = Date.parse(String(iso || ''));
|
|
562
|
+
if (!Number.isFinite(t)) return '';
|
|
563
|
+
const min = Math.round((Date.now() - t) / 60000);
|
|
564
|
+
if (min < 1) return '刚刚';
|
|
565
|
+
if (min < 60) return min + ' 分钟前';
|
|
566
|
+
const hr = Math.round(min / 60);
|
|
567
|
+
if (hr < 24) return hr + ' 小时前';
|
|
568
|
+
return Math.round(hr / 24) + ' 天前';
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function Avatar(props) {
|
|
572
|
+
const size = (props && props.size) || 32;
|
|
573
|
+
const alert = !!(props && props.alert);
|
|
574
|
+
return h('div', {
|
|
575
|
+
style: {
|
|
576
|
+
width: size, height: size, borderRadius: '50%', flex: '0 0 auto',
|
|
577
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative',
|
|
578
|
+
background: mix(alert ? C.critical : C.info, 15),
|
|
579
|
+
border: '1px solid ' + mix(alert ? C.critical : C.info, 40),
|
|
580
|
+
},
|
|
581
|
+
title: 'DSH 智能管家 · 只读体检 + 授权执行',
|
|
582
|
+
},
|
|
583
|
+
h('svg', {
|
|
584
|
+
width: Math.round(size * 0.56), height: Math.round(size * 0.56),
|
|
585
|
+
viewBox: '0 0 24 24', fill: 'none', stroke: alert ? C.critical : C.info,
|
|
586
|
+
strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
587
|
+
},
|
|
588
|
+
h('path', { d: 'M12 3l7 3v5.5c0 4.2-2.9 7.6-7 9.5-4.1-1.9-7-5.3-7-9.5V6z' }),
|
|
589
|
+
h('path', { d: 'M8.8 12.2h1.8l1.1-2.4 1.4 4.2 1.1-1.8h1.9' })),
|
|
590
|
+
alert ? h('span', { style: { position: 'absolute', top: -1, right: -1, width: 8, height: 8, borderRadius: '50%', background: C.critical } }) : null);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function Briefing(props) {
|
|
594
|
+
const report = props && props.report;
|
|
595
|
+
const onGo = props && props.onGo;
|
|
596
|
+
const loading = !!(props && props.loading);
|
|
597
|
+
const hi = greetingWord(new Date().getHours()) + ',我是你的 DSH 管家。';
|
|
598
|
+
const box = {
|
|
599
|
+
display: 'flex', gap: 11, alignItems: 'flex-start',
|
|
600
|
+
padding: '11px 13px', marginBottom: 12, borderRadius: 10,
|
|
601
|
+
border: '1px solid ' + C.line, background: C.fill,
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
if (!report || !report.summary) {
|
|
605
|
+
return h('div', { style: box },
|
|
606
|
+
h(Avatar, { size: 34, alert: false }),
|
|
607
|
+
h('div', { style: { flex: '1 1 auto', minWidth: 0 } },
|
|
608
|
+
h('div', { style: { fontSize: 13.5, fontWeight: 600 } }, hi),
|
|
609
|
+
h('div', { style: { fontSize: 12, color: C.sub, marginTop: 2 } },
|
|
610
|
+
loading ? '我正在把系统看一遍,稍等…'
|
|
611
|
+
: '还没有体检记录。点右上角「重新体检」,我先把启动、插件、数据、磁盘看一遍。')));
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const counts = report.summary.counts || {};
|
|
615
|
+
const level = report.summary.level || 'ok';
|
|
616
|
+
const alive = Array.isArray(report.findings) ? report.findings.filter((f) => !f.muted) : [];
|
|
617
|
+
const top = alive.filter((f) => typeof f.score === 'number')
|
|
618
|
+
.slice().sort((a, b) => b.score - a.score).slice(0, 3);
|
|
619
|
+
const nCrit = counts.critical || 0;
|
|
620
|
+
const nWarn = counts.warning || 0;
|
|
621
|
+
const nMuted = counts.muted || 0;
|
|
622
|
+
let line;
|
|
623
|
+
if (level === 'critical') line = '有 ' + nCrit + ' 件严重问题,建议现在就看一眼。';
|
|
624
|
+
else if (level === 'warning') line = '有 ' + nWarn + ' 件值得留意,都不影响使用。';
|
|
625
|
+
else line = '一切正常,没有需要立刻处理的事。';
|
|
626
|
+
if (nMuted) line += '(另有 ' + nMuted + ' 项你已静音)';
|
|
627
|
+
const when = agoText(report.generatedAt);
|
|
628
|
+
|
|
629
|
+
return h('div', { style: box },
|
|
630
|
+
h(Avatar, { size: 34, alert: level === 'critical' }),
|
|
631
|
+
h('div', { style: { flex: '1 1 auto', minWidth: 0 } },
|
|
632
|
+
h('div', { style: { fontSize: 13.5, fontWeight: 600 } }, hi),
|
|
633
|
+
h('div', { style: { fontSize: 12.5, color: C.text, marginTop: 2 } }, line),
|
|
634
|
+
when ? h('div', { style: { fontSize: 10.5, color: C.faint, marginTop: 1 } }, '上次体检 · ' + when) : null,
|
|
635
|
+
top.length ? h('div', { style: { marginTop: 7, display: 'flex', flexDirection: 'column', gap: 4 } },
|
|
636
|
+
top.map((f, i) => h('div', { key: (f.id || 'x') + i, style: { display: 'flex', alignItems: 'center', gap: 6, fontSize: 11.5 } },
|
|
637
|
+
h('span', { style: { color: (SEV[f.severity] || SEV.info).color, flex: '0 0 auto' } }, '·'),
|
|
638
|
+
h('span', { style: { color: C.sub, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: '1 1 auto', minWidth: 0 } }, f.title || f.id || ''),
|
|
639
|
+
typeof f.score === 'number' ? h('span', { style: { color: C.faint, fontSize: 10 }, title: (f.scoreReasons || []).join(';') }, '★' + f.score) : null,
|
|
640
|
+
ACTION_FOR[f.id] && onGo ? h('button', {
|
|
641
|
+
onClick: () => onGo(ACTION_FOR[f.id]),
|
|
642
|
+
style: { cursor: 'pointer', fontSize: 10.5, color: C.info, background: 'transparent', border: '1px solid ' + C.line, borderRadius: 5, padding: '0 5px', flex: '0 0 auto' },
|
|
643
|
+
}, '去处理') : null))) : null,
|
|
644
|
+
h('div', { style: { fontSize: 10.5, color: C.faint, marginTop: 7, lineHeight: 1.5 } },
|
|
645
|
+
'我只负责「看」和「问」:真正动手要你确认——预览计划是零写入的,看清再点执行。')));
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* L2 对话入口:一句话 → 一个意图 → 打开对应动作卡。
|
|
650
|
+
*
|
|
651
|
+
* 三条纪律,写在这里免得以后被改坏:
|
|
652
|
+
* 1. 解析在 host(lib/intent.js),纯规则匹配、零 token。它只决定「打开哪张卡」,
|
|
653
|
+
* 绝不替用户执行任何东西。
|
|
654
|
+
* 2. 命中后等同于「用户自己点了那张卡的预览」——同一套 plan→confirm→audit 链路,
|
|
655
|
+
* 没有第二条写入口。
|
|
656
|
+
* 3. 听不懂就把原话和例句摆回去,不猜。猜对一次会养成替用户拍板的习惯。
|
|
657
|
+
*/
|
|
658
|
+
// 输入区(v0.7.4 重做)。原来那一坨:三行小胶囊堆在条子上面、
|
|
659
|
+
// 按钮是方角文字、说明文字有三层、模型列表一展开就把输入框顶走。
|
|
660
|
+
// 现在照成熟 chat composer 的形状来:
|
|
661
|
+
// 一条圆角输入条 —— 左「模式/模型」芯片,中输入,右圆形发送键;
|
|
662
|
+
// 芯片点开是条子下方的设置面板(选项行 + 可滚动的 provider/模型),
|
|
663
|
+
// 面板不进布局流,不遮输入;未展开时输入条永远在同一高度。
|
|
664
|
+
// 红线不变:零 <style> 注入、全内联、不用原生 <select>(胶囊/自绘)。
|
|
665
|
+
function ChatBar(props) {
|
|
666
|
+
const [value, setValue] = useState('');
|
|
667
|
+
const [busy, setBusy] = useState(false);
|
|
668
|
+
const [note, setNote] = useState(null);
|
|
669
|
+
const [mode, setMode] = useState(cache.mode || 'rules');
|
|
670
|
+
const [catalog, setCatalog] = useState(null);
|
|
671
|
+
const [open, setOpen] = useState(false);
|
|
672
|
+
const [freeOnly, setFreeOnly] = useState(false);
|
|
673
|
+
const [model, setModel] = useState(cache.model);
|
|
674
|
+
const [focused, setFocused] = useState(false);
|
|
675
|
+
const [pending, setPending] = useState(null);
|
|
676
|
+
const inputRef = useRef(null);
|
|
677
|
+
|
|
678
|
+
const loadCatalog = useCallback(async () => {
|
|
679
|
+
try {
|
|
680
|
+
const data = await call('models');
|
|
681
|
+
setCatalog(data);
|
|
682
|
+
if (!model && data && data.default) {
|
|
683
|
+
setModel(data.default);
|
|
684
|
+
cache.model = data.default;
|
|
685
|
+
}
|
|
686
|
+
return data;
|
|
687
|
+
} catch (e) {
|
|
688
|
+
setCatalog({ ok: false, available: false, providers: [], error: e && e.message ? e.message : String(e) });
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
}, [model]);
|
|
692
|
+
|
|
693
|
+
const chooseMode = useCallback((next) => {
|
|
694
|
+
setMode(next);
|
|
695
|
+
cache.mode = next;
|
|
696
|
+
if (next === 'model' && !catalog) void loadCatalog();
|
|
697
|
+
}, [catalog, loadCatalog]);
|
|
698
|
+
|
|
699
|
+
const submit = useCallback(async () => {
|
|
700
|
+
const line = String(value || '').trim();
|
|
701
|
+
if (!line || busy) return;
|
|
702
|
+
setBusy(true); setNote(null); setPending(null);
|
|
703
|
+
try {
|
|
704
|
+
let res;
|
|
705
|
+
if (mode === 'model') {
|
|
706
|
+
if (!model || !model.provider || !model.model) {
|
|
707
|
+
const cat = catalog || await loadCatalog();
|
|
708
|
+
const pick = model || (cat && cat.default);
|
|
709
|
+
if (!pick) throw new Error('还没有可用模型——点输入条左边的芯片选一个,或切回规则式。');
|
|
710
|
+
res = await call('ask', { text: line, provider: pick.provider, model: pick.model });
|
|
711
|
+
} else {
|
|
712
|
+
res = await call('ask', { text: line, provider: model.provider, model: model.model });
|
|
713
|
+
}
|
|
714
|
+
if (res.ok === false) throw new Error(res.error || '模型没有回答');
|
|
715
|
+
} else {
|
|
716
|
+
res = await call('intent', { text: line });
|
|
717
|
+
if (res.ok === false) throw new Error(res.error || '解析失败');
|
|
718
|
+
}
|
|
719
|
+
if (!res.intent) {
|
|
720
|
+
setNote({ kind: 'clarify', lines: [res.clarify || '这句我没听懂。'], suggestions: res.suggestions || [] });
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
const kind = res.intent.kind;
|
|
724
|
+
setNote({ kind: 'reply', lines: res.reply || [], suggestions: [] });
|
|
725
|
+
setValue('');
|
|
726
|
+
if (kind === 'report') {
|
|
727
|
+
if (props.onRescan) props.onRescan();
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
if (kind === 'goto') {
|
|
731
|
+
const target = res.intent.params && res.intent.params.tab;
|
|
732
|
+
if (props.onTab) props.onTab(target || 'overview');
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
if (kind === 'ack' || kind === 'unack') {
|
|
736
|
+
const plan = await call('plan', { kind, id: res.intent.params.id });
|
|
737
|
+
if (plan.ok === false) throw new Error(plan.error || 'plan 失败');
|
|
738
|
+
setPending({ kind, planHash: plan.planHash, human: plan.plan && plan.plan.human });
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
if (props.onGo) props.onGo(kind);
|
|
742
|
+
} catch (e) {
|
|
743
|
+
setNote({ kind: 'error', lines: ['没走通:' + (e && e.message ? e.message : String(e))] });
|
|
744
|
+
} finally { setBusy(false); }
|
|
745
|
+
}, [value, busy, props, mode, model, catalog, loadCatalog]);
|
|
746
|
+
|
|
747
|
+
const confirmPending = useCallback(async () => {
|
|
748
|
+
if (!pending || busy) return;
|
|
749
|
+
setBusy(true);
|
|
750
|
+
try {
|
|
751
|
+
const out = await call('execute', { planHash: pending.planHash, confirm: true });
|
|
752
|
+
setNote({ kind: 'reply', lines: Array.isArray(out.human) ? out.human : ['已完成'] });
|
|
753
|
+
setPending(null);
|
|
754
|
+
if (props.onRescan) props.onRescan();
|
|
755
|
+
} catch (e) {
|
|
756
|
+
setNote({ kind: 'error', lines: ['没走通:' + (e && e.message ? e.message : String(e))] });
|
|
757
|
+
} finally { setBusy(false); }
|
|
758
|
+
}, [pending, busy, props]);
|
|
759
|
+
|
|
760
|
+
const tone = !note ? C.line : note.kind === 'error' ? C.critical : note.kind === 'clarify' ? C.warning : C.ok;
|
|
761
|
+
|
|
762
|
+
// 胶囊按钮而不是原生 <select>:原生下拉的弹层由系统绘制,主题管不住
|
|
763
|
+
// (v0.3.7 踩过:白底浅字)。
|
|
764
|
+
const capsule = (label, active, onClick, title) => h('button', {
|
|
765
|
+
key: label + '|' + String(active),
|
|
766
|
+
onClick, title: title || label,
|
|
767
|
+
style: {
|
|
768
|
+
cursor: 'pointer', fontSize: 11, padding: '3px 9px', borderRadius: 999, fontFamily: 'inherit',
|
|
769
|
+
border: '1px solid ' + (active ? 'transparent' : C.line),
|
|
770
|
+
background: active ? C.solid : 'transparent',
|
|
771
|
+
color: active ? C.on : C.sub,
|
|
772
|
+
fontWeight: active ? 600 : 400,
|
|
773
|
+
},
|
|
774
|
+
}, label);
|
|
775
|
+
|
|
776
|
+
const providers = catalog && Array.isArray(catalog.providers) ? catalog.providers : [];
|
|
777
|
+
// 32 个 provider 里免费那几组排在最后,不筛就得往下翻一大片。
|
|
778
|
+
// 判据:名字带 free/免费,或过半模型以 :free 结尾(WorkBuddy 的 hy4free 单例不算)。
|
|
779
|
+
const isFreeProvider = (p) => {
|
|
780
|
+
const name = String(p && p.id || '') + ' ' + String(p && p.name || '');
|
|
781
|
+
if (/free|免费/i.test(name)) return true;
|
|
782
|
+
const models = Array.isArray(p && p.models) ? p.models : [];
|
|
783
|
+
if (!models.length) return false;
|
|
784
|
+
const freeModels = models.filter((m) => /(^|[-/:])free$/i.test(String(m && m.id || ''))).length;
|
|
785
|
+
return freeModels * 2 >= models.length;
|
|
786
|
+
};
|
|
787
|
+
const shownProviders = freeOnly ? providers.filter(isFreeProvider) : providers;
|
|
788
|
+
const freeCount = providers.filter(isFreeProvider).length;
|
|
789
|
+
const activeProvider = shownProviders.find((p) => model && p.id === model.provider) || shownProviders[0] || null;
|
|
790
|
+
const modelsOf = activeProvider && Array.isArray(activeProvider.models) ? activeProvider.models : [];
|
|
791
|
+
|
|
792
|
+
const modelLabel = mode === 'rules'
|
|
793
|
+
? '规则式'
|
|
794
|
+
: (model ? String(model.model) : '选模型');
|
|
795
|
+
const chipTitle = mode === 'rules'
|
|
796
|
+
? '规则式理解:本地词表,0 token。点开可切换模型理解或选择模型。'
|
|
797
|
+
: ('模型理解:' + (model ? model.provider + '/' + model.model : '尚未选择模型') + '。点开可更换。');
|
|
798
|
+
|
|
799
|
+
// 选项行(面板里的单选行)——比一排小胶囊更像「设置」而不是「过滤器」。
|
|
800
|
+
const optionRow = (title, sub, active, onClick) => h('button', {
|
|
801
|
+
key: title, onClick,
|
|
802
|
+
style: {
|
|
803
|
+
display: 'flex', alignItems: 'flex-start', gap: 8, width: '100%', textAlign: 'left',
|
|
804
|
+
cursor: 'pointer', fontFamily: 'inherit', padding: '7px 8px', borderRadius: 7,
|
|
805
|
+
border: '1px solid ' + (active ? C.text : 'transparent'),
|
|
806
|
+
background: 'transparent',
|
|
807
|
+
},
|
|
808
|
+
},
|
|
809
|
+
h('span', {
|
|
810
|
+
style: {
|
|
811
|
+
flex: '0 0 auto', width: 13, height: 13, marginTop: 2, borderRadius: '50%',
|
|
812
|
+
border: '1.5px solid ' + (active ? C.text : C.line), position: 'relative',
|
|
813
|
+
},
|
|
814
|
+
}, active ? h('span', {
|
|
815
|
+
style: { position: 'absolute', inset: 2, borderRadius: '50%', background: C.text },
|
|
816
|
+
}) : null),
|
|
817
|
+
h('span', { style: { flex: '1 1 auto', minWidth: 0 } },
|
|
818
|
+
h('span', { style: { display: 'block', fontSize: 12, color: C.text, fontWeight: active ? 600 : 400 } }, title),
|
|
819
|
+
sub ? h('span', { style: { display: 'block', fontSize: 10.5, color: C.faint, marginTop: 1, lineHeight: 1.5 } }, sub) : null),
|
|
820
|
+
);
|
|
821
|
+
|
|
822
|
+
const canSend = !busy && !!String(value).trim();
|
|
823
|
+
|
|
824
|
+
return h('div', { style: { marginBottom: 12 } },
|
|
825
|
+
// ── 输入条 ────────────────────────────────────────────────
|
|
826
|
+
h('div', {
|
|
827
|
+
style: {
|
|
828
|
+
display: 'flex', alignItems: 'center', gap: 8, padding: '5px 6px 5px 10px',
|
|
829
|
+
// 默认态就用强调色描边(截图里那种蓝),只是比聚焦态淡一档:
|
|
830
|
+
// 这条输入条是面板的主入口,用中性灰边它会被当成装饰而没人用。
|
|
831
|
+
border: '1px solid ' + (focused ? C.info : mix(C.info, 42)), borderRadius: 12,
|
|
832
|
+
background: C.fill, transition: 'border-color .12s, box-shadow .12s',
|
|
833
|
+
boxShadow: focused ? '0 0 0 3px ' + mix(C.info, 12) : '0 0 0 1px ' + mix(C.info, 6),
|
|
834
|
+
},
|
|
835
|
+
},
|
|
836
|
+
h('button', {
|
|
837
|
+
onClick: () => { setOpen((v) => !v); if (!open && !catalog && mode !== 'rules') void loadCatalog(); },
|
|
838
|
+
title: chipTitle,
|
|
839
|
+
style: {
|
|
840
|
+
flex: '0 0 auto', display: 'inline-flex', alignItems: 'center', gap: 5, cursor: 'pointer',
|
|
841
|
+
fontFamily: 'inherit', fontSize: 11, padding: '3px 7px', borderRadius: 7,
|
|
842
|
+
border: '1px solid transparent',
|
|
843
|
+
background: open ? C.solid : 'transparent',
|
|
844
|
+
color: open ? C.on : C.sub, maxWidth: 210, overflow: 'hidden',
|
|
845
|
+
},
|
|
846
|
+
},
|
|
847
|
+
h('svg', {
|
|
848
|
+
width: 12, height: 12, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor',
|
|
849
|
+
'stroke-width': 2, 'stroke-linecap': 'round', 'stroke-linejoin': 'round', style: { flex: '0 0 auto' },
|
|
850
|
+
},
|
|
851
|
+
mode === 'rules'
|
|
852
|
+
? h('path', { d: 'M4 6h16M4 12h10M4 18h7' })
|
|
853
|
+
: h('path', { d: 'M12 3l1.8 4.9L19 9.7l-5.2 1.8L12 16.4l-1.8-4.9L5 9.7l5.2-1.8z' }),
|
|
854
|
+
),
|
|
855
|
+
h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, modelLabel),
|
|
856
|
+
h('span', { style: { fontSize: 9, opacity: .7 } }, open ? '▲' : '▼'),
|
|
857
|
+
),
|
|
858
|
+
h('input', {
|
|
859
|
+
ref: inputRef,
|
|
860
|
+
value,
|
|
861
|
+
onChange: (e) => setValue(e.target.value),
|
|
862
|
+
onFocus: () => setFocused(true),
|
|
863
|
+
onBlur: () => setFocused(false),
|
|
864
|
+
onKeyDown: (e) => {
|
|
865
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void submit(); }
|
|
866
|
+
if (e.key === 'Escape') setOpen(false);
|
|
867
|
+
},
|
|
868
|
+
placeholder: '跟我说一句,比如「清理一下缓存」「有什么问题吗」',
|
|
869
|
+
'aria-label': '跟管家说一句话',
|
|
870
|
+
style: {
|
|
871
|
+
flex: '1 1 auto', minWidth: 0, fontSize: 13, padding: '5px 0',
|
|
872
|
+
border: 'none', outline: 'none', background: 'transparent', color: C.text, fontFamily: 'inherit',
|
|
873
|
+
},
|
|
874
|
+
}),
|
|
875
|
+
h('button', {
|
|
876
|
+
onClick: () => void submit(),
|
|
877
|
+
disabled: !canSend,
|
|
878
|
+
title: busy ? '处理中…' : '发送(Enter)',
|
|
879
|
+
'aria-label': '发送',
|
|
880
|
+
style: {
|
|
881
|
+
flex: '0 0 auto', width: 28, height: 28, borderRadius: '50%',
|
|
882
|
+
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
883
|
+
cursor: canSend ? 'pointer' : 'default', fontFamily: 'inherit',
|
|
884
|
+
border: '1px solid ' + (canSend ? 'transparent' : mix(C.info, 34)),
|
|
885
|
+
background: canSend ? C.solid : 'transparent',
|
|
886
|
+
color: canSend ? C.on : C.faint, opacity: busy ? .6 : 1,
|
|
887
|
+
transition: 'background .12s, border-color .12s',
|
|
888
|
+
},
|
|
889
|
+
},
|
|
890
|
+
busy
|
|
891
|
+
? h('span', { style: { fontSize: 10 } }, '…')
|
|
892
|
+
: h('svg', {
|
|
893
|
+
width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor',
|
|
894
|
+
'stroke-width': 2.2, 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
|
|
895
|
+
}, h('path', { d: 'M12 19V5M6 11l6-6 6 6' })),
|
|
896
|
+
),
|
|
897
|
+
),
|
|
898
|
+
|
|
899
|
+
// ── 设置面板(在条子下方展开,不进布局流的高度预算) ────────
|
|
900
|
+
open ? h('div', {
|
|
901
|
+
style: {
|
|
902
|
+
marginTop: 6, padding: '9px 10px', borderRadius: 10,
|
|
903
|
+
border: '1px solid ' + C.line, background: C.bg,
|
|
904
|
+
boxShadow: '0 8px 24px rgba(0,0,0,.28)',
|
|
905
|
+
},
|
|
906
|
+
},
|
|
907
|
+
h('div', { style: { fontSize: 10.5, color: C.faint, marginBottom: 5, letterSpacing: '.3px' } }, '理解方式'),
|
|
908
|
+
optionRow('规则式 · 不花 token', '本地词表匹配,毫秒级、离线、结果可复现', mode === 'rules', () => chooseMode('rules')),
|
|
909
|
+
optionRow('模型理解', '把你的话交给模型翻译成同一个动作意图;每句花一次调用', mode === 'model', () => chooseMode('model')),
|
|
910
|
+
|
|
911
|
+
mode === 'model' ? h('div', { style: { marginTop: 8, paddingTop: 8, borderTop: '1px solid ' + C.line } },
|
|
912
|
+
catalog === null ? h('div', { style: { fontSize: 11, color: C.faint } }, '正在读取模型列表…') : null,
|
|
913
|
+
catalog && catalog.available === false ? h('div', { style: { fontSize: 11, color: C.warning, lineHeight: 1.6 } },
|
|
914
|
+
'宿主报告没有可用的模型服务', catalog.error ? '(' + catalog.error + ')' : '',
|
|
915
|
+
'。切回「规则式」照常可用。') : null,
|
|
916
|
+
catalog && catalog.available && !providers.length ? h('div', { style: { fontSize: 11, color: C.warning } },
|
|
917
|
+
'模型服务在,但没有列出任何已配置的模型。') : null,
|
|
918
|
+
catalog && catalog.available && providers.length ? h('div', null,
|
|
919
|
+
h('div', { style: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', marginBottom: 6 } },
|
|
920
|
+
capsule('只看免费 · ' + freeCount, freeOnly, () => setFreeOnly((v) => !v), '只列名字带 free/免费、或过半模型带 :free 的 provider'),
|
|
921
|
+
h('span', { style: { fontSize: 10.5, color: C.faint } }, providers.length + ' 个 provider'),
|
|
922
|
+
),
|
|
923
|
+
h('div', { style: { maxHeight: 150, overflowY: 'auto', marginBottom: 6 } },
|
|
924
|
+
shownProviders.length
|
|
925
|
+
? h('div', { style: { display: 'flex', gap: 5, flexWrap: 'wrap' } },
|
|
926
|
+
...shownProviders.map((p) => capsule(p.name || p.id, !!(model && model.provider === p.id),
|
|
927
|
+
() => { const next = { provider: p.id, model: p.models[0].id }; setModel(next); cache.model = next; },
|
|
928
|
+
p.id + ' · ' + p.models.length + ' 个模型')))
|
|
929
|
+
: h('div', { style: { fontSize: 11, color: C.faint } }, '这个筛选下没有 provider,取消「只看免费」看看全部。'),
|
|
930
|
+
),
|
|
931
|
+
activeProvider ? h('div', null,
|
|
932
|
+
h('div', { style: { fontSize: 10.5, color: C.faint, marginBottom: 4 } },
|
|
933
|
+
String(activeProvider.name || activeProvider.id) + ' 的模型'),
|
|
934
|
+
h('div', { style: { display: 'flex', gap: 5, flexWrap: 'wrap', maxHeight: 108, overflowY: 'auto' } },
|
|
935
|
+
...modelsOf.map((m) => capsule(m.name || m.id, !!(model && model.provider === activeProvider.id && model.model === m.id),
|
|
936
|
+
() => { const next = { provider: activeProvider.id, model: m.id }; setModel(next); cache.model = next; },
|
|
937
|
+
m.id))),
|
|
938
|
+
) : null,
|
|
939
|
+
) : null,
|
|
940
|
+
h('div', { style: { fontSize: 10.5, color: C.faint, marginTop: 8, lineHeight: 1.55 } },
|
|
941
|
+
'选中的模型只用来「翻译你那句话」:它拿到的是一次性提示,不持有工具、不写文件、不碰授权。结果仍是一个动作意图,要你点两次才会真的执行。'),
|
|
942
|
+
) : null,
|
|
943
|
+
) : null,
|
|
944
|
+
|
|
945
|
+
// ── 一行小字(不再是三行说明) ─────────────────────────────
|
|
946
|
+
h('div', {
|
|
947
|
+
style: { fontSize: 10.5, color: C.faint, marginTop: 5, paddingLeft: 2, display: 'flex', gap: 8, flexWrap: 'wrap' },
|
|
948
|
+
},
|
|
949
|
+
h('span', null, 'Enter 发送'),
|
|
950
|
+
h('span', null, '·'),
|
|
951
|
+
h('span', null, mode === 'model' ? '模型只翻译,不动手' : '本地解析,零 token'),
|
|
952
|
+
h('span', null, '·'),
|
|
953
|
+
h('span', null, '动手前会先给零写入预览'),
|
|
954
|
+
),
|
|
955
|
+
|
|
956
|
+
note ? h('div', {
|
|
957
|
+
style: {
|
|
958
|
+
marginTop: 8, padding: '8px 10px', borderRadius: 10,
|
|
959
|
+
background: 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.08))', borderLeft: '2px solid ' + tone,
|
|
960
|
+
fontSize: 12, lineHeight: 1.65, whiteSpace: 'pre-wrap',
|
|
961
|
+
},
|
|
962
|
+
},
|
|
963
|
+
...note.lines.map((l, i) => h('div', { key: i, style: { color: note.kind === 'error' ? C.critical : C.text } }, l)),
|
|
964
|
+
note.suggestions && note.suggestions.length ? h('div', { style: { marginTop: 6, display: 'flex', gap: 6, flexWrap: 'wrap' } },
|
|
965
|
+
note.suggestions.map((s, i) => h('button', {
|
|
966
|
+
key: i,
|
|
967
|
+
onClick: () => { setValue(s); if (inputRef.current) inputRef.current.focus(); },
|
|
968
|
+
style: {
|
|
969
|
+
cursor: 'pointer', fontSize: 10.5, color: C.info, background: 'transparent',
|
|
970
|
+
border: '1px dashed ' + C.line, borderRadius: 6, padding: '2px 7px', fontFamily: 'inherit',
|
|
971
|
+
},
|
|
972
|
+
}, s))) : null,
|
|
973
|
+
) : null,
|
|
974
|
+
|
|
975
|
+
pending ? h('div', {
|
|
976
|
+
style: {
|
|
977
|
+
marginTop: 8, padding: '9px 10px', borderRadius: 10,
|
|
978
|
+
border: '1px solid ' + C.line, background: C.fill, fontSize: 12, lineHeight: 1.65,
|
|
979
|
+
},
|
|
980
|
+
},
|
|
981
|
+
...(Array.isArray(pending.human) ? pending.human : []).map((l, i) => h('div', { key: i }, l)),
|
|
982
|
+
h('div', { style: { marginTop: 8, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
|
|
983
|
+
h(Btn, { onClick: confirmPending, disabled: busy, primary: true }, busy ? '执行中…' : '确认执行'),
|
|
984
|
+
h('span', { style: { fontSize: 10.5, color: C.faint } }, '指纹 ' + pending.planHash + ' · 一次性')),
|
|
985
|
+
) : null,
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// v0.8.0 设置页:控件由 host 下发的 SPEC 驱动(标签/范围/解释只有一份)
|
|
990
|
+
function SettingsTab(props) {
|
|
991
|
+
const [groups, setGroups] = useState(null);
|
|
992
|
+
const [draft, setDraft] = useState({});
|
|
993
|
+
const [error, setError] = useState("");
|
|
994
|
+
const [saveError, setSaveError] = useState("");
|
|
995
|
+
const [saved, setSaved] = useState("");
|
|
996
|
+
const [loading, setLoading] = useState(false);
|
|
997
|
+
const flatOf = (gs) => { const d = {}; (gs || []).forEach((g) => (g.items || []).forEach((it) => { d[it.key] = it.value; })); return d; };
|
|
998
|
+
const load = useCallback(async () => {
|
|
999
|
+
setLoading(true); setError("");
|
|
1000
|
+
try {
|
|
1001
|
+
const res = await call("settings");
|
|
1002
|
+
const gs = Array.isArray(res.groups) ? res.groups : [];
|
|
1003
|
+
setGroups(gs);
|
|
1004
|
+
setDraft(flatOf(gs));
|
|
1005
|
+
setSaved(res.savedAt ? "上次保存:" + String(res.savedAt).slice(0, 16).replace("T", " ") : "还没改过(全部是推荐默认值)");
|
|
1006
|
+
} catch (e) { setError(e && e.message ? e.message : String(e)); }
|
|
1007
|
+
finally { setLoading(false); }
|
|
1008
|
+
}, []);
|
|
1009
|
+
useEffect(() => { void load(); }, [load]);
|
|
1010
|
+
const setVal = (k, v) => setDraft((prev) => { const next = Object.assign({}, prev); next[k] = v; return next; });
|
|
1011
|
+
const save = async () => {
|
|
1012
|
+
setLoading(true); setError(""); setSaveError("");
|
|
1013
|
+
try {
|
|
1014
|
+
const res = await call("settings-save", { settings: draft });
|
|
1015
|
+
if (res && res.ok) {
|
|
1016
|
+
const gs = Array.isArray(res.groups) ? res.groups : [];
|
|
1017
|
+
setGroups(gs);
|
|
1018
|
+
setDraft(flatOf(gs));
|
|
1019
|
+
setSaved((res.human || []).join(" "));
|
|
1020
|
+
// v0.8.4:入口类设置就地生效。旧版要用户先 Ctrl+R,桌面版里那步基本等于死路。
|
|
1021
|
+
try {
|
|
1022
|
+
const entry = res.settings && res.settings.ui && res.settings.ui.entry;
|
|
1023
|
+
if (typeof ENTRY_APPLIER === 'function' && (entry === 'left' || entry === 'right' || entry === 'both')) {
|
|
1024
|
+
ENTRY_APPLIER(entry);
|
|
1025
|
+
}
|
|
1026
|
+
} catch { /* 入口重挂失败不影响设置已保存 */ }
|
|
1027
|
+
if (props && props.onSaved) props.onSaved();
|
|
1028
|
+
} else { setSaveError((res && res.error) || "保存失败"); }
|
|
1029
|
+
} catch (e) { setSaveError(e && e.message ? e.message : String(e)); }
|
|
1030
|
+
finally { setLoading(false); }
|
|
1031
|
+
};
|
|
1032
|
+
const reset = () => {
|
|
1033
|
+
const d = {};
|
|
1034
|
+
(groups || []).forEach((g) => (g.items || []).forEach((it) => { d[it.key] = it.def; }));
|
|
1035
|
+
setDraft(d);
|
|
1036
|
+
setSaved("已填回默认值——还要点「保存设置」才会生效。");
|
|
1037
|
+
};
|
|
1038
|
+
// 读失败才允许整页换成错误:那时没有表单可留。保存失败必须留在表单上并保留已填的值,
|
|
1039
|
+
// 否则用户辛苦改的一堆数字会随着一条提示一起消失。
|
|
1040
|
+
if (error) return h("div", { style: { marginTop: 12, fontSize: 12, color: C.critical } }, "读取失败: " + error);
|
|
1041
|
+
if (!groups) return h("div", { style: { marginTop: 12, fontSize: 12, color: C.sub } }, loading ? "读取中…" : "没有设置项");
|
|
1042
|
+
return h("div", { style: { marginTop: 12 } },
|
|
1043
|
+
h("div", { style: { fontSize: 12, color: C.sub, lineHeight: 1.7, margin: "2px 0 12px", padding: "8px 10px", borderLeft: "2px solid " + C.info, background: C.fill, borderRadius: 6 } },
|
|
1044
|
+
"这里只放开「判据的松紧」——能把 7 天改 3 天,不能把某项体检关掉。", h("br"),
|
|
1045
|
+
"超出范围会被夹到边界并告出来,不会静默接受一个会让检测失真的值。默认值就是推荐值,不改就和以前一样。"),
|
|
1046
|
+
...groups.map((g) => h("div", { key: g.name, style: { marginBottom: 16 } },
|
|
1047
|
+
h("div", { style: { fontSize: 12.5, fontWeight: 600, marginBottom: 6, color: C.text } }, g.name),
|
|
1048
|
+
...(g.items || []).map((it) => {
|
|
1049
|
+
const changed = draft[it.key] !== it.def;
|
|
1050
|
+
const isChoice = it.type === 'choice';
|
|
1051
|
+
const rangeLine = isChoice
|
|
1052
|
+
? '可选 ' + (it.options || []).map((o) => o.label).join(' / ')
|
|
1053
|
+
: '范围 ' + it.min + '–' + it.max + (it.unit ? ' ' + it.unit : '');
|
|
1054
|
+
const defLabel = isChoice
|
|
1055
|
+
? ((it.options || []).find((o) => o.value === it.def) || {}).label || String(it.def)
|
|
1056
|
+
: String(it.def);
|
|
1057
|
+
return h("div", { key: it.key, style: { display: "flex", gap: 8, alignItems: "flex-start", padding: "6px 8px", borderRadius: 6, background: changed ? mix(C.info, 8) : "transparent", borderLeft: "2px solid " + (changed ? C.info : "transparent") } },
|
|
1058
|
+
h("div", { style: { flex: "1 1 auto", minWidth: 0 } },
|
|
1059
|
+
h("div", { style: { fontSize: 12.5 } }, it.label, changed ? h("span", { style: { fontSize: 10.5, color: C.info, marginLeft: 6 } }, "已改") : null),
|
|
1060
|
+
h("div", { style: { fontSize: 11, color: C.faint, lineHeight: 1.55, marginTop: 2 } }, it.why || ""),
|
|
1061
|
+
h("div", { style: { fontSize: 10.5, color: C.faint, marginTop: 2 } }, rangeLine + " · 默认 " + defLabel),
|
|
1062
|
+
),
|
|
1063
|
+
isChoice
|
|
1064
|
+
? h("div", { style: { display: "flex", alignItems: "center", gap: 4, flex: "0 0 auto", flexWrap: "wrap", justifyContent: "flex-end", maxWidth: 260 } },
|
|
1065
|
+
...(it.options || []).map((o) => h("button", {
|
|
1066
|
+
key: o.value,
|
|
1067
|
+
onClick: () => setVal(it.key, o.value),
|
|
1068
|
+
style: {
|
|
1069
|
+
cursor: "pointer", fontFamily: "inherit", fontSize: 11.5, padding: "4px 10px", borderRadius: 7,
|
|
1070
|
+
border: "1px solid " + (draft[it.key] === o.value ? "transparent" : C.line),
|
|
1071
|
+
background: draft[it.key] === o.value ? C.solid : "transparent",
|
|
1072
|
+
color: draft[it.key] === o.value ? C.on : C.sub,
|
|
1073
|
+
fontWeight: draft[it.key] === o.value ? 600 : 400,
|
|
1074
|
+
},
|
|
1075
|
+
}, o.label)),
|
|
1076
|
+
)
|
|
1077
|
+
: h("div", { style: { display: "flex", alignItems: "center", gap: 4, flex: "0 0 auto" } },
|
|
1078
|
+
h("input", {
|
|
1079
|
+
type: "number", value: draft[it.key] === undefined ? "" : draft[it.key], min: it.min, max: it.max,
|
|
1080
|
+
onChange: (e) => setVal(it.key, e.target.value === "" ? "" : Number(e.target.value)),
|
|
1081
|
+
style: { width: 66, padding: "3px 6px", borderRadius: 6, fontSize: 12, textAlign: "right", border: "1px solid " + C.line, background: C.bg, color: C.text },
|
|
1082
|
+
}),
|
|
1083
|
+
h("span", { style: { fontSize: 10.5, color: C.faint, minWidth: 28 } }, it.unit || ""),
|
|
1084
|
+
),
|
|
1085
|
+
);
|
|
1086
|
+
}),
|
|
1087
|
+
)),
|
|
1088
|
+
h("div", { style: { display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap", marginTop: 4 } },
|
|
1089
|
+
h(Btn, { onClick: save, disabled: loading, primary: true }, loading ? "保存中…" : "保存设置"),
|
|
1090
|
+
h(Btn, { onClick: load, disabled: loading }, "重新读取"),
|
|
1091
|
+
h(Btn, { onClick: reset, disabled: loading }, "填回默认值"),
|
|
1092
|
+
),
|
|
1093
|
+
saved ? h("div", { style: { fontSize: 11.5, color: C.sub, marginTop: 8, padding: "7px 9px", borderRadius: 6, background: C.fill, borderLeft: "2px solid " + C.ok } }, saved) : null,
|
|
1094
|
+
saveError ? h("div", { style: { fontSize: 11.5, color: C.critical, marginTop: 8, padding: "7px 9px", borderRadius: 6, background: C.fill, borderLeft: "2px solid " + C.critical } }, "保存失败: " + saveError + "(已填的值还在,改一下再存即可)") : null,
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function ToastCard(props) {
|
|
1099
|
+
const t = props.toast;
|
|
1100
|
+
const total = Math.max(3, props.seconds || 12);
|
|
1101
|
+
const [left, setLeft] = React.useState(total);
|
|
1102
|
+
const [paused, setPaused] = React.useState(false);
|
|
1103
|
+
React.useEffect(() => {
|
|
1104
|
+
if (paused) return undefined;
|
|
1105
|
+
if (left <= 0) { props.onDismiss(); return undefined; }
|
|
1106
|
+
const timer = setTimeout(() => { setLeft((v) => v - 1); }, 1000);
|
|
1107
|
+
return () => clearTimeout(timer);
|
|
1108
|
+
}, [left, paused]);
|
|
1109
|
+
const tone = t.severity === 'critical' ? C.critical : C.warning;
|
|
1110
|
+
return h('div', {
|
|
1111
|
+
// 鼠标移上去暂停计时:正在读的时候消失,比不弹更烦人。
|
|
1112
|
+
onMouseEnter: () => setPaused(true),
|
|
1113
|
+
onMouseLeave: () => setPaused(false),
|
|
1114
|
+
style: {
|
|
1115
|
+
position: 'relative', maxWidth: 340, borderRadius: 12, padding: '10px 12px 9px 14px',
|
|
1116
|
+
border: '1px solid ' + C.line, borderLeft: '3px solid ' + tone,
|
|
1117
|
+
background: C.bg, color: C.text, fontSize: 12.5, lineHeight: 1.55,
|
|
1118
|
+
boxShadow: '0 10px 30px rgba(0,0,0,.28)', overflow: 'hidden',
|
|
1119
|
+
},
|
|
1120
|
+
},
|
|
1121
|
+
h('div', { style: { display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3 } },
|
|
1122
|
+
h('span', { style: { fontSize: 10.5, color: tone, fontWeight: 600, flex: '0 0 auto' } }, t.severity === 'critical' ? '严重' : '留意'),
|
|
1123
|
+
h('span', { style: { fontSize: 13, fontWeight: 600, flex: '1 1 auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, t.title),
|
|
1124
|
+
h('button', {
|
|
1125
|
+
onClick: props.onDismiss, title: '关闭',
|
|
1126
|
+
style: { cursor: 'pointer', border: 'none', background: 'transparent', color: C.faint, fontSize: 15, padding: '0 2px', lineHeight: 1, flex: '0 0 auto' },
|
|
1127
|
+
}, '×'),
|
|
1128
|
+
),
|
|
1129
|
+
t.body ? h('div', { style: { fontSize: 11.5, color: C.sub, marginBottom: 7, whiteSpace: 'pre-wrap' } }, t.body) : null,
|
|
1130
|
+
h('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
|
|
1131
|
+
h('button', {
|
|
1132
|
+
onClick: () => { props.onOpen(); props.onDismiss(); },
|
|
1133
|
+
style: { cursor: 'pointer', fontSize: 11.5, padding: '3px 10px', borderRadius: 6, border: '1px solid ' + tone, background: 'transparent', color: tone },
|
|
1134
|
+
}, '去看看'),
|
|
1135
|
+
h('button', {
|
|
1136
|
+
onClick: () => { props.onAck(); props.onDismiss(); },
|
|
1137
|
+
title: '静音这条,直到有效期结束',
|
|
1138
|
+
style: { cursor: 'pointer', fontSize: 11.5, padding: '3px 10px', borderRadius: 6, border: '1px solid ' + C.line, background: 'transparent', color: C.sub },
|
|
1139
|
+
}, '已知晓'),
|
|
1140
|
+
h('span', { style: { flex: '1 1 auto' } }),
|
|
1141
|
+
h('span', { style: { fontSize: 10, color: C.faint, flex: '0 0 auto' } }, paused ? '暂停' : left + 's'),
|
|
1142
|
+
),
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// 弹窗宿主:挂在 shell.overlay(frame-wide 浮层,replaceRisk none)。
|
|
1147
|
+
// 它自己读一次设置 + 一次报告,此后靠 publishReport 被动更新。
|
|
1148
|
+
function ToastHost(props) {
|
|
1149
|
+
const [, force] = React.useState(0);
|
|
1150
|
+
React.useEffect(() => {
|
|
1151
|
+
const fn = () => force((v) => v + 1);
|
|
1152
|
+
toastListeners.add(fn);
|
|
1153
|
+
return () => { toastListeners.delete(fn); };
|
|
1154
|
+
}, []);
|
|
1155
|
+
React.useEffect(() => {
|
|
1156
|
+
let alive = true;
|
|
1157
|
+
const onReport = (report) => {
|
|
1158
|
+
if (!alive) return;
|
|
1159
|
+
try { evaluateToasts(report); } catch { /* noop */ }
|
|
1160
|
+
};
|
|
1161
|
+
reportListeners.add(onReport);
|
|
1162
|
+
void (async () => {
|
|
1163
|
+
try {
|
|
1164
|
+
const res = await call('settings');
|
|
1165
|
+
const ui = res && res.settings && res.settings.ui ? res.settings.ui : null;
|
|
1166
|
+
if (ui && (ui.toast === 'critical' || ui.toast === 'warning' || ui.toast === 'off')) toastConfig.mode = ui.toast;
|
|
1167
|
+
if (ui && Number.isFinite(ui.toastSeconds)) toastConfig.seconds = ui.toastSeconds;
|
|
1168
|
+
} catch { /* 读不到设置就用默认:只弹严重、12 秒 */ }
|
|
1169
|
+
// 面板已经体检过就直接复用,不为弹窗再跑一次全量采集。
|
|
1170
|
+
if (alive && cache.report) { try { evaluateToasts(cache.report); } catch { /* noop */ } return; }
|
|
1171
|
+
// 面板没开(用户没点开管家)时,弹窗仍然要能提醒——这正是它存在的理由。
|
|
1172
|
+
// 只在挂载后取一次,不做轮询:提醒的及时性来自「页面打开时立刻看一遍」,
|
|
1173
|
+
// 而不是把体检变成后台常驻任务。
|
|
1174
|
+
if (!alive || toastConfig.mode === 'off') return;
|
|
1175
|
+
try {
|
|
1176
|
+
const data = await call('report');
|
|
1177
|
+
if (!alive) return;
|
|
1178
|
+
if (data && data.report) { cache.report = data.report; evaluateToasts(data.report); }
|
|
1179
|
+
} catch { /* 采集失败不弹任何东西——宁可不提醒,也不误报 */ }
|
|
1180
|
+
})();
|
|
1181
|
+
return () => { alive = false; reportListeners.delete(onReport); };
|
|
1182
|
+
}, []);
|
|
1183
|
+
const items = toastStore.items;
|
|
1184
|
+
if (!items.length) return null;
|
|
1185
|
+
return h('div', {
|
|
1186
|
+
style: {
|
|
1187
|
+
position: 'fixed', right: 18, bottom: 18, zIndex: 1200,
|
|
1188
|
+
display: 'flex', flexDirection: 'column', gap: 10, alignItems: 'flex-end',
|
|
1189
|
+
maxWidth: 'min(360px, calc(100vw - 36px))',
|
|
1190
|
+
},
|
|
1191
|
+
}, items.map((t) => h(ToastCard, {
|
|
1192
|
+
key: t.id, toast: t, seconds: toastConfig.seconds,
|
|
1193
|
+
onDismiss: () => dismissToast(t.id),
|
|
1194
|
+
onOpen: () => { try { if (props && props.onOpen) props.onOpen(t.actionKey); } catch { /* noop */ } },
|
|
1195
|
+
onAck: () => { try { if (props && props.onAck) props.onAck(t.findingId); } catch { /* noop */ } },
|
|
1196
|
+
})));
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function Panel(props) {
|
|
1200
|
+
const compactChrome = props && props.host === 'sidebar';
|
|
1201
|
+
const [report, setReport] = useState(cache.report);
|
|
1202
|
+
const [bundles, setBundles] = useState(null);
|
|
1203
|
+
const [loading, setLoading] = useState(false);
|
|
1204
|
+
const [error, setError] = useState(cache.error);
|
|
1205
|
+
const [cardKey, setCardKey] = useState(0);
|
|
1206
|
+
const [tab, setTab] = useState('overview');
|
|
1207
|
+
const [autoOpen, setAutoOpen] = useState('');
|
|
1208
|
+
const [nonce, setNonce] = useState(0);
|
|
1209
|
+
const [expandedGroups, setExpandedGroups] = useState({});
|
|
1210
|
+
const [hidden, setHidden] = useState(cache.hidden);
|
|
1211
|
+
const toggleHidden = (label) => {
|
|
1212
|
+
const next = { ...hidden, [label]: !hidden[label] };
|
|
1213
|
+
cache.hidden = next;
|
|
1214
|
+
setHidden(next);
|
|
1215
|
+
};
|
|
1216
|
+
|
|
1217
|
+
const scan = useCallback(async () => {
|
|
1218
|
+
setLoading(true); setError('');
|
|
1219
|
+
try {
|
|
1220
|
+
const data = await call('report');
|
|
1221
|
+
try { publishReport(data && data.report); } catch { /* noop */ }
|
|
1222
|
+
cache.report = data.report || null;
|
|
1223
|
+
cache.error = '';
|
|
1224
|
+
setReport(cache.report);
|
|
1225
|
+
} catch (e) {
|
|
1226
|
+
cache.report = null;
|
|
1227
|
+
cache.error = e && e.message ? e.message : String(e);
|
|
1228
|
+
setReport(null);
|
|
1229
|
+
setError(cache.error);
|
|
1230
|
+
} finally { setLoading(false); }
|
|
1231
|
+
// v0.4.4: 启用动作要的是「当前已停用的插件」,这是独立于崩溃报告的
|
|
1232
|
+
// 事实。只读查询,服务缺席/失败就退化成没有候选,不影响体检本体。
|
|
1233
|
+
try { setBundles(await call('bundles')); } catch { setBundles(null); }
|
|
1234
|
+
}, []);
|
|
1235
|
+
|
|
1236
|
+
// 回到面板不再要求手点:无缓存→挂载即自动体检一次;缓存超过 10 分钟→后台刷新。
|
|
1237
|
+
// 先例:disk-cleaner 面板同样挂载即扫,同一 GUI 正常运行——二分定案证明卡死元凶不是自动扫描。
|
|
1238
|
+
useEffect(() => {
|
|
1239
|
+
if (!cache.report) { void scan(); return; }
|
|
1240
|
+
const age = Date.now() - Date.parse(String(cache.report.generatedAt || ''));
|
|
1241
|
+
if (Number.isFinite(age) && age > 10 * 60 * 1000) void scan();
|
|
1242
|
+
}, [scan]);
|
|
1243
|
+
|
|
1244
|
+
const crashFinding = report && Array.isArray(report.findings)
|
|
1245
|
+
? report.findings.find((f) => f.id === 'boot-crash-latest') : null;
|
|
1246
|
+
const crashData = crashFinding && crashFinding.data ? crashFinding.data : null;
|
|
1247
|
+
const suspectNames = crashData && Array.isArray(crashData.suspects)
|
|
1248
|
+
? crashData.suspects.filter(Boolean) : [];
|
|
1249
|
+
const crashStale = !!(crashData && crashData.stale);
|
|
1250
|
+
const disableBlocked = !crashFinding || crashStale || suspectNames.length === 0;
|
|
1251
|
+
// v0.4.4:启用只对「已停用」的 bundle 有意义。已停用清单来自只读 bundles
|
|
1252
|
+
// 查询,跟崩溃报告无关——旧版拿崩溃疑似插件当启用对象,必然自相矛盾。
|
|
1253
|
+
const disabledBundles = bundles && Array.isArray(bundles.disabled) ? bundles.disabled : [];
|
|
1254
|
+
const enableBlocked = disabledBundles.length === 0;
|
|
1255
|
+
|
|
1256
|
+
const rescan = useCallback(() => { setCardKey((k) => k + 1); void scan(); }, [scan]);
|
|
1257
|
+
const ackIt = useCallback(async (finding) => {
|
|
1258
|
+
try {
|
|
1259
|
+
const p = await call('plan', { kind: 'ack', id: finding.id });
|
|
1260
|
+
if (p.ok === false) throw new Error(p.error || 'plan 失败');
|
|
1261
|
+
await call('execute', { planHash: p.planHash, confirm: true });
|
|
1262
|
+
await scan();
|
|
1263
|
+
} catch (e) { setError(e && e.message ? e.message : String(e)); }
|
|
1264
|
+
}, [scan]);
|
|
1265
|
+
const unackIt = useCallback(async (finding) => {
|
|
1266
|
+
try {
|
|
1267
|
+
const p = await call('plan', { kind: 'unack', id: finding.id });
|
|
1268
|
+
if (p.ok === false) throw new Error(p.error || 'plan 失败');
|
|
1269
|
+
await call('execute', { planHash: p.planHash, confirm: true });
|
|
1270
|
+
await scan();
|
|
1271
|
+
} catch (e) { setError(e && e.message ? e.message : String(e)); }
|
|
1272
|
+
}, [scan]);
|
|
1273
|
+
|
|
1274
|
+
// 把面板内的能力交给 overlay 里的弹窗宿主。
|
|
1275
|
+
React.useEffect(() => {
|
|
1276
|
+
PANEL_NAVIGATOR = (actionKey) => { try { jumpToActionRef.current(actionKey); } catch { /* noop */ } };
|
|
1277
|
+
PANEL_ACK = (findingId) => { try { ackItRef.current(findingId); } catch { /* noop */ } };
|
|
1278
|
+
return () => { PANEL_NAVIGATOR = null; PANEL_ACK = null; };
|
|
1279
|
+
}, []);
|
|
1280
|
+
|
|
1281
|
+
const jumpToAction = useCallback((actionKey) => {
|
|
1282
|
+
setTab('actions');
|
|
1283
|
+
setAutoOpen(actionKey);
|
|
1284
|
+
setNonce((n) => n + 1);
|
|
1285
|
+
}, []);
|
|
1286
|
+
|
|
1287
|
+
// 上面那个 effect 用 ref 间接调用,避免把 useCallback 的依赖卷进 effect。
|
|
1288
|
+
const jumpToActionRef = useRef(jumpToAction);
|
|
1289
|
+
jumpToActionRef.current = jumpToAction;
|
|
1290
|
+
const ackItRef = useRef(typeof ackIt === 'function' ? ackIt : null);
|
|
1291
|
+
ackItRef.current = typeof ackIt === 'function' ? ackIt : null;
|
|
1292
|
+
|
|
1293
|
+
const counts = report && report.summary ? report.summary.counts : null;
|
|
1294
|
+
const level = report && report.summary ? report.summary.level : null;
|
|
1295
|
+
const levelColor = (SEV[level] || SEV.info).color;
|
|
1296
|
+
|
|
1297
|
+
// 优先级排序:静音恒垫底;其余按重要性分数降序(严重/留意/提示是
|
|
1298
|
+
// 分级底色,分数才是同组内比较的依据)。
|
|
1299
|
+
const SEV_RANK = { critical: 0, warning: 1, info: 2 };
|
|
1300
|
+
const tierOf = (f) => (f.muted ? 3 : (SEV_RANK[f.severity] == null ? 2 : SEV_RANK[f.severity]));
|
|
1301
|
+
const scoreOf = (f) => (typeof f.score === 'number' ? f.score : 0);
|
|
1302
|
+
const orderedFindings = report && Array.isArray(report.findings)
|
|
1303
|
+
? report.findings.map((f, i) => ({ f, i })).sort((a, b) => {
|
|
1304
|
+
const ra = tierOf(a.f); const rb = tierOf(b.f);
|
|
1305
|
+
if (ra !== rb) return ra - rb;
|
|
1306
|
+
if (scoreOf(a.f) !== scoreOf(b.f)) return scoreOf(b.f) - scoreOf(a.f);
|
|
1307
|
+
return a.i - b.i; // backend order for equal scores (stable)
|
|
1308
|
+
}).map((x) => x.f)
|
|
1309
|
+
: [];
|
|
1310
|
+
// 同组超过 COLLAPSE_PREVIEW 条即提供折叠/展开(默认只露前 2 条)。
|
|
1311
|
+
const COLLAPSE_PREVIEW = 2;
|
|
1312
|
+
const findingNode = (f, i, compact, label) => h(Finding, {
|
|
1313
|
+
key: (f.id || 'f') + '-' + i + (compact ? '-c' : ''),
|
|
1314
|
+
compact: compact === true,
|
|
1315
|
+
onToggle: compact ? () => setExpandedGroups((m) => ({ ...m, [label]: true })) : null,
|
|
1316
|
+
finding: f,
|
|
1317
|
+
onAck: !f.muted ? () => ackIt(f) : null,
|
|
1318
|
+
onUnack: f.muted ? () => unackIt(f) : null,
|
|
1319
|
+
onGo: ACTION_FOR[f.id] && !f.muted && f.severity !== 'info' ? () => jumpToAction(ACTION_FOR[f.id]) : null,
|
|
1320
|
+
goLabel: f.id === 'data-at-risk' ? '去备份 →' : f.id === 'boot-crash-latest' ? '去崩溃处置 →' : f.id === 'disk-advice' ? '去看可清理 →' : null,
|
|
1321
|
+
});
|
|
1322
|
+
const groupBlock = (label, color, list) => {
|
|
1323
|
+
if (!list.length) return null;
|
|
1324
|
+
if (hidden[label]) {
|
|
1325
|
+
return h('div', { key: label, style: { marginBottom: 6 } },
|
|
1326
|
+
h('button', {
|
|
1327
|
+
onClick: () => toggleHidden(label),
|
|
1328
|
+
style: { cursor: 'pointer', fontSize: 11.5, color: C.faint, background: C.fill, border: '1px dashed ' + C.line, borderRadius: 6, padding: '5px 9px', width: '100%', textAlign: 'left' },
|
|
1329
|
+
}, '🙈 已隐藏「' + label + '」' + list.length + ' 项 · 点击显示'));
|
|
1330
|
+
}
|
|
1331
|
+
// 折叠语义(v0.7.5 修):以前是「只露前 2 张完整卡 + 其余消失」,
|
|
1332
|
+
// 所以同类项一折叠就只剩一条能看。现在两条路完全不同:
|
|
1333
|
+
// 折叠 = 该组所有并列项各压成一条两行摘要(一条不少、一眼扫完);
|
|
1334
|
+
// 展开 = 才切回完整卡片(证据/建议/动作按钮)。
|
|
1335
|
+
// 「严重」组默认展开——要处置的东西不该藏在折叠里。
|
|
1336
|
+
const canToggle = list.length > 1;
|
|
1337
|
+
const open = expandedGroups[label] === undefined ? label === '严重' : expandedGroups[label] === true;
|
|
1338
|
+
return h('div', { key: label, style: { marginBottom: 10 } },
|
|
1339
|
+
h('div', { style: { display: 'flex', alignItems: 'center', gap: 6, margin: '4px 0 7px' } },
|
|
1340
|
+
h('span', { style: { fontSize: 11, fontWeight: 600, color, letterSpacing: 0.5 } }, label),
|
|
1341
|
+
h('span', { style: { fontSize: 11, color: C.faint } }, '· ' + list.length + ' 项'),
|
|
1342
|
+
canToggle ? h('button', {
|
|
1343
|
+
onClick: () => setExpandedGroups((m) => ({ ...m, [label]: !(m[label] === undefined ? label === '严重' : m[label] === true) })),
|
|
1344
|
+
style: { cursor: 'pointer', fontSize: 11, color: C.info, background: 'transparent', border: 'none', padding: '0 4px' },
|
|
1345
|
+
}, open ? '收起详情 ⌃' : '展开详情 ⌄') : null),
|
|
1346
|
+
open
|
|
1347
|
+
? list.map((f, i) => findingNode(f, i, false, label))
|
|
1348
|
+
: h('div', { style: { display: 'flex', flexDirection: 'column', gap: 5 } },
|
|
1349
|
+
...list.map((f, i) => findingNode(f, i, true, label)))
|
|
1350
|
+
);
|
|
1351
|
+
};
|
|
1352
|
+
|
|
1353
|
+
const tabBtn = (key, label) => h('button', {
|
|
1354
|
+
key, onClick: () => setTab(key),
|
|
1355
|
+
style: {
|
|
1356
|
+
cursor: 'pointer', fontSize: 12.5, padding: '6px 14px',
|
|
1357
|
+
border: 'none', borderBottom: tab === key ? '2px solid ' + C.ok : '2px solid transparent',
|
|
1358
|
+
background: 'transparent', color: tab === key ? C.text : C.sub, fontWeight: tab === key ? 600 : 400,
|
|
1359
|
+
},
|
|
1360
|
+
}, label);
|
|
1361
|
+
|
|
1362
|
+
return h('div', {
|
|
1363
|
+
style: {
|
|
1364
|
+
boxSizing: 'border-box', height: '100%', overflowY: 'auto',
|
|
1365
|
+
padding: compactChrome ? '12px 14px 24px' : '18px 22px 34px',
|
|
1366
|
+
color: C.text, fontSize: 13, lineHeight: 1.6,
|
|
1367
|
+
},
|
|
1368
|
+
},
|
|
1369
|
+
// sticky KPI 头:级别 + 计数 + 体检按钮,滚动不丢
|
|
1370
|
+
h('div', {
|
|
1371
|
+
style: {
|
|
1372
|
+
position: 'sticky', top: 0, zIndex: 5, background: C.bg,
|
|
1373
|
+
paddingBottom: 8, marginBottom: 0, borderBottom: '1px solid ' + C.line,
|
|
1374
|
+
},
|
|
1375
|
+
},
|
|
1376
|
+
h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
|
|
1377
|
+
h('h2', { style: { margin: 0, fontSize: 16, fontWeight: 600 } }, '智能管家'),
|
|
1378
|
+
counts ? h(Badge, { color: levelColor }, (SEV[level] || SEV.info).label) : null,
|
|
1379
|
+
counts ? h('span', { style: { fontSize: 11, color: C.sub, display: 'flex', gap: 6, flexWrap: 'wrap' } },
|
|
1380
|
+
['严重 ' + counts.critical, '留意 ' + counts.warning, '提示 ' + counts.info].concat(counts.muted ? ['静音 ' + counts.muted] : []).map((txt, i) => {
|
|
1381
|
+
const label = txt.split(' ')[0];
|
|
1382
|
+
const isH = !!hidden[label];
|
|
1383
|
+
return h('button', {
|
|
1384
|
+
key: i, onClick: () => toggleHidden(label), title: isH ? '点击显示这一组' : '点击隐藏这一组',
|
|
1385
|
+
style: { cursor: 'pointer', fontSize: 11, padding: '1px 5px', borderRadius: 5, border: 'none', background: 'transparent', color: isH ? C.faint : C.sub, textDecoration: isH ? 'line-through' : 'none' },
|
|
1386
|
+
}, txt);
|
|
1387
|
+
})) : null,
|
|
1388
|
+
h('span', { style: { flex: '1 1 auto' } }),
|
|
1389
|
+
h(Btn, { onClick: rescan, disabled: loading, primary: true }, loading ? '体检中…' : '重新体检'),
|
|
1390
|
+
),
|
|
1391
|
+
h('div', { style: { display: 'flex', gap: 2, marginTop: 6 } },
|
|
1392
|
+
tabBtn('overview', '体检总览'), tabBtn('actions', '动作中心'), tabBtn('audit', '审计趋势'), tabBtn('settings', '设置')),
|
|
1393
|
+
),
|
|
1394
|
+
error ? h('div', { style: { fontSize: 12, color: C.critical, margin: '8px 0' } }, '读取失败: ' + error) : null,
|
|
1395
|
+
|
|
1396
|
+
tab === 'overview' ? h('div', { style: { marginTop: 12 } },
|
|
1397
|
+
h(Briefing, { report, loading, onGo: jumpToAction }),
|
|
1398
|
+
h(ChatBar, { onGo: jumpToAction, onRescan: rescan, onTab: setTab }),
|
|
1399
|
+
// v0.5.0:结论行已由上方管家简报统一讲,这里只保留「需要额外提醒」的部分,
|
|
1400
|
+
// 避免两行内容相近的总结堆在一起造成冗余。
|
|
1401
|
+
report && report.summary && (report.status === 'degraded' || report.staleAfterRestart) ? h('div', {
|
|
1402
|
+
style: { fontSize: 12, padding: '7px 11px', borderRadius: 8, marginBottom: 12, border: '1px solid ' + C.line, borderLeft: '3px solid ' + C.warning, background: C.fill },
|
|
1403
|
+
},
|
|
1404
|
+
report.status === 'degraded' ? h('span', { style: { color: C.warning } }, '部分采集不可用,已降级(结论可能不完整)') : null,
|
|
1405
|
+
report.status === 'degraded' && report.staleAfterRestart ? h('span', { style: { color: C.faint } }, ' · ') : null,
|
|
1406
|
+
report.staleAfterRestart ? h('span', { style: { color: C.warning } }, '这是重启前 ' + localStamp(report.restoredFrom) + ' 的存档,仅供回看;归因动作已封锁,点右上「重新体检」') : null) : null,
|
|
1407
|
+
report && Array.isArray(report.findings) && orderedFindings.length
|
|
1408
|
+
? [
|
|
1409
|
+
groupBlock('严重', C.critical, orderedFindings.filter((f) => !f.muted && f.severity === 'critical')),
|
|
1410
|
+
groupBlock('留意', C.warning, orderedFindings.filter((f) => !f.muted && f.severity === 'warning')),
|
|
1411
|
+
groupBlock('提示', C.info, orderedFindings.filter((f) => !f.muted && f.severity !== 'critical' && f.severity !== 'warning')),
|
|
1412
|
+
groupBlock('静音', C.faint, orderedFindings.filter((f) => f.muted)),
|
|
1413
|
+
h('div', { key: 'score-legend', style: { fontSize: 10.5, color: C.faint, marginTop: 2 } },
|
|
1414
|
+
'★分数 = 重要性(0–100,组内高者在前):级别 60/40/20 + 证据新鲜度 −15~+15 + 有直达动作 +15 + 证据体量 +5~+10;鼠标悬停分数看构成。折叠态每组只显示摘要行,点「展开详情」才出证据与按钮。'),
|
|
1415
|
+
]
|
|
1416
|
+
: (loading || error ? null : h('div', {
|
|
1417
|
+
style: { fontSize: 12, color: C.faint, padding: '10px 12px', border: '1px dashed ' + C.line, borderRadius: 8 },
|
|
1418
|
+
}, '点右上角「重新体检」生成健康报告,再经「动作中心」执行授权操作。')),
|
|
1419
|
+
report && Array.isArray(report.limitations) ? h('details', { style: { marginTop: 10, fontSize: 11, color: C.faint } },
|
|
1420
|
+
h('summary', { style: { cursor: 'pointer' } }, '只读边界 · 提权命令永不代跑(点开看全部)'),
|
|
1421
|
+
h('ul', { style: { margin: '6px 0', paddingLeft: 18 } },
|
|
1422
|
+
report.limitations.map((l, i) => h('li', { key: i }, l))),
|
|
1423
|
+
h('div', {}, '动作执行全部写入 audit.jsonl,可在「审计趋势」回看。'),
|
|
1424
|
+
h('div', { style: { marginTop: 4 } }, '依赖清单与降级行为、设计取舍见插件目录 README(dsh-steward/README.md)。')) : null,
|
|
1425
|
+
report ? h('div', { style: { fontSize: 10.5, color: C.faint, marginTop: 8 } }, '生成时间 · ' + localStamp(report.generatedAt) + (agoText(report.generatedAt) ? '(' + agoText(report.generatedAt) + ')' : '')) : null,
|
|
1426
|
+
) : null,
|
|
1427
|
+
|
|
1428
|
+
tab === 'actions' ? h('div', { style: { marginTop: 12 } },
|
|
1429
|
+
h('div', { style: { fontSize: 11.5, color: C.sub, marginBottom: 8 } },
|
|
1430
|
+
'每个动作两步:预览计划(零写入)→ 确认执行(一次性指纹 + 审计留痕)。'),
|
|
1431
|
+
h(ActionRow, {
|
|
1432
|
+
key: 'clean-' + cardKey, actionKey: 'clean', autoOpen, nonce,
|
|
1433
|
+
title: '安全清理(纯缓存)', risk: 'low',
|
|
1434
|
+
hint: '只删 disk-cleaner 标记 safe 的缓存(删后可自动重建)。勾选后可把谨慎级一并纳入——明细会在计划里列全。',
|
|
1435
|
+
extraUI: (ref) => h('label', { style: { display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11.5, color: C.sub, marginTop: 6, cursor: 'pointer' } },
|
|
1436
|
+
h('input', { type: 'checkbox', style: { accentColor: C.info, width: 13, height: 13, cursor: 'pointer' }, onChange: (e) => { ref.current.withCaution = e.target.checked; } }),
|
|
1437
|
+
'一并纳入谨慎级(caution,可能仍在使用)'),
|
|
1438
|
+
planFn: (extra) => call('plan', { kind: 'clean', ...(extra && extra.withCaution ? { withCaution: true } : {}) }),
|
|
1439
|
+
onDone: scan,
|
|
1440
|
+
}),
|
|
1441
|
+
h(ActionRow, {
|
|
1442
|
+
key: 'backup-' + cardKey, actionKey: 'backup', autoOpen, nonce,
|
|
1443
|
+
title: '易失数据加密备份', risk: 'low', needPassword: true,
|
|
1444
|
+
hint: '把 sessions/pluginData/pluginAssets 打成 DCA1 加密档(AES-256-GCM)。自动备份恒不含这些分区。口令只进本次请求。',
|
|
1445
|
+
planFn: () => call('plan', { kind: 'backup' }),
|
|
1446
|
+
}),
|
|
1447
|
+
h(ActionRow, {
|
|
1448
|
+
key: 'enable-' + cardKey, actionKey: 'enable', autoOpen, nonce,
|
|
1449
|
+
title: '重新启用插件(停用动作的反向)', risk: 'mid',
|
|
1450
|
+
blocked: enableBlocked,
|
|
1451
|
+
blockedHint: !bundles
|
|
1452
|
+
? '正在读取当前插件启用状态…(只读查询,不写任何东西)'
|
|
1453
|
+
: disabledBundles.length === 0 && bundles.available
|
|
1454
|
+
? '当前没有已停用的插件——启用动作无可对象。停用过某个插件后这里会列出它。'
|
|
1455
|
+
: '读不到当前插件启用状态(pluginManager 服务不可用),所以不知道该启用谁。体检本体不受影响。',
|
|
1456
|
+
hint: '只对「当前已停用」的插件生效。宿主 #575 守卫同样生效:若它导入即崩,启用失败不会写入启动配置。',
|
|
1457
|
+
extraUI: (ref) => h('div', null,
|
|
1458
|
+
h('input', {
|
|
1459
|
+
placeholder: disabledBundles.length ? '已停用:' + disabledBundles.slice(0, 3).join(' / ') : '包名,如 dsh-example-plugin',
|
|
1460
|
+
style: { width: '100%', boxSizing: 'border-box', fontSize: 12, padding: '5px 8px', marginTop: 6, borderRadius: 6, border: '1px solid ' + C.line, background: 'transparent', color: C.text },
|
|
1461
|
+
onChange: (e) => { ref.current.plugin = e.target.value.trim(); },
|
|
1462
|
+
}),
|
|
1463
|
+
disabledBundles.length ? h('div', { style: { fontSize: 11, color: C.faint, marginTop: 4 } },
|
|
1464
|
+
'当前已停用 ' + disabledBundles.length + ' 个:' + disabledBundles.join('、')) : null,
|
|
1465
|
+
),
|
|
1466
|
+
planFn: (extra) => call('plan', { kind: 'enable', plugin: (extra && extra.plugin) || disabledBundles[0] || '' }),
|
|
1467
|
+
}),
|
|
1468
|
+
h(ActionRow, {
|
|
1469
|
+
key: 'knowledge-' + cardKey, actionKey: 'knowledge', autoOpen, nonce,
|
|
1470
|
+
title: '沉淀本次体检结论', risk: 'low',
|
|
1471
|
+
hint: '把当前报告结论与趋势追加到管家本地知识存储(只增不改)。Mnemon 项目知识库写入由 Agent 经你确认。',
|
|
1472
|
+
planFn: () => call('plan', { kind: 'knowledge' }),
|
|
1473
|
+
}),
|
|
1474
|
+
// 归因门挡住的禁用行沉底,说明原因而不是可点的按钮
|
|
1475
|
+
h(ActionRow, {
|
|
1476
|
+
key: 'disable-' + cardKey, actionKey: 'disable', autoOpen, nonce,
|
|
1477
|
+
title: '禁用崩溃点名插件' + (suspectNames.length ? '(' + suspectNames[0] + ')' : ''), risk: 'mid',
|
|
1478
|
+
hint: '对最近一次启动崩溃点名的第一个疑似插件执行停用。经宿主 #575 守卫:失败不落 durable 状态、可逆。',
|
|
1479
|
+
blocked: disableBlocked,
|
|
1480
|
+
blockedHint: crashStale
|
|
1481
|
+
? '归因门:最近一条崩溃早于当前启动(历史、已自愈),当前没有现行崩溃——不该停用任何插件。若面板显示过时,点右上「重新体检」。'
|
|
1482
|
+
: !crashFinding
|
|
1483
|
+
? '还没有含启动诊断的体检报告;先点右上「重新体检」。'
|
|
1484
|
+
: '崩溃日志未点名具体插件,禁用动作无可对象。',
|
|
1485
|
+
planFn: () => call('plan', { kind: 'disable', plugin: suspectNames[0] || '' }),
|
|
1486
|
+
}),
|
|
1487
|
+
) : null,
|
|
1488
|
+
|
|
1489
|
+
tab === 'audit' ? h('div', { style: { marginTop: 12 } }, h(AuditTab, null)) : null,
|
|
1490
|
+
tab === 'settings' ? h(SettingsTab, { onSaved: rescan }) : null,
|
|
1491
|
+
);
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
// Static factory: receives ONLY `require`. apply() fully contained —
|
|
1495
|
+
// a failure here must never become "1 entry did not activate".
|
|
1496
|
+
return {
|
|
1497
|
+
inject: ['slots'],
|
|
1498
|
+
apply(ctx) {
|
|
1499
|
+
// v0.8.4:入口切换不再需要刷新页面。
|
|
1500
|
+
// 旧版只在 apply() 里读一次 ui.entry,改完必须重载整个 Web 界面才生效;
|
|
1501
|
+
// 而桌面版里 Ctrl+R 未必有绑定(用户实测「好像没有效果」)——把「改完要刷新」
|
|
1502
|
+
// 当成使用前提,本身就是设计缺陷:一个设置项不该要求用户去猜快捷键。
|
|
1503
|
+
// 现在两个入口先都挂上,读到设置再裁掉不要的那个;设置页保存成功后通过
|
|
1504
|
+
// ENTRY_APPLIER 就地重挂,全程不刷新。
|
|
1505
|
+
let currentDisposers = [];
|
|
1506
|
+
const disposeAllNow = () => {
|
|
1507
|
+
for (const d of currentDisposers) { try { d(); } catch { /* noop */ } }
|
|
1508
|
+
currentDisposers = [];
|
|
1509
|
+
};
|
|
1510
|
+
|
|
1511
|
+
const mountRight = () => {
|
|
1512
|
+
const disposers = [];
|
|
1513
|
+
try {
|
|
1514
|
+
const off = ctx.inject(['sidebarRightTabs'], (c) => {
|
|
1515
|
+
try {
|
|
1516
|
+
const svc = c && c.sidebarRightTabs;
|
|
1517
|
+
if (!svc || typeof svc.register !== 'function') return;
|
|
1518
|
+
const r = svc.register({
|
|
1519
|
+
id: PANEL_ID, kind: PANEL_ID, title: () => '智能管家',
|
|
1520
|
+
guide: [{ id: PANEL_ID, order: 25, title: () => '智能管家', description: () => '稳定 · 可靠 · 整洁 · 知识的授权守护', icon: PanelIcon }],
|
|
1521
|
+
});
|
|
1522
|
+
if (typeof r === 'function') disposers.push(r);
|
|
1523
|
+
disposers.push(ctx.slots.inject('sidebar.right.pane.tab', () =>
|
|
1524
|
+
ctx.slots.register({ name: 'sidebar.right.pane.tab', key: PANEL_ID }, (p) =>
|
|
1525
|
+
h(Panel, { host: (p && p.host) || 'sidebar' })),
|
|
1526
|
+
));
|
|
1527
|
+
} catch { /* sidebar unavailable — contained */ }
|
|
1528
|
+
});
|
|
1529
|
+
if (typeof off === 'function') disposers.push(off);
|
|
1530
|
+
} catch { /* inject unsupported — contained */ }
|
|
1531
|
+
return () => { for (const d of disposers) { try { d(); } catch { /* noop */ } } };
|
|
1532
|
+
};
|
|
1533
|
+
|
|
1534
|
+
// 浮层宿主与入口无关:三个入口模式下提醒都必须能到,所以在 mountFor 之外
|
|
1535
|
+
// 单独挂一次,且只挂一次(重复注册会让同一个提醒弹两遍)。
|
|
1536
|
+
try {
|
|
1537
|
+
currentDisposers.push(ctx.slots.inject('shell.overlay', () =>
|
|
1538
|
+
ctx.slots.register(
|
|
1539
|
+
{ name: 'shell.overlay', id: PANEL_ID + '-toast', order: 900, label: () => '智能管家提醒' },
|
|
1540
|
+
() => h(ToastHost, {
|
|
1541
|
+
onOpen: (actionKey) => {
|
|
1542
|
+
try { if (PANEL_NAVIGATOR) PANEL_NAVIGATOR(actionKey); } catch { /* noop */ }
|
|
1543
|
+
},
|
|
1544
|
+
onAck: (findingId) => {
|
|
1545
|
+
try { if (PANEL_ACK && findingId) PANEL_ACK(findingId); } catch { /* noop */ }
|
|
1546
|
+
},
|
|
1547
|
+
}),
|
|
1548
|
+
)));
|
|
1549
|
+
} catch { /* overlay 不可用:提醒降级为只出现在面板里,不影响体检 */ }
|
|
1550
|
+
|
|
1551
|
+
// entry: 'both' | 'left' | 'right'
|
|
1552
|
+
// left = 左侧栏导航项 + main 面板本体(点导航项打开的就是 main)
|
|
1553
|
+
// right = 右侧栏标签页;只留右侧栏时不挂 left/main,避免两处重复
|
|
1554
|
+
const mountFor = (entry) => {
|
|
1555
|
+
disposeAllNow();
|
|
1556
|
+
const wantLeft = entry !== 'right';
|
|
1557
|
+
const wantRight = entry !== 'left';
|
|
1558
|
+
if (wantLeft) {
|
|
1559
|
+
try {
|
|
1560
|
+
currentDisposers.push(ctx.slots.inject('sidebar.panellist', () =>
|
|
1561
|
+
ctx.slots.register(
|
|
1562
|
+
{ name: 'sidebar.panellist', id: PANEL_ID, order: 55, label: () => '智能管家' },
|
|
1563
|
+
(p) => h(PanelIcon, { size: p && p.size }),
|
|
1564
|
+
)));
|
|
1565
|
+
} catch { /* contained */ }
|
|
1566
|
+
try {
|
|
1567
|
+
currentDisposers.push(ctx.slots.inject('main', () =>
|
|
1568
|
+
ctx.slots.register({ name: 'main', key: PANEL_ID }, () => h(Panel))));
|
|
1569
|
+
} catch { /* contained */ }
|
|
1570
|
+
}
|
|
1571
|
+
if (wantRight) {
|
|
1572
|
+
try { currentDisposers.push(mountRight()); } catch { /* contained */ }
|
|
1573
|
+
}
|
|
1574
|
+
};
|
|
1575
|
+
|
|
1576
|
+
try {
|
|
1577
|
+
mountFor('both');
|
|
1578
|
+
// 暴露给设置页:保存成功后立刻按新值重挂,不需要重载页面。
|
|
1579
|
+
ENTRY_APPLIER = (entry) => {
|
|
1580
|
+
try { mountFor(entry === 'left' || entry === 'right' ? entry : 'both'); } catch { /* noop */ }
|
|
1581
|
+
};
|
|
1582
|
+
ctx.effect(() => () => {
|
|
1583
|
+
ENTRY_APPLIER = null;
|
|
1584
|
+
disposeAllNow();
|
|
1585
|
+
}, 'dsh-steward: entries');
|
|
1586
|
+
} catch { /* contained */ }
|
|
1587
|
+
|
|
1588
|
+
// 初次挂载后读一次设置,把不要的入口裁掉。读失败就保持「两个都在」(默认态)。
|
|
1589
|
+
try {
|
|
1590
|
+
void (async () => {
|
|
1591
|
+
let entry = 'both';
|
|
1592
|
+
try {
|
|
1593
|
+
const res = await call('settings');
|
|
1594
|
+
const v = res && res.settings && res.settings.ui && res.settings.ui.entry;
|
|
1595
|
+
if (v === 'left' || v === 'right' || v === 'both') entry = v;
|
|
1596
|
+
} catch { return; }
|
|
1597
|
+
if (entry !== 'both') {
|
|
1598
|
+
try { mountFor(entry); } catch { /* noop */ }
|
|
1599
|
+
}
|
|
1600
|
+
})();
|
|
1601
|
+
} catch { /* contained */ }
|
|
1602
|
+
},
|
|
1603
|
+
};
|
|
1604
|
+
},
|
|
1605
|
+
});
|