ims-flow-dashboard 1.2.0

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/template.html ADDED
@@ -0,0 +1,1699 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%234A97DB'/%3E%3Cstop offset='1' stop-color='%230C447C'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='64' height='64' rx='14' fill='url(%23g)'/%3E%3Ccircle cx='52' cy='14' r='20' fill='%23ffffff' opacity='0.08'/%3E%3Cpath d='M14 46 L30 32 L50 18' stroke='%23ffffff' stroke-width='3.5' fill='none' stroke-linecap='round' stroke-linejoin='round' opacity='0.9'/%3E%3Ccircle cx='14' cy='46' r='5' fill='%23ffffff'/%3E%3Ccircle cx='30' cy='32' r='5' fill='%23ffffff'/%3E%3Ccircle cx='50' cy='18' r='6.5' fill='%23ffffff'/%3E%3Ccircle cx='50' cy='18' r='3' fill='%230C447C'/%3E%3C/svg%3E">
7
+ <title>IMS Flow 控制台</title>
8
+ <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
9
+ <meta name="theme-color" content="#0d1015" media="(prefers-color-scheme: dark)">
10
+ <script>
11
+ /* 主题必须在首帧渲染前落到 <html> 上, 否则会先闪一下浅色再切深色 */
12
+ (function () {
13
+ var KEY = 'imsflow.theme';
14
+ var root = document.documentElement;
15
+ var stored = null;
16
+ try { stored = localStorage.getItem(KEY); } catch (e) {}
17
+ var mq = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null;
18
+ var apply = function (t) { root.setAttribute('data-theme', t); };
19
+ apply(stored === 'light' || stored === 'dark' ? stored : (mq && mq.matches ? 'dark' : 'light'));
20
+ if (mq && mq.addEventListener) {
21
+ mq.addEventListener('change', function (e) {
22
+ var cur = null;
23
+ try { cur = localStorage.getItem(KEY); } catch (err) {}
24
+ if (cur !== 'light' && cur !== 'dark') apply(e.matches ? 'dark' : 'light');
25
+ });
26
+ }
27
+ })();
28
+ </script>
29
+ <style>
30
+ /* =====================================================================
31
+ IMS Flow 控制台 · 设计系统
32
+ 风格:工程精密风 (engineering-precision) —— 克制的灰阶 + 单一品牌蓝,
33
+ 细发丝边框、极浅分层阴影、高信息密度、几乎无装饰。
34
+ 主题:浅色 / 深色双主题, 全部走 token, 由 html[data-theme] 切换。
35
+ ===================================================================== */
36
+
37
+ /* ---------------------------------------------------------------------
38
+ 1. 设计令牌 (Design Tokens)
39
+ --------------------------------------------------------------------- */
40
+ :root {
41
+ color-scheme: light;
42
+
43
+ /* 中性面 —— 带品牌冷调偏移, 避免死灰 */
44
+ --bg: #f4f6f9;
45
+ --bg-sub: #eef1f6;
46
+ --card: #ffffff;
47
+ --border: #e3e7ee;
48
+ --border-strong: #ccd4e0;
49
+
50
+ /* 文字 */
51
+ --text: #10141b;
52
+ --muted: #5a6577;
53
+ --faint: #67707f;
54
+
55
+ /* 品牌 (与 favicon 渐变 #4A97DB → #0C447C 同源) */
56
+ --pri: #12558f;
57
+ --pri-soft: #e8f1fa;
58
+ --pri-line: #bcd7ef;
59
+ --ring: rgba(18, 85, 143, .28);
60
+ --on-pri: #ffffff;
61
+
62
+ /* 语义色 */
63
+ --ok: #0b6b52; --ok-soft: #e2f4ee; --ok-line: #a9ddcc;
64
+ --warn: #8a5209; --warn-soft: #faf0dc; --warn-line: #ecd3a4;
65
+ --err: #a52c2c; --err-soft: #fbeceb; --err-line: #f0c3c1;
66
+ --purple: #6d28d9;
67
+
68
+ /* 代码块 (两种主题下都保持深底, 便于读代码) */
69
+ --code-bg: #151a22;
70
+ --code-fg: #d7dde7;
71
+ --code-line: #0d1117;
72
+
73
+ /* 圆角 */
74
+ --r-xs: 4px; --r-sm: 6px; --r-md: 8px; --r-lg: 12px; --r-xl: 16px;
75
+
76
+ /* 阴影 —— 分层, 极克制 */
77
+ --sh-xs: 0 1px 2px rgba(16, 24, 40, .05);
78
+ --sh-sm: 0 1px 2px rgba(16, 24, 40, .05), 0 1px 3px rgba(16, 24, 40, .04);
79
+ --sh-md: 0 2px 4px -1px rgba(16, 24, 40, .06), 0 6px 16px -4px rgba(16, 24, 40, .09);
80
+ --sh-lg: 0 8px 28px -8px rgba(16, 24, 40, .17), 0 2px 6px -2px rgba(16, 24, 40, .07);
81
+
82
+ /* 字体 —— 系统栈: 工具类产品用原生 UI 字体才「精密」, 且零外部依赖 */
83
+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI Variable Text", "Segoe UI",
84
+ "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei",
85
+ sans-serif;
86
+ --mono: ui-monospace, "JetBrains Mono", "Cascadia Code", "SF Mono", Consolas,
87
+ "Liberation Mono", monospace;
88
+
89
+ /* 动效 —— 指数缓出, 不用回弹 */
90
+ --ease: cubic-bezier(.22, .61, .36, 1);
91
+ --ease-out: cubic-bezier(.16, 1, .3, 1);
92
+ --d1: .12s; --d2: .2s; --d3: .34s;
93
+
94
+ /* 尺寸 */
95
+ --top-h: 56px;
96
+ --sidebar-w: 306px;
97
+ --ov-w: 268px;
98
+
99
+ /* 兼容旧变量名 (JS 内联样式直接引用了这些名字) */
100
+ --blue: var(--pri); --blue-bg: var(--pri-soft);
101
+ --green: var(--ok); --green-bg: var(--ok-soft);
102
+ --red: var(--err); --red-bg: var(--err-soft);
103
+ --amber: var(--warn); --amber-bg: var(--warn-soft);
104
+ }
105
+
106
+ html[data-theme="dark"] {
107
+ color-scheme: dark;
108
+
109
+ --bg: #0d1015;
110
+ --bg-sub: #12161d;
111
+ --card: #161b23;
112
+ --border: #262e3a;
113
+ --border-strong: #38434f;
114
+
115
+ --text: #e8ecf2;
116
+ --muted: #9aa5b6;
117
+ --faint: #7d8798;
118
+
119
+ --pri: #63a9e8;
120
+ --pri-soft: rgba(99, 169, 232, .13);
121
+ --pri-line: rgba(99, 169, 232, .32);
122
+ --ring: rgba(99, 169, 232, .30);
123
+ --on-pri: #0b0e13;
124
+
125
+ --ok: #4fc79f; --ok-soft: rgba(79, 199, 159, .13); --ok-line: rgba(79, 199, 159, .30);
126
+ --warn: #e0ac5a; --warn-soft: rgba(224, 172, 90, .13); --warn-line: rgba(224, 172, 90, .30);
127
+ --err: #f08a86; --err-soft: rgba(240, 138, 134, .13); --err-line: rgba(240, 138, 134, .30);
128
+ --purple: #a78bfa;
129
+
130
+ --code-bg: #0b0e13;
131
+ --code-fg: #cdd6e2;
132
+ --code-line: #1d2530;
133
+
134
+ --sh-xs: 0 1px 2px rgba(0, 0, 0, .40);
135
+ --sh-sm: 0 1px 2px rgba(0, 0, 0, .40), 0 1px 3px rgba(0, 0, 0, .28);
136
+ --sh-md: 0 2px 4px -1px rgba(0, 0, 0, .40), 0 6px 16px -4px rgba(0, 0, 0, .50);
137
+ --sh-lg: 0 8px 28px -8px rgba(0, 0, 0, .60), 0 2px 6px -2px rgba(0, 0, 0, .40);
138
+ }
139
+
140
+ /* ---------------------------------------------------------------------
141
+ 2. 基础层
142
+ --------------------------------------------------------------------- */
143
+ * { margin: 0; padding: 0; box-sizing: border-box; }
144
+ html, body { height: 100%; }
145
+ body {
146
+ font-family: var(--sans);
147
+ font-size: 13.5px;
148
+ line-height: 1.6;
149
+ background: var(--bg);
150
+ color: var(--text);
151
+ overflow: hidden;
152
+ -webkit-font-smoothing: antialiased;
153
+ -moz-osx-font-smoothing: grayscale;
154
+ text-rendering: optimizeLegibility;
155
+ font-variant-numeric: tabular-nums;
156
+ transition: background-color var(--d2) var(--ease), color var(--d2) var(--ease);
157
+ }
158
+
159
+ :focus-visible {
160
+ outline: 2px solid var(--pri);
161
+ outline-offset: 2px;
162
+ border-radius: var(--r-sm);
163
+ }
164
+
165
+ /* 细滚动条, 静默存在 */
166
+ * { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
167
+ ::-webkit-scrollbar { width: 10px; height: 10px; }
168
+ ::-webkit-scrollbar-track { background: transparent; }
169
+ ::-webkit-scrollbar-thumb {
170
+ background: var(--border-strong);
171
+ border: 3px solid transparent;
172
+ background-clip: content-box;
173
+ border-radius: 99px;
174
+ }
175
+ ::-webkit-scrollbar-thumb:hover {
176
+ background: var(--faint);
177
+ border: 3px solid transparent;
178
+ background-clip: content-box;
179
+ }
180
+ ::-webkit-scrollbar-corner { background: transparent; }
181
+
182
+ /* ---------------------------------------------------------------------
183
+ 3. 顶栏
184
+ --------------------------------------------------------------------- */
185
+ .top {
186
+ height: var(--top-h);
187
+ flex-shrink: 0;
188
+ display: flex;
189
+ align-items: center;
190
+ gap: 14px;
191
+ padding: 0 18px;
192
+ background: var(--card);
193
+ border-bottom: 1px solid var(--border);
194
+ box-shadow: var(--sh-xs);
195
+ position: relative;
196
+ z-index: 20;
197
+ }
198
+ .brand { display: flex; align-items: center; gap: 9px; flex-shrink: 0; }
199
+ .brand-mark { width: 28px; height: 28px; display: block; border-radius: 7px; box-shadow: var(--sh-xs); }
200
+ .brand h1 { font-size: 14.5px; font-weight: 650; letter-spacing: -.011em; white-space: nowrap; }
201
+ .brand h1 span { color: var(--faint); font-weight: 450; letter-spacing: 0; }
202
+
203
+ .search-wrap { position: relative; display: flex; align-items: center; flex: 1 1 340px; max-width: 520px; min-width: 150px; }
204
+ .search-wrap .search-ic {
205
+ position: absolute; left: 10px; width: 13px; height: 13px;
206
+ color: var(--faint); pointer-events: none; display: flex;
207
+ }
208
+ .search-wrap .search-ic svg { width: 100%; height: 100%; display: block; }
209
+ .search-box {
210
+ width: 100%;
211
+ padding: 6px 46px 6px 30px;
212
+ font-family: var(--sans);
213
+ font-size: 12.5px;
214
+ color: var(--text);
215
+ background: var(--bg-sub);
216
+ border: 1px solid var(--border);
217
+ border-radius: var(--r-md);
218
+ outline: none;
219
+ transition: border-color var(--d1) var(--ease), background var(--d1) var(--ease),
220
+ box-shadow var(--d1) var(--ease);
221
+ }
222
+ .search-box:hover { border-color: var(--border-strong); }
223
+ .search-box:focus {
224
+ background: var(--card);
225
+ border-color: var(--pri);
226
+ box-shadow: 0 0 0 3px var(--ring);
227
+ }
228
+ .search-box::placeholder { color: var(--faint); }
229
+ /* 输入框有内容时让位给原生清除按钮, 避免和快捷键提示挤在一起 */
230
+ .search-box:not(:placeholder-shown) + kbd { opacity: 0; }
231
+ .search-wrap kbd {
232
+ position: absolute; right: 8px;
233
+ font-family: var(--mono); font-size: 9.5px; letter-spacing: .04em;
234
+ color: var(--faint);
235
+ background: var(--card);
236
+ border: 1px solid var(--border);
237
+ border-radius: var(--r-xs);
238
+ padding: 1px 5px;
239
+ pointer-events: none;
240
+ transition: opacity var(--d1) var(--ease);
241
+ }
242
+
243
+ .top-tools { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-shrink: 0; position: relative; }
244
+ .root-sel {
245
+ font-family: var(--sans);
246
+ font-size: 12px;
247
+ padding: 5px 10px;
248
+ max-width: 200px;
249
+ color: var(--muted);
250
+ background: var(--card);
251
+ border: 1px solid var(--border);
252
+ border-radius: var(--r-md);
253
+ cursor: pointer;
254
+ outline: none;
255
+ transition: border-color var(--d1) var(--ease), color var(--d1) var(--ease);
256
+ }
257
+ .root-sel:hover { border-color: var(--border-strong); color: var(--text); }
258
+ .root-sel:focus-visible { border-color: var(--pri); }
259
+
260
+ /* ---------- 工作区管理面板(顶栏弹出, serve 模式可编辑) ---------- */
261
+ /* 遮罩:面板会盖住侧栏, 没有遮罩时被压住的半截文字看起来像渲染错误。
262
+ z-index 15 —— 高于侧栏吸顶(3)/页签吸顶(10), 低于 .top(20) 以让面板浮在其上。 */
263
+ .ws-scrim {
264
+ position: fixed; inset: var(--top-h) 0 0 0; z-index: 15;
265
+ background: color-mix(in srgb, var(--text) 15%, transparent);
266
+ animation: scrim-in var(--d2) var(--ease);
267
+ }
268
+ .ws-scrim[hidden] { display: none; }
269
+ @keyframes scrim-in { from { opacity: 0; } }
270
+ .ws-panel {
271
+ position: absolute; top: calc(100% + 8px); right: 18px; z-index: 60;
272
+ width: 480px; max-width: 92vw;
273
+ padding: 12px;
274
+ background: var(--card);
275
+ border: 1px solid var(--border);
276
+ border-radius: var(--r-lg);
277
+ box-shadow: var(--sh-lg);
278
+ font-size: 12px;
279
+ color: var(--text);
280
+ }
281
+ .ws-panel[hidden] { display: none; }
282
+ .ws-panel h4 { margin: 0 0 2px; font-size: 12.5px; color: var(--text); font-weight: 600; }
283
+ .ws-panel .ws-sub { margin: 0 0 6px; color: var(--faint); font-size: 11px; }
284
+ .ws-row { display: flex; align-items: center; gap: 8px; padding: 5px 6px; border-radius: var(--r-sm); }
285
+ .ws-row:hover { background: color-mix(in srgb, var(--text) 4%, transparent); }
286
+ .ws-row input[type="checkbox"] { accent-color: var(--pri); margin: 0; flex-shrink: 0; width: 13px; height: 13px; cursor: pointer; }
287
+ .ws-row .ws-path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); direction: rtl; text-align: left; unicode-bidi: plaintext; }
288
+ .ws-row .ws-del {
289
+ flex-shrink: 0; border: none; background: none; padding: 2px 8px;
290
+ color: var(--faint); cursor: pointer; border-radius: var(--r-sm);
291
+ font-size: 14px; line-height: 1;
292
+ }
293
+ .ws-row .ws-del:hover { color: var(--red); background: var(--red-bg); }
294
+ .ws-add { display: flex; gap: 8px; margin-top: 10px; }
295
+ .ws-add input {
296
+ flex: 1; min-width: 0;
297
+ font-family: var(--sans); font-size: 12px; padding: 6px 10px;
298
+ color: var(--text); background: var(--bg-sub);
299
+ border: 1px solid var(--border); border-radius: var(--r-md); outline: none;
300
+ transition: border-color var(--d1) var(--ease);
301
+ }
302
+ .ws-add input:focus { border-color: var(--pri); }
303
+ .ws-add input:disabled { opacity: .55; }
304
+ .ws-add button, .ws-foot button {
305
+ flex-shrink: 0; font-family: var(--sans); font-size: 12px; padding: 5px 12px; cursor: pointer;
306
+ color: var(--pri); background: var(--pri-soft);
307
+ border: 1px solid var(--pri-line); border-radius: var(--r-md);
308
+ transition: filter var(--d1) var(--ease);
309
+ }
310
+ .ws-add button:hover, .ws-foot button:hover { filter: brightness(1.04); }
311
+ .ws-add button:disabled, .ws-foot button:disabled { opacity: .55; cursor: not-allowed; }
312
+ .ws-foot { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
313
+ .ws-foot .ws-msg { flex: 1; min-width: 0; color: var(--red); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
314
+ .ws-hint { margin: 8px 0 0; color: var(--faint); font-size: 11px; }
315
+ .ws-hint code { font-family: var(--mono); color: var(--muted); }
316
+
317
+ .icon-btn {
318
+ flex-shrink: 0;
319
+ width: 30px; height: 30px;
320
+ display: inline-flex; align-items: center; justify-content: center;
321
+ color: var(--muted);
322
+ background: var(--card);
323
+ border: 1px solid var(--border);
324
+ border-radius: var(--r-md);
325
+ cursor: pointer;
326
+ transition: color var(--d1) var(--ease), border-color var(--d1) var(--ease),
327
+ background var(--d1) var(--ease);
328
+ }
329
+ .icon-btn:hover { color: var(--pri); border-color: var(--pri-line); background: var(--pri-soft); }
330
+ .icon-btn:active { transform: translateY(.5px); }
331
+ .icon-btn:disabled { opacity: .55; cursor: progress; }
332
+ .icon-btn svg { width: 15px; height: 15px; display: block; }
333
+ .icon-btn.spinning svg { animation: spin .9s linear infinite; }
334
+ @keyframes spin { to { transform: rotate(360deg); } }
335
+ html[data-theme="dark"] .ic-moon { display: none; }
336
+ html:not([data-theme="dark"]) .ic-sun { display: none; }
337
+
338
+ .top-right {
339
+ display: flex; align-items: center; gap: 14px; flex-shrink: 0;
340
+ padding-left: 14px;
341
+ border-left: 1px solid var(--border);
342
+ }
343
+ .top .stats { display: flex; align-items: center; font-size: 11.5px; color: var(--muted); }
344
+ .top .stats span { display: inline-flex; align-items: baseline; gap: 4px; white-space: nowrap; }
345
+ .top .stats span + span { margin-left: 11px; padding-left: 11px; border-left: 1px solid var(--border); }
346
+ .top .stats b { color: var(--text); font-weight: 650; }
347
+ .top .stats .bad b { color: var(--err); }
348
+ .top .meta { font-size: 11px; color: var(--faint); white-space: nowrap; }
349
+
350
+ .toast {
351
+ position: fixed; top: 14px; left: 50%;
352
+ transform: translateX(-50%) translateY(-4px);
353
+ background: var(--text); color: var(--card);
354
+ font-size: 12.5px; padding: 8px 18px;
355
+ border-radius: 99px;
356
+ box-shadow: var(--sh-lg);
357
+ opacity: 0;
358
+ transition: opacity var(--d2) var(--ease), transform var(--d2) var(--ease);
359
+ z-index: 99; pointer-events: none;
360
+ }
361
+ .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
362
+
363
+ /* ---------------------------------------------------------------------
364
+ 4. 骨架布局
365
+ --------------------------------------------------------------------- */
366
+ .layout { display: flex; height: calc(100% - var(--top-h)); }
367
+
368
+ /* ---------------------------------------------------------------------
369
+ 5. 侧边栏
370
+ --------------------------------------------------------------------- */
371
+ .sidebar {
372
+ width: var(--sidebar-w);
373
+ flex-shrink: 0;
374
+ padding-bottom: 24px;
375
+ background: var(--card);
376
+ border-right: 1px solid var(--border);
377
+ overflow-y: auto;
378
+ overscroll-behavior: contain;
379
+ }
380
+ .ws-group { padding: 0 0 4px; }
381
+ .ws-group + .ws-group { border-top: 1px solid var(--border); }
382
+ .ws-name {
383
+ position: sticky; top: 0; z-index: 3;
384
+ display: flex; align-items: center; gap: 7px;
385
+ padding: 14px 16px 8px;
386
+ font-size: 11px; font-weight: 650; letter-spacing: .04em;
387
+ color: var(--faint);
388
+ background: var(--card);
389
+ }
390
+ .ws-name .cnt {
391
+ margin-left: auto;
392
+ font-size: 10.5px; font-weight: 500; letter-spacing: 0;
393
+ color: var(--faint);
394
+ background: var(--bg-sub);
395
+ border-radius: 99px;
396
+ padding: 1px 7px;
397
+ white-space: nowrap;
398
+ }
399
+
400
+ .ch-item {
401
+ position: relative;
402
+ display: flex; flex-direction: column; gap: 2px;
403
+ margin: 1px 8px;
404
+ padding: 7px 12px 7px 15px;
405
+ border-radius: var(--r-md);
406
+ cursor: pointer;
407
+ transition: background var(--d1) var(--ease);
408
+ }
409
+ .ch-item::before {
410
+ content: '';
411
+ position: absolute; left: 2px; top: 50%;
412
+ width: 3px; height: 0;
413
+ transform: translateY(-50%);
414
+ background: var(--pri);
415
+ border-radius: 99px;
416
+ transition: height var(--d2) var(--ease-out);
417
+ }
418
+ .ch-item:hover { background: var(--bg-sub); }
419
+ .ch-item.sel { background: var(--pri-soft); }
420
+ .ch-item.sel::before { height: 20px; }
421
+ /* 第一行 = 健康点 + 标题(标题独占剩余宽度, 不再和阶段胶囊抢空间) */
422
+ .ch-item .row1 { display: flex; align-items: center; gap: 7px; min-width: 0; }
423
+ .ch-item .ttl {
424
+ flex: 1; min-width: 0;
425
+ font-size: 12.5px; font-weight: 500;
426
+ color: var(--text);
427
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
428
+ }
429
+ .ch-item.sel .ttl { color: var(--pri); font-weight: 650; }
430
+ /* 第二行 = slug + 来源/阶段徽章。
431
+ ⚠️ 徽章必须放在这个 flex 容器里当独立子项, 不能塞进 .slug 内部 ——
432
+ .slug 是 text-overflow:ellipsis 的文本流, slug 一长就会把徽章整块裁掉
433
+ (实测 trae 徽章被切 29px, 10 条里 7 条徽章完全不可见)。 */
434
+ .ch-item .row2 { display: flex; align-items: center; gap: 5px; min-width: 0; padding-left: 13px; }
435
+ .ch-item .slug {
436
+ flex: 1; min-width: 0;
437
+ font-family: var(--mono); font-size: 10px;
438
+ color: var(--faint);
439
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
440
+ }
441
+ .ch-item.archived .ttl { color: var(--muted); font-size: 12px; font-weight: 450; }
442
+ .ch-item.archived .slug { font-size: 9.5px; opacity: .85; }
443
+
444
+ .arch-toggle {
445
+ display: flex; align-items: center; gap: 6px;
446
+ margin: 2px 8px;
447
+ padding: 5px 8px 5px 11px;
448
+ font-size: 11px; color: var(--faint);
449
+ border-radius: var(--r-sm);
450
+ cursor: pointer; user-select: none;
451
+ transition: color var(--d1) var(--ease), background var(--d1) var(--ease);
452
+ }
453
+ .arch-toggle:hover { color: var(--text); background: var(--bg-sub); }
454
+
455
+ /* 健康指示灯 */
456
+ .dot-s { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; background: var(--border-strong); }
457
+ .dot-s.ok { background: var(--ok); }
458
+ .dot-s.err { background: var(--err); box-shadow: 0 0 0 3px var(--err-soft); }
459
+ .dot-s.warn { background: var(--warn); }
460
+ .dot-s.idle { background: var(--border); }
461
+
462
+ /* 来源徽章 —— color-mix 让配色自动跟随主题 */
463
+ .origin-chip {
464
+ flex-shrink: 0;
465
+ font-family: var(--mono); font-size: 9.5px; line-height: 1.55; letter-spacing: .02em;
466
+ padding: 1px 5px;
467
+ border-radius: var(--r-xs);
468
+ border: 1px solid var(--border);
469
+ background: var(--bg-sub);
470
+ color: var(--muted);
471
+ }
472
+ .origin-chip.trae {
473
+ background: color-mix(in srgb, #2563eb 13%, transparent);
474
+ color: color-mix(in srgb, #2563eb 72%, var(--text));
475
+ border-color: color-mix(in srgb, #2563eb 26%, transparent);
476
+ }
477
+ .origin-chip.tuanjieai {
478
+ background: color-mix(in srgb, #7c3aed 13%, transparent);
479
+ color: color-mix(in srgb, #7c3aed 72%, var(--text));
480
+ border-color: color-mix(in srgb, #7c3aed 26%, transparent);
481
+ }
482
+ .origin-chip.workbuddy {
483
+ background: color-mix(in srgb, #059669 14%, transparent);
484
+ color: color-mix(in srgb, #059669 72%, var(--text));
485
+ border-color: color-mix(in srgb, #059669 28%, transparent);
486
+ }
487
+ .origin-chip.human {
488
+ background: color-mix(in srgb, #64748b 14%, transparent);
489
+ color: color-mix(in srgb, #64748b 72%, var(--text));
490
+ border-color: color-mix(in srgb, #64748b 26%, transparent);
491
+ }
492
+ .origin-chip.unknown {
493
+ background: color-mix(in srgb, #b45309 14%, transparent);
494
+ color: color-mix(in srgb, #b45309 72%, var(--text));
495
+ border-color: color-mix(in srgb, #b45309 26%, transparent);
496
+ }
497
+
498
+ /* ---------------------------------------------------------------------
499
+ 6. 详情区
500
+ --------------------------------------------------------------------- */
501
+ .main { flex: 1; min-width: 0; overflow-y: auto; overscroll-behavior: contain; }
502
+ .detail { padding: 18px 22px 40px; }
503
+
504
+ .detail-head {
505
+ display: flex; align-items: center; gap: 9px; flex-wrap: wrap;
506
+ min-height: 32px;
507
+ margin-bottom: 14px;
508
+ }
509
+ .detail-head h2 {
510
+ font-size: 17px; font-weight: 650; letter-spacing: -.015em; line-height: 1.35;
511
+ word-break: break-word;
512
+ }
513
+ .detail-head .path { font-family: var(--mono); font-size: 10.5px; color: var(--faint); }
514
+
515
+ .badge {
516
+ font-size: 10.5px; font-weight: 600; letter-spacing: .01em;
517
+ padding: 2px 9px; border-radius: 99px; white-space: nowrap;
518
+ background: var(--pri-soft); color: var(--pri); border: 1px solid var(--pri-line);
519
+ }
520
+ .badge.red { background: var(--err-soft); color: var(--err); border-color: var(--err-line); }
521
+ .badge.amber { background: var(--warn-soft); color: var(--warn); border-color: var(--warn-line); }
522
+ .badge.green { background: var(--ok-soft); color: var(--ok); border-color: var(--ok-line); }
523
+
524
+ /* 阶段胶囊 —— 全部 color-mix, 深浅主题同一套规则 */
525
+ .stage-chip {
526
+ flex-shrink: 0; white-space: nowrap;
527
+ font-size: 10px; font-weight: 600; letter-spacing: .02em; line-height: 1.6;
528
+ padding: 1px 8px; border-radius: 99px;
529
+ border: 1px solid var(--border);
530
+ background: var(--bg-sub); color: var(--muted);
531
+ }
532
+ .st-created, .st-archived {
533
+ background: color-mix(in srgb, var(--text) 6%, transparent);
534
+ color: var(--faint);
535
+ border-color: color-mix(in srgb, var(--text) 11%, transparent);
536
+ }
537
+ .st-proposing {
538
+ background: color-mix(in srgb, var(--warn) 13%, transparent);
539
+ color: var(--warn);
540
+ border-color: color-mix(in srgb, var(--warn) 28%, transparent);
541
+ }
542
+ .st-ready {
543
+ background: color-mix(in srgb, var(--pri) 12%, transparent);
544
+ color: var(--pri);
545
+ border-color: color-mix(in srgb, var(--pri) 28%, transparent);
546
+ }
547
+ .st-applying {
548
+ background: color-mix(in srgb, var(--purple) 13%, transparent);
549
+ color: color-mix(in srgb, var(--purple) 82%, var(--text));
550
+ border-color: color-mix(in srgb, var(--purple) 28%, transparent);
551
+ }
552
+ .st-applied, .st-reviewed {
553
+ background: color-mix(in srgb, var(--ok) 13%, transparent);
554
+ color: var(--ok);
555
+ border-color: color-mix(in srgb, var(--ok) 28%, transparent);
556
+ }
557
+
558
+ /* 主体两列 */
559
+ .body-cols { display: flex; gap: 18px; align-items: flex-start; }
560
+ .content-col { flex: 1; min-width: 0; }
561
+ .overview-col {
562
+ width: var(--ov-w); flex-shrink: 0;
563
+ position: sticky; top: 0;
564
+ display: flex; flex-direction: column; gap: 12px;
565
+ }
566
+
567
+ /* ---------------------------------------------------------------------
568
+ 7. 工件 Tabs (下划线式, 吸顶)
569
+ --------------------------------------------------------------------- */
570
+ .tabs {
571
+ position: sticky; top: 0; z-index: 10;
572
+ display: flex; gap: 2px; flex-wrap: wrap;
573
+ border-bottom: 1px solid var(--border);
574
+ background: var(--bg);
575
+ }
576
+ .tab {
577
+ display: inline-flex; align-items: center;
578
+ padding: 7px 12px;
579
+ font-size: 12.5px;
580
+ color: var(--muted);
581
+ border: none; border-bottom: 2px solid transparent;
582
+ border-radius: var(--r-sm) var(--r-sm) 0 0;
583
+ background: transparent;
584
+ cursor: pointer; user-select: none;
585
+ transition: color var(--d1) var(--ease), background var(--d1) var(--ease),
586
+ border-color var(--d1) var(--ease);
587
+ }
588
+ .tab:hover { color: var(--text); background: color-mix(in srgb, var(--text) 4%, transparent); }
589
+ .tab.on { color: var(--pri); font-weight: 650; border-bottom-color: var(--pri); }
590
+ .tab.off { color: var(--faint); opacity: .55; cursor: default; }
591
+ .tab.off:hover { color: var(--faint); background: transparent; }
592
+ .tab .tag { display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-left: 6px; flex-shrink: 0; }
593
+ .tag.on { background: var(--ok); }
594
+ .tag.warn { background: var(--warn); }
595
+ .tag.off { background: var(--border-strong); }
596
+
597
+ .tab-body {
598
+ margin-top: 14px;
599
+ min-height: 180px;
600
+ padding: 20px 24px;
601
+ background: var(--card);
602
+ border: 1px solid var(--border);
603
+ border-radius: var(--r-lg);
604
+ box-shadow: var(--sh-sm);
605
+ }
606
+
607
+ .file-tabs {
608
+ display: flex; gap: 5px; flex-wrap: wrap;
609
+ margin-bottom: 16px; padding-bottom: 13px;
610
+ border-bottom: 1px solid var(--border);
611
+ }
612
+ .file-tab {
613
+ font-family: var(--mono); font-size: 11px;
614
+ padding: 3px 10px;
615
+ color: var(--muted);
616
+ background: var(--bg-sub);
617
+ border: 1px solid var(--border);
618
+ border-radius: 99px;
619
+ cursor: pointer;
620
+ transition: color var(--d1) var(--ease), border-color var(--d1) var(--ease),
621
+ background var(--d1) var(--ease);
622
+ }
623
+ .file-tab:hover { color: var(--text); border-color: var(--border-strong); }
624
+ .file-tab.on { background: var(--pri); border-color: var(--pri); color: var(--on-pri); }
625
+
626
+ /* ---------------------------------------------------------------------
627
+ 8. 概览栏卡片
628
+ --------------------------------------------------------------------- */
629
+ .ov-card {
630
+ background: var(--card);
631
+ border: 1px solid var(--border);
632
+ border-radius: var(--r-lg);
633
+ box-shadow: var(--sh-sm);
634
+ overflow: hidden;
635
+ }
636
+ .ov-card .ov-head {
637
+ display: flex; align-items: center; gap: 8px;
638
+ padding: 9px 14px;
639
+ font-size: 11.5px; font-weight: 650; letter-spacing: .03em;
640
+ color: var(--muted);
641
+ background: var(--bg-sub);
642
+ border-bottom: 1px solid var(--border);
643
+ }
644
+ .ov-card .ov-head .mini { margin-left: auto; font-weight: 450; font-size: 10.5px; letter-spacing: 0; color: var(--faint); }
645
+
646
+ /* 生命周期竖排时间线 */
647
+ .lc-vert { padding: 12px 14px; }
648
+ .lc-vstep {
649
+ position: relative;
650
+ display: flex; align-items: center; gap: 10px;
651
+ padding: 5px 0;
652
+ }
653
+ .lc-vstep::before {
654
+ content: '';
655
+ position: absolute; left: 6px; top: 50%; bottom: -50%;
656
+ width: 2px;
657
+ background: var(--border);
658
+ border-radius: 2px;
659
+ }
660
+ .lc-vstep:last-child::before { display: none; }
661
+ .lc-vstep.done::before { background: var(--ok-line); }
662
+ .lc-vstep .rail { width: 14px; display: flex; justify-content: center; flex-shrink: 0; }
663
+ .lc-vstep .nd2 {
664
+ position: relative; z-index: 1;
665
+ width: 11px; height: 11px; flex-shrink: 0;
666
+ border-radius: 50%;
667
+ background: var(--card);
668
+ border: 2px solid var(--border-strong);
669
+ }
670
+ .lc-vstep.done .nd2 { background: var(--ok); border-color: var(--ok); }
671
+ .lc-vstep.cur .nd2 {
672
+ background: var(--pri); border-color: var(--pri);
673
+ box-shadow: 0 0 0 3px var(--pri-soft);
674
+ }
675
+ .lc-vstep .lb { font-size: 12.5px; color: var(--muted); }
676
+ .lc-vstep.done .lb { color: var(--text); }
677
+ .lc-vstep.cur .lb { color: var(--pri); font-weight: 650; }
678
+ .lc-vstep .lc-cmd {
679
+ margin-left: auto;
680
+ font-family: var(--mono); font-size: 9.5px;
681
+ color: var(--faint);
682
+ cursor: help;
683
+ }
684
+ .lc-vstep.done .lc-cmd { color: var(--ok); opacity: .8; }
685
+ .lc-vstep.cur .lc-cmd { color: var(--pri); }
686
+
687
+ .next-action {
688
+ display: flex; gap: 8px; align-items: flex-start;
689
+ padding: 10px 14px;
690
+ font-size: 12px; line-height: 1.55;
691
+ background: var(--pri-soft);
692
+ color: var(--pri);
693
+ border: 1px solid var(--pri-line);
694
+ border-radius: var(--r-lg);
695
+ box-shadow: var(--sh-xs);
696
+ }
697
+ .next-action::before {
698
+ content: '→';
699
+ flex-shrink: 0;
700
+ font-family: var(--mono); font-weight: 700;
701
+ opacity: .75;
702
+ }
703
+ .next-action.attn { background: var(--warn-soft); color: var(--warn); border-color: var(--warn-line); }
704
+ .next-action.attn::before { content: '!'; }
705
+
706
+ /* 产物清单 */
707
+ .art-row {
708
+ display: flex; align-items: center; gap: 9px;
709
+ padding: 7px 14px;
710
+ font-size: 12px;
711
+ border-bottom: 1px solid var(--border);
712
+ transition: background var(--d1) var(--ease);
713
+ }
714
+ .art-row:last-child { border-bottom: none; }
715
+ .art-row.link { cursor: pointer; }
716
+ .art-row.link:hover { background: var(--bg-sub); }
717
+ .art-row .fn {
718
+ flex: 1; min-width: 0;
719
+ color: var(--muted);
720
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
721
+ }
722
+ .art-row.noart .fn { color: var(--faint); }
723
+ .art-st { flex-shrink: 0; font-size: 11px; font-weight: 700; line-height: 1; }
724
+ .art-st.ok { color: var(--ok); }
725
+ .art-st.no { color: var(--faint); font-weight: 400; }
726
+
727
+ /* 健康检查 */
728
+ .issue { padding: 8px 14px; font-size: 12px; line-height: 1.55; }
729
+ .issue + .issue { border-top: 1px solid var(--border); }
730
+ .issue.err { background: var(--err-soft); color: var(--err); }
731
+ .issue.warn { background: var(--warn-soft); color: var(--warn); }
732
+ .issue.ok { color: var(--ok); }
733
+
734
+ /* ---------------------------------------------------------------------
735
+ 9. 任务清单
736
+ --------------------------------------------------------------------- */
737
+ .task-group + .task-group { margin-top: 8px; }
738
+ .task-group-title {
739
+ display: flex; align-items: center; gap: 7px;
740
+ padding: 7px 8px;
741
+ font-size: 12px; font-weight: 650; color: var(--text);
742
+ border-radius: var(--r-sm);
743
+ cursor: pointer; user-select: none;
744
+ transition: background var(--d1) var(--ease);
745
+ }
746
+ .task-group-title:hover { background: var(--bg-sub); }
747
+ .task-group-title .arrow { width: 12px; flex-shrink: 0; font-size: 9px; color: var(--faint); }
748
+ .task-group-title .mini {
749
+ margin-left: auto;
750
+ font-size: 10.5px; font-weight: 500; color: var(--faint);
751
+ background: var(--bg-sub);
752
+ border-radius: 99px;
753
+ padding: 1px 7px;
754
+ }
755
+ .task {
756
+ display: flex; align-items: flex-start; gap: 9px;
757
+ padding: 5px 8px 5px 26px;
758
+ font-size: 13px;
759
+ border-radius: var(--r-sm);
760
+ transition: background var(--d1) var(--ease);
761
+ }
762
+ .task:hover { background: var(--bg-sub); }
763
+ .task .cb {
764
+ position: relative; flex-shrink: 0;
765
+ width: 15px; height: 15px; margin-top: 3px;
766
+ background: var(--card);
767
+ border: 1.5px solid var(--border-strong);
768
+ border-radius: 5px;
769
+ transition: background var(--d1) var(--ease), border-color var(--d1) var(--ease);
770
+ }
771
+ .task.done .cb {
772
+ background: var(--ok); border-color: var(--ok);
773
+ display: flex; align-items: center; justify-content: center;
774
+ }
775
+ .task.done .cb::after {
776
+ content: '✓';
777
+ font-size: 10px; font-weight: 700; line-height: 1;
778
+ color: var(--card);
779
+ }
780
+ .task .tx { flex: 1; min-width: 0; }
781
+ .task.done .tx { color: var(--faint); }
782
+
783
+ /* ---------------------------------------------------------------------
784
+ 10. Markdown 渲染
785
+ --------------------------------------------------------------------- */
786
+ .md { max-width: 880px; font-size: 13.5px; line-height: 1.72; color: var(--text); }
787
+ .md > *:first-child { margin-top: 0; }
788
+ .md h1 {
789
+ font-size: 19px; font-weight: 650; letter-spacing: -.015em;
790
+ margin: 26px 0 12px; padding-bottom: 8px;
791
+ border-bottom: 1px solid var(--border);
792
+ }
793
+ .md h2 {
794
+ display: flex; align-items: center; gap: 9px;
795
+ font-size: 15.5px; font-weight: 650; letter-spacing: -.01em;
796
+ margin: 22px 0 9px;
797
+ }
798
+ .md h2::before {
799
+ content: '';
800
+ flex-shrink: 0;
801
+ width: 3px; height: 13px;
802
+ background: var(--pri);
803
+ border-radius: 99px;
804
+ opacity: .75;
805
+ }
806
+ .md h3 { font-size: 13.5px; font-weight: 650; margin: 18px 0 6px; }
807
+ .md h4 { font-size: 12.5px; font-weight: 600; margin: 14px 0 5px; color: var(--muted); }
808
+ .md p { margin: 9px 0; }
809
+ .md ul, .md ol { margin: 9px 0 9px 20px; }
810
+ .md li { margin: 4px 0; }
811
+ .md li::marker { color: var(--faint); }
812
+ .md code {
813
+ font-family: var(--mono); font-size: 11.5px;
814
+ padding: 1px 5px;
815
+ color: var(--pri);
816
+ background: var(--bg-sub);
817
+ border: 1px solid var(--border);
818
+ border-radius: var(--r-xs);
819
+ }
820
+ .md pre {
821
+ margin: 12px 0; padding: 14px 16px;
822
+ background: var(--code-bg);
823
+ border: 1px solid var(--code-line);
824
+ border-radius: var(--r-md);
825
+ box-shadow: var(--sh-xs);
826
+ overflow-x: auto;
827
+ }
828
+ .md pre code {
829
+ padding: 0; border: none; background: none;
830
+ color: var(--code-fg);
831
+ font-size: 12px; line-height: 1.6;
832
+ }
833
+ .md table {
834
+ display: block; max-width: 100%;
835
+ margin: 12px 0;
836
+ font-size: 12px;
837
+ border: 1px solid var(--border);
838
+ border-radius: var(--r-md);
839
+ border-collapse: separate; border-spacing: 0;
840
+ overflow-x: auto;
841
+ }
842
+ .md th, .md td {
843
+ padding: 7px 12px; text-align: left; vertical-align: top;
844
+ border: none;
845
+ border-right: 1px solid var(--border);
846
+ border-bottom: 1px solid var(--border);
847
+ }
848
+ .md th {
849
+ font-size: 11.5px; font-weight: 650; letter-spacing: .02em;
850
+ white-space: nowrap;
851
+ color: var(--muted);
852
+ background: var(--bg-sub);
853
+ }
854
+ .md th:last-child, .md td:last-child { border-right: none; }
855
+ .md tr:last-child th, .md tr:last-child td { border-bottom: none; }
856
+ .md tbody tr:hover td { background: var(--bg-sub); }
857
+ .md blockquote {
858
+ margin: 12px 0; padding: 9px 14px;
859
+ font-size: 13px; color: var(--text);
860
+ background: var(--pri-soft);
861
+ border-left: 3px solid var(--pri-line);
862
+ border-radius: 0 var(--r-md) var(--r-md) 0;
863
+ }
864
+ .md hr { margin: 20px 0; border: none; border-top: 1px solid var(--border); }
865
+ .md a {
866
+ color: var(--pri); text-decoration: none;
867
+ border-bottom: 1px solid var(--pri-line);
868
+ transition: border-color var(--d1) var(--ease);
869
+ }
870
+ .md a:hover { border-bottom-color: var(--pri); }
871
+ .md img { max-width: 100%; border-radius: var(--r-md); }
872
+
873
+ /* ---------------------------------------------------------------------
874
+ 11. 空状态
875
+ --------------------------------------------------------------------- */
876
+ .empty-panel { padding: 48px 28px; text-align: center; font-size: 13px; line-height: 1.8; color: var(--faint); }
877
+ .placeholder {
878
+ display: flex; flex-direction: column; gap: 4px;
879
+ height: 100%; align-items: center; justify-content: center;
880
+ color: var(--faint);
881
+ }
882
+ .placeholder .ph-ic { width: 32px; height: 32px; color: var(--faint); opacity: .45; margin-bottom: 10px; }
883
+ .placeholder .ph-1 { font-size: 14px; font-weight: 500; color: var(--muted); }
884
+ .placeholder .ph-2 { font-size: 12px; color: var(--faint); }
885
+ .placeholder::after {
886
+ content: '按 Ctrl K 全文搜索';
887
+ margin-top: 16px; padding: 4px 12px;
888
+ font-size: 11px; color: var(--faint);
889
+ background: var(--card);
890
+ border: 1px solid var(--border);
891
+ border-radius: 99px;
892
+ }
893
+
894
+ /* ---------------------------------------------------------------------
895
+ 12. 搜索结果
896
+ --------------------------------------------------------------------- */
897
+ .sr-wrap { max-width: 940px; margin: 0 auto; padding: 22px 24px 48px; }
898
+ .sr-head { margin-bottom: 16px; font-size: 12.5px; color: var(--muted); }
899
+ .sr-head b { color: var(--text); font-weight: 650; }
900
+ .sr-item {
901
+ margin-bottom: 10px; padding: 4px 6px;
902
+ background: var(--card);
903
+ border: 1px solid var(--border);
904
+ border-radius: var(--r-lg);
905
+ box-shadow: var(--sh-sm);
906
+ overflow: hidden;
907
+ }
908
+ .sr-ch {
909
+ display: flex; align-items: center; gap: 8px;
910
+ padding: 9px 10px;
911
+ border-radius: var(--r-md);
912
+ cursor: pointer;
913
+ transition: background var(--d1) var(--ease);
914
+ }
915
+ .sr-ch:hover { background: var(--bg-sub); }
916
+ .sr-ch .ttl { flex: 1; min-width: 0; font-size: 13.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
917
+ .sr-ch:hover .ttl { color: var(--pri); }
918
+ .sr-hit {
919
+ display: flex; align-items: baseline; gap: 10px;
920
+ margin: 0 0 2px 12px; padding: 6px 10px;
921
+ border-left: 2px solid var(--pri-line);
922
+ border-radius: 0 var(--r-sm) var(--r-sm) 0;
923
+ cursor: pointer;
924
+ transition: background var(--d1) var(--ease), border-color var(--d1) var(--ease);
925
+ }
926
+ .sr-hit:hover { background: var(--pri-soft); border-left-color: var(--pri); }
927
+ .sr-loc { flex-shrink: 0; font-family: var(--mono); font-size: 10.5px; font-weight: 600; color: var(--pri); }
928
+ .sr-prev { flex: 1; min-width: 0; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
929
+ .sr-cnt { flex-shrink: 0; font-size: 10.5px; color: var(--faint); }
930
+ .sr-more { margin: 6px 0 10px 14px; font-size: 11px; color: var(--faint); }
931
+
932
+ mark.hit {
933
+ padding: 0 2px;
934
+ color: inherit;
935
+ background: color-mix(in srgb, var(--warn) 32%, transparent);
936
+ border-radius: 3px;
937
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--warn) 20%, transparent);
938
+ }
939
+
940
+ /* ---------------------------------------------------------------------
941
+ 13. 响应式
942
+ --------------------------------------------------------------------- */
943
+ @media (max-width: 1380px) {
944
+ .top .meta { display: none; }
945
+ .brand h1 span { display: none; }
946
+ }
947
+ @media (max-width: 1240px) {
948
+ .top .stats span + span { margin-left: 8px; padding-left: 8px; }
949
+ .top .stats { font-size: 11px; }
950
+ .root-sel { max-width: 150px; }
951
+ }
952
+ /* ≤940: 统计读数让位。实测 620px 视口下顶栏内容 802px > 620px 会横向溢出,
953
+ 把「变更/违规」推出屏幕右侧 —— 看起来就是"跑偏"。统计口径在详情页右侧「概览」栏完整可见。 */
954
+ @media (max-width: 940px) {
955
+ .top .stats { display: none; }
956
+ .root-sel { max-width: 120px; }
957
+ }
958
+ /* ≤760: 再收敛一档 —— 品牌只留图标, 快捷键提示让位给输入框 */
959
+ @media (max-width: 760px) {
960
+ .top { gap: 10px; padding: 0 12px; }
961
+ .brand h1 { display: none; }
962
+ .search-wrap kbd { display: none; }
963
+ .root-sel { max-width: 96px; }
964
+ }
965
+
966
+ @media (max-width: 1180px) {
967
+ .body-cols { flex-direction: column; }
968
+ .overview-col { position: static; width: 100%; flex-direction: row; flex-wrap: wrap; }
969
+ .overview-col .ov-card, .overview-col .next-action { flex: 1 1 260px; }
970
+ :root { --sidebar-w: 268px; }
971
+ }
972
+
973
+ /* ---------------------------------------------------------------------
974
+ 14. 无障碍 / 偏好
975
+ --------------------------------------------------------------------- */
976
+ @media (prefers-reduced-motion: reduce) {
977
+ *, *::before, *::after {
978
+ transition-duration: .01ms !important;
979
+ animation-duration: .01ms !important;
980
+ }
981
+ }
982
+ </style>
983
+ </head>
984
+ <body>
985
+ <header class="top">
986
+ <div class="brand">
987
+ <svg class="brand-mark" viewBox="0 0 64 64" aria-hidden="true"><defs><linearGradient id="bm" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#4A97DB"/><stop offset="1" stop-color="#0C447C"/></linearGradient></defs><rect width="64" height="64" rx="14" fill="url(#bm)"/><circle cx="52" cy="14" r="20" fill="#ffffff" opacity="0.08"/><path d="M14 46 L30 32 L50 18" stroke="#ffffff" stroke-width="3.5" fill="none" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"/><circle cx="14" cy="46" r="5" fill="#ffffff"/><circle cx="30" cy="32" r="5" fill="#ffffff"/><circle cx="50" cy="18" r="6.5" fill="#ffffff"/><circle cx="50" cy="18" r="3" fill="#0C447C"/></svg>
988
+ <h1>IMS Flow <span>控制台</span></h1>
989
+ </div>
990
+ <div class="search-wrap">
991
+ <span class="search-ic" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><circle cx="7" cy="7" r="4.2"/><path d="M10.4 10.4 14 14"/></svg></span>
992
+ <input id="searchBox" class="search-box" type="search" placeholder="搜索变更内容…" autocomplete="off" aria-label="搜索全部变更内容" />
993
+ <kbd>Ctrl K</kbd>
994
+ </div>
995
+ <div class="top-tools">
996
+ <select id="rootFilter" class="root-sel" title="选择要查看的根路径" aria-label="选择根路径"></select>
997
+ <button id="rescanBtn" class="icon-btn" title="重新扫描全部工作区" aria-label="重新扫描全部工作区">
998
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round"><path d="M13.3 9.6A5.6 5.6 0 1 1 11.9 3.6"/><path d="M14.1 2.3v3.9h-3.9"/></svg>
999
+ </button>
1000
+ <button id="wsBtn" class="icon-btn" title="管理扫描的工作区(增删路径 / 勾选本次扫描)" aria-label="管理扫描的工作区">
1001
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.45" stroke-linecap="round"><path d="M2.6 4.4h1.3M6.2 4.4h7.2M2.6 8h1.3M6.2 8h7.2M2.6 11.6h1.3M6.2 11.6h7.2"/></svg>
1002
+ </button>
1003
+ <button id="themeBtn" class="icon-btn" title="切换深色 / 浅色主题" aria-label="切换深色 / 浅色主题">
1004
+ <svg class="ic-moon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13.6 9.7A5.7 5.7 0 0 1 6.3 2.4a5.8 5.8 0 1 0 7.3 7.3Z"/></svg>
1005
+ <svg class="ic-sun" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="3.1"/><path d="M8 1.2v1.6M8 13.2v1.6M14.8 8h-1.6M2.8 8H1.2M12.8 3.2l-1.1 1.1M4.3 11.7l-1.1 1.1M12.8 12.8l-1.1-1.1M4.3 4.3 3.2 3.2"/></svg>
1006
+ </button>
1007
+ </div>
1008
+ <div id="wsPanel" class="ws-panel" hidden></div>
1009
+ <div class="top-right">
1010
+ <div class="stats" id="stats"></div>
1011
+ <span class="meta" id="meta"></span>
1012
+ </div>
1013
+ </header>
1014
+ <div id="wsScrim" class="ws-scrim" hidden></div>
1015
+ <div class="layout">
1016
+ <div class="sidebar" id="sidebar"></div>
1017
+ <div class="main" id="main"><div class="placeholder"><svg class="ph-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4" width="17" height="16" rx="2.5"/><path d="M7.5 9h6M7.5 12.5h9M7.5 16h5"/></svg><div class="ph-1">从左侧选择一个变更</div><div class="ph-2">生命周期 · 产物清单 · 任务状态 · 产物全文</div></div></div>
1018
+ </div>
1019
+ <script>
1020
+ const DATA = __DATA__;
1021
+ const esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1022
+
1023
+ /* ---------- 工件中文名 ---------- */
1024
+ const ART_CN = { proposal: '提案', specs: '规格', design: '设计', tasks: '任务', api: '接口', 'api-docs': '接口', 'db-changes': '数据库', 'test-plan': '自测', 'release-steps': '上线步骤', retrospective: '问题总结' };
1025
+ const artLabel = a => ART_CN[a.id] || a.id;
1026
+
1027
+ /* 空态占位 —— 静态 HTML 里是同一份, resetView 复用, 避免两处文案漂移 */
1028
+ const EMPTY_HTML = '<div class="placeholder">'
1029
+ + '<svg class="ph-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4" width="17" height="16" rx="2.5"/><path d="M7.5 9h6M7.5 12.5h9M7.5 16h5"/></svg>'
1030
+ + '<div class="ph-1">从左侧选择一个变更</div>'
1031
+ + '<div class="ph-2">生命周期 · 产物清单 · 任务状态 · 产物全文</div>'
1032
+ + '</div>';
1033
+
1034
+ /* ---------- 轻量 markdown 渲染 ---------- */
1035
+ function renderMd(src) {
1036
+ if (!src) return '<div class="empty-panel">(空文件)</div>';
1037
+ const lines = src.split(/\r?\n/);
1038
+ let out = [], inCode = false, codeBuf = [], codeLang = '', listStack = [], inTable = false, tableBuf = [];
1039
+ const flushList = () => { while (listStack.length) out.push(listStack.pop() === 'ul' ? '</ul>' : '</ol>'); };
1040
+ const flushTable = () => {
1041
+ if (!tableBuf.length) return;
1042
+ const isSep = tableBuf[1] && /^[\s|:-]+$/.test(tableBuf[1]);
1043
+ let html = '<table>';
1044
+ const rows = isSep ? [tableBuf[0]].concat(tableBuf.slice(2)) : tableBuf;
1045
+ rows.forEach((r, i) => {
1046
+ const cells = r.replace(/^\||\|$/g, '').split('|');
1047
+ html += '<tr>' + cells.map(c => (i === 0 && isSep ? '<th>' : '<td>') + inline(c.trim()) + (i === 0 && isSep ? '</th>' : '</td>')).join('') + '</tr>';
1048
+ });
1049
+ html += '</table>';
1050
+ out.push(html); tableBuf = []; inTable = false;
1051
+ };
1052
+ for (const line of lines) {
1053
+ if (line.trim().startsWith('```')) {
1054
+ if (inCode) { out.push('<pre><code>' + esc(codeBuf.join('\n')) + '</code></pre>'); codeBuf = []; inCode = false; }
1055
+ else { flushList(); flushTable(); inCode = true; codeLang = line.trim().slice(3); }
1056
+ continue;
1057
+ }
1058
+ if (inCode) { codeBuf.push(line); continue; }
1059
+ if (/^\s*$/.test(line)) { flushList(); flushTable(); continue; }
1060
+ if (/^\s*\|/.test(line)) { flushList(); tableBuf.push(line); inTable = true; continue; }
1061
+ else if (inTable) flushTable();
1062
+ const h = line.match(/^(#{1,4})\s+(.*)/);
1063
+ if (h) { flushList(); out.push(`<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`); continue; }
1064
+ if (/^\s*(---|\*\*\*)\s*$/.test(line)) { flushList(); out.push('<hr>'); continue; }
1065
+ const bq = line.match(/^>\s?(.*)/);
1066
+ if (bq) { flushList(); out.push(`<blockquote>${inline(bq[1])}</blockquote>`); continue; }
1067
+ const ul = line.match(/^(\s*)[-*+]\s+(.*)/);
1068
+ const ol = line.match(/^(\s*)\d+[.)]\s+(.*)/);
1069
+ if (ul || ol) {
1070
+ const want = ul ? 'ul' : 'ol';
1071
+ const depth = Math.floor(((ul || ol)[1]).length / 2);
1072
+ while (listStack.length > depth + 1) out.push(listStack.pop() === 'ul' ? '</ul>' : '</ol>');
1073
+ if (listStack.length === depth) { out.push(want === 'ul' ? '<ul>' : '<ol>'); listStack.push(want); }
1074
+ else if (listStack[depth] !== want) { out.push(listStack.pop() === 'ul' ? '</ul>' : '</ol>'); out.push(want === 'ul' ? '<ul>' : '<ol>'); listStack.push(want); }
1075
+ let content = (ul || ol)[2];
1076
+ const cb = content.match(/^\[([ xX])\]\s+(.*)/);
1077
+ if (cb) content = `<span style="color:${cb[1].toLowerCase()==='x'?'var(--green)':'var(--faint)'}">${cb[1].toLowerCase()==='x'?'☑':'☐'}</span> ${inline(cb[2])}`;
1078
+ else content = inline(content);
1079
+ out.push(`<li>${content}</li>`);
1080
+ continue;
1081
+ }
1082
+ flushList();
1083
+ out.push(`<p>${inline(line)}</p>`);
1084
+ }
1085
+ if (inCode) out.push('<pre><code>' + esc(codeBuf.join('\n')) + '</code></pre>');
1086
+ flushList(); flushTable();
1087
+ return out.join('\n');
1088
+ }
1089
+ function inline(s) {
1090
+ return esc(s)
1091
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
1092
+ .replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
1093
+ .replace(/(^|\s)\*([^*]+)\*/g, '$1<i>$2</i>')
1094
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
1095
+ }
1096
+
1097
+ /* ---------- 状态计算 ---------- */
1098
+ function changeStatus(c) {
1099
+ const errs = c.health.issues.filter(i => i.level === 'error').length;
1100
+ const warns = c.health.issues.filter(i => i.level === 'warn').length;
1101
+ const doneArts = c.artifacts.filter(a => a.exists).length;
1102
+ const allDone = doneArts === c.artifacts.length;
1103
+ return { errs, warns, doneArts, allDone };
1104
+ }
1105
+
1106
+ /* ---------- 生命周期 ---------- */
1107
+ const LC_STAGES = ['created', 'proposing', 'ready', 'applying', 'applied', 'archived'];
1108
+ const LC_LABELS = { created: '刚创建', proposing: '提议', ready: '待实施', applying: '实施', applied: '待审查', archived: '归档' };
1109
+ const LC_CMDS = {
1110
+ created: null,
1111
+ proposing: { text: '/opsx:propose', tip: 'AI 对话命令 · 产出规划工件(proposal/specs/…/tasks)' },
1112
+ ready: { text: '/opsx:apply', tip: 'AI 对话命令 · 规划工件已齐,就绪待执行' },
1113
+ applying: { text: '/opsx:apply', tip: 'AI 对话命令 · 执行中:勾选 tasks 并产出实施工件' },
1114
+ applied: { text: 'review.md', tip: '自建标记 · 人工审查通过后在变更目录创建 review.md' },
1115
+ archived: { text: 'openspec archive', tip: 'CLI 命令 · 归档变更(specs 合入主规格库)' },
1116
+ };
1117
+ function currentChange() {
1118
+ if (!sel) return null;
1119
+ const ws = DATA.workspaces[sel.wsIdx];
1120
+ return (sel.archived ? ws.archived : ws.active).find(x => x.name === sel.chName) || null;
1121
+ }
1122
+ function tabUsable(c, a) {
1123
+ if (!a.exists) return false;
1124
+ if (a.id === 'tasks') return !!(c.tasks && c.tasks.total);
1125
+ return a.files.length > 0;
1126
+ }
1127
+ function defaultTab(c) {
1128
+ const ti = c.artifacts.findIndex(a => a.id === 'tasks');
1129
+ if (ti >= 0 && tabUsable(c, c.artifacts[ti])) return ti;
1130
+ const fi = c.artifacts.findIndex(a => tabUsable(c, a));
1131
+ return fi >= 0 ? fi : 0;
1132
+ }
1133
+ function jumpToArtifact(id) {
1134
+ const c = currentChange(); if (!c) return;
1135
+ const i = c.artifacts.findIndex(a => a.id === id);
1136
+ if (i >= 0 && tabUsable(c, c.artifacts[i])) { curTab = i; curFile = 0; renderDetail(); }
1137
+ }
1138
+
1139
+ /* ---------- 任务组折叠 ---------- */
1140
+ const collapsedGroups = {}; // key = wsIdx:chName:groupIdx
1141
+ function toggleGroup(key) {
1142
+ collapsedGroups[key] = !collapsedGroups[key];
1143
+ renderDetail();
1144
+ }
1145
+
1146
+ /* ---------- 路径筛选 ---------- */
1147
+ let selRootFilter = ''; // '' = 全部
1148
+ function visibleWorkspaces() { return DATA.workspaces.filter(w => !selRootFilter || w.root === selRootFilter); }
1149
+
1150
+ /* 来源徽章: meta.yaml created-by → 短标签; 无 meta 显示未标记 */
1151
+ function originChip(c) {
1152
+ const by = c.meta && c.meta.createdBy;
1153
+ const KNOWN = { trae: 'Trae', tuanjieai: 'TuanjieAI', workbuddy: 'WorkBuddy', human: '人工' };
1154
+ const cls = by && KNOWN[by] ? by : 'unknown';
1155
+ const label = by ? (KNOWN[by] || by) : '未标记';
1156
+ const tip = by ? (c.meta.createdAt ? '创建于 ' + c.meta.createdAt : '来源 ' + by) : '无 meta.yaml, 创建工具未留标记';
1157
+ return `<span class="origin-chip ${cls}" title="${esc(tip)}">${esc(label)}</span>`;
1158
+ }
1159
+ /* ---------- 全文搜索 ---------- */
1160
+ let searchQ = '';
1161
+ let showingSearch = false;
1162
+ let srResults = []; // [{wi, archived, change, hits:[{artId, artLabel, rel, line, text, count}], total}]
1163
+
1164
+ function buildSearchResults(q) {
1165
+ const ql = q.toLowerCase();
1166
+ const out = [];
1167
+ DATA.workspaces.forEach((ws, wi) => {
1168
+ if (selRootFilter && ws.root !== selRootFilter) return;
1169
+ [['active', false], ['archived', true]].forEach(([key, arch]) => {
1170
+ for (const c of ws[key]) {
1171
+ const hits = [];
1172
+ const ttl = (c.title && c.title.text) || '';
1173
+ if (c.name.toLowerCase().includes(ql) || (ttl && ttl.toLowerCase().includes(ql)))
1174
+ hits.push({ artId: null, artLabel: '名称', rel: '', line: 0, text: (ttl || c.name) + ' · ' + c.name, count: 1 });
1175
+ let total = 0;
1176
+ for (const a of c.artifacts) {
1177
+ if (!a.exists) continue;
1178
+ for (const f of (a.files || [])) {
1179
+ if (!f.content) continue;
1180
+ const lower = f.content.toLowerCase();
1181
+ if (!lower.includes(ql)) continue;
1182
+ let count = 0, i = 0;
1183
+ while ((i = lower.indexOf(ql, i)) !== -1) { count++; i += ql.length; }
1184
+ const lines = f.content.split(/\r?\n/);
1185
+ let lineNo = 0, preview = '';
1186
+ for (let li = 0; li < lines.length; li++) {
1187
+ if (lines[li].toLowerCase().includes(ql)) { lineNo = li + 1; preview = lines[li].trim().slice(0, 200); break; }
1188
+ }
1189
+ hits.push({ artId: a.id, artLabel: artLabel(a), rel: f.rel, line: lineNo, text: preview, count });
1190
+ total += count;
1191
+ }
1192
+ }
1193
+ if (hits.length) out.push({ wi, archived: arch, change: c, hits, total });
1194
+ }
1195
+ });
1196
+ });
1197
+ return out;
1198
+ }
1199
+ function markMatch(text, q) {
1200
+ const s = esc(text);
1201
+ if (!q) return s;
1202
+ const needle = esc(q).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1203
+ return s.replace(new RegExp(needle, 'gi'), m => '<mark class="hit">' + m + '</mark>');
1204
+ }
1205
+ function renderSearchResults() {
1206
+ const main = document.getElementById('main');
1207
+ const nHit = srResults.reduce((n, r) => n + r.hits.length, 0);
1208
+ let html = `<div class="sr-wrap"><div class="sr-head">搜索 “<b>${esc(searchQ)}</b>” · 命中 ${srResults.length} 个变更 / ${nHit} 处</div>`;
1209
+ if (!srResults.length) html += '<div class="empty-panel">没有匹配的内容</div>';
1210
+ srResults.forEach((r, ri) => {
1211
+ const c = r.change;
1212
+ const st = changeStatus(c);
1213
+ const cls = st.errs ? 'err' : (st.warns ? 'warn' : 'ok');
1214
+ const lc = c.lifecycle;
1215
+ const ttl = (c.title && c.title.text) || c.name;
1216
+ html += '<div class="sr-item">'
1217
+ + `<div class="sr-ch" onclick="srGoTo(${ri},-1)"><span class="dot-s ${cls}"></span>`
1218
+ + (r.archived ? '<span class="stage-chip st-archived">归档</span>' : `<span class="stage-chip st-${lc.stage}">${esc(lc.label)}</span>`)
1219
+ + `${originChip(c)}<span class="ttl">${markMatch(ttl, searchQ)}</span><span class="sr-cnt">${r.total} 处</span></div>`;
1220
+ r.hits.slice(0, 8).forEach((h, hi) => {
1221
+ const loc = esc(h.artLabel) + (h.rel && h.rel !== h.artLabel ? ' / ' + esc(h.rel) : '') + (h.line ? ':' + h.line : '');
1222
+ html += `<div class="sr-hit" onclick="srGoTo(${ri},${hi})"><span class="sr-loc">${loc}</span>`
1223
+ + `<span class="sr-prev">${markMatch(h.text, searchQ)}</span>`
1224
+ + (h.count > 1 ? `<span class="sr-cnt">${h.count} 处</span>` : '')
1225
+ + '</div>';
1226
+ });
1227
+ if (r.hits.length > 8) html += `<div class="sr-more">… 其余 ${r.hits.length - 8} 处,点击标题进入后全文高亮查看</div>`;
1228
+ html += '</div>';
1229
+ });
1230
+ main.innerHTML = html + '</div>';
1231
+ main.scrollTop = 0;
1232
+ }
1233
+ function runSearch() {
1234
+ const q = (document.getElementById('searchBox').value || '').trim();
1235
+ searchQ = q;
1236
+ if (!q) { exitSearch(); return; }
1237
+ showingSearch = true;
1238
+ srResults = buildSearchResults(q);
1239
+ renderSearchResults();
1240
+ }
1241
+ function exitSearch() {
1242
+ showingSearch = false; srResults = [];
1243
+ searchQ = '';
1244
+ if (sel && currentChange()) renderDetail();
1245
+ else document.getElementById('main').innerHTML = '<div class="placeholder"><div>← 从左侧选择一个变更</div><div style="font-size:12px">任务状态 · 流程步骤 · 产物全文</div></div>';
1246
+ }
1247
+ function srGoTo(ri, hi) {
1248
+ const r = srResults[ri]; if (!r) return;
1249
+ showingSearch = false;
1250
+ select(r.wi, r.change.name, r.archived);
1251
+ if (hi >= 0) {
1252
+ const h = r.hits[hi]; const c = currentChange();
1253
+ if (h && h.artId && c) {
1254
+ const ai = c.artifacts.findIndex(a => a.id === h.artId);
1255
+ if (ai >= 0 && tabUsable(c, c.artifacts[ai])) {
1256
+ curTab = ai;
1257
+ const fi = c.artifacts[ai].files.findIndex(f => f.rel === h.rel);
1258
+ curFile = fi >= 0 ? fi : 0;
1259
+ renderDetail();
1260
+ }
1261
+ }
1262
+ }
1263
+ }
1264
+ function highlightTerm(root, term) {
1265
+ if (!root || !term) return;
1266
+ const tl = term.toLowerCase();
1267
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
1268
+ const nodes = [];
1269
+ while (walker.nextNode()) {
1270
+ const p = walker.currentNode.parentNode;
1271
+ if (p && /^(SCRIPT|STYLE|MARK)$/.test(p.nodeName)) continue;
1272
+ if (walker.currentNode.nodeValue && walker.currentNode.nodeValue.toLowerCase().includes(tl)) nodes.push(walker.currentNode);
1273
+ }
1274
+ nodes.forEach(n => {
1275
+ const v = n.nodeValue, lower = v.toLowerCase();
1276
+ const frag = document.createDocumentFragment();
1277
+ let i = 0, idx;
1278
+ while ((idx = lower.indexOf(tl, i)) !== -1) {
1279
+ if (idx > i) frag.appendChild(document.createTextNode(v.slice(i, idx)));
1280
+ const m = document.createElement('mark'); m.className = 'hit'; m.textContent = v.slice(idx, idx + tl.length);
1281
+ frag.appendChild(m); i = idx + tl.length;
1282
+ }
1283
+ if (i < v.length) frag.appendChild(document.createTextNode(v.slice(i)));
1284
+ n.parentNode.replaceChild(frag, n);
1285
+ });
1286
+ }
1287
+ (function initSearch() {
1288
+ const box = document.getElementById('searchBox');
1289
+ let timer = null;
1290
+ box.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(runSearch, 160); });
1291
+ box.addEventListener('keydown', e => {
1292
+ if (e.key === 'Enter') { clearTimeout(timer); runSearch(); if (srResults.length) srGoTo(0, 0); }
1293
+ else if (e.key === 'Escape') { box.value = ''; exitSearch(); box.blur(); }
1294
+ });
1295
+ document.addEventListener('keydown', e => {
1296
+ if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); box.focus(); box.select(); }
1297
+ });
1298
+ })();
1299
+
1300
+ /* ---------- 侧边栏 ---------- */
1301
+ let sel = null; // {wsIdx, chName, archived}
1302
+ let archOpen = {}; // key: wsIdx -> 归档栏是否展开(渲染时按状态输出,避免重建后丢失)
1303
+ function toggleArch(wi) {
1304
+ const sb = document.getElementById('sidebar');
1305
+ const top = sb.scrollTop;
1306
+ archOpen[wi] = !archOpen[wi];
1307
+ renderSidebar();
1308
+ sb.scrollTop = top; // 重建后保持侧边栏滚动位置
1309
+ }
1310
+ function renderSidebar() {
1311
+ let html = '';
1312
+ DATA.workspaces.forEach((ws, wi) => {
1313
+ if (selRootFilter && ws.root !== selRootFilter) return;
1314
+ const wsName = ws.root.split(/[\\/]/).pop();
1315
+ html += `<div class="ws-group"><div class="ws-name">${esc(wsName)}<span class="cnt">进行中 ${ws.active.length}</span></div>`;
1316
+ for (const c of ws.active) {
1317
+ const st = changeStatus(c);
1318
+ const cls = st.errs ? 'err' : (st.warns ? 'warn' : 'ok');
1319
+ const lc = c.lifecycle;
1320
+ const ttl = (c.title && c.title.text) || c.name;
1321
+ html += `<div class="ch-item ${sel && sel.wsIdx===wi && sel.chName===c.name ? 'sel' : ''}" onclick="select(${wi},'${esc(c.name)}',false)" title="${esc(ttl)} &#10;${esc(c.name)}">
1322
+ <div class="row1"><span class="dot-s ${cls}"></span><span class="ttl">${esc(ttl)}</span></div>
1323
+ <div class="row2"><span class="slug">${esc(c.name)}</span>${c.meta && c.meta.createdBy ? originChip(c) : ''}<span class="stage-chip st-${lc.stage}">${esc(lc.label)}</span></div></div>`;
1324
+ }
1325
+ if (ws.archived.length) {
1326
+ html += `<div class="arch-toggle" onclick="toggleArch(${wi})">${archOpen[wi] ? '▾' : '▸'} 归档 (${ws.archived.length})</div>`;
1327
+ html += `<div style="display:${archOpen[wi] ? 'block' : 'none'}">`;
1328
+ for (const c of ws.archived) {
1329
+ const st = changeStatus(c);
1330
+ const ttl = (c.title && c.title.text) || c.name;
1331
+ html += `<div class="ch-item archived ${sel && sel.wsIdx===wi && sel.chName===c.name ? 'sel' : ''}" onclick="select(${wi},'${esc(c.name)}',true)" title="${esc(ttl)} &#10;${esc(c.name)}">
1332
+ <div class="row1"><span class="dot-s ${st.errs ? 'err' : 'ok'}"></span><span class="ttl">${esc(ttl)}</span></div>
1333
+ <div class="row2"><span class="slug">${esc(c.name)}</span></div></div>`;
1334
+ }
1335
+ html += `</div>`;
1336
+ }
1337
+ html += `</div>`;
1338
+ });
1339
+ document.getElementById('sidebar').innerHTML = html;
1340
+ }
1341
+
1342
+ /* ---------- 详情 ---------- */
1343
+ let curTab = 0, curFile = 0;
1344
+ function select(wsIdx, name, archived) {
1345
+ const sb = document.getElementById('sidebar');
1346
+ const sbTop = sb.scrollTop;
1347
+ sel = { wsIdx, chName: name, archived: !!archived };
1348
+ curFile = 0;
1349
+ const c = currentChange();
1350
+ curTab = c ? defaultTab(c) : 0;
1351
+ if (archived) archOpen[wsIdx] = true; // 选中归档项:确保其所在归档栏保持展开(含重扫恢复场景)
1352
+ renderSidebar(); renderDetail();
1353
+ sb.scrollTop = sbTop; // 重建侧边栏后保持滚动位置,点击的归档项不跑出视野
1354
+ document.getElementById('main').scrollTop = 0; // 仅切换变更时回顶; 切 tab / 折叠组 / 点产物清单 都不重置滚动
1355
+ }
1356
+ function renderDetail() {
1357
+ if (!sel) return;
1358
+ const ws = DATA.workspaces[sel.wsIdx];
1359
+ const c = (sel.archived ? ws.archived : ws.active).find(x => x.name === sel.chName);
1360
+ if (!c) return;
1361
+ const st = changeStatus(c);
1362
+ const lc = c.lifecycle;
1363
+
1364
+ /* ===== 头部: 标题 + 徽章, 紧凑一行 ===== */
1365
+ let html = `<div class="detail">
1366
+ <div class="detail-head">
1367
+ <h2>${c.title && c.title.text ? esc(c.title.text) : esc(c.name)}</h2>
1368
+ <span class="stage-chip st-${lc.stage}">${esc(lc.label)}</span>
1369
+ ${originChip(c)}
1370
+ ${c.title && c.title.reqId ? `<span class="badge">禅道 ${esc(c.title.reqId)}</span>` : ''}
1371
+ ${st.errs ? `<span class="badge red">${st.errs} 违规</span>` : ''}
1372
+ ${st.warns ? `<span class="badge amber">${st.warns} 警告</span>` : ''}
1373
+ ${!st.errs && !st.warns ? '<span class="badge green">产物健康</span>' : ''}
1374
+ <span class="path" title="${esc(c.dir)}">${esc(c.name)} · ${esc(ws.schemaName)}${c.title && c.title.meta ? ' · ' + esc(c.title.meta) : ''}</span>
1375
+ </div>`;
1376
+
1377
+ /* ===== 主体两列: 左 tabs+内容 / 右概览(sticky) ===== */
1378
+ html += '<div class="body-cols"><div class="content-col">';
1379
+
1380
+ // tabs: 详情区第一行, 全量工件, 未产出灰显
1381
+ if (curTab >= c.artifacts.length) curTab = 0;
1382
+ html += '<div class="tabs">';
1383
+ c.artifacts.forEach((a, i) => {
1384
+ const usable = tabUsable(c, a);
1385
+ let label = esc(artLabel(a));
1386
+ if (a.id === 'tasks' && c.tasks && c.tasks.total) label += ` ${c.tasks.done}/${c.tasks.total}`;
1387
+ let tag;
1388
+ if (!a.exists) tag = 'off';
1389
+ else if (a.missing && a.missing.length) tag = 'warn';
1390
+ else tag = 'on';
1391
+ const tip = esc(a.id + ' · ' + a.generates) + (!a.exists ? ' · 未产出' : ((a.missing && a.missing.length) ? ' · 缺节: ' + a.missing.join('、') : ' · 完整'));
1392
+ html += usable
1393
+ ? `<div class="tab ${i === curTab ? 'on' : ''}" title="${tip}" onclick="curTab=${i};curFile=0;renderDetail()">${label}<span class="tag ${tag}"></span></div>`
1394
+ : `<div class="tab off" title="${tip}">${label}<span class="tag off"></span></div>`;
1395
+ });
1396
+ html += '</div>';
1397
+
1398
+ // tab 内容
1399
+ const a = c.artifacts[curTab];
1400
+ html += '<div class="tab-body">';
1401
+ if (a.id === 'tasks') {
1402
+ if (c.tasks && c.tasks.total) {
1403
+ c.tasks.groups.forEach((g, gi) => {
1404
+ const key = `${sel.wsIdx}:${sel.chName}:${gi}`;
1405
+ const col = !!collapsedGroups[key];
1406
+ const doneN = g.items.filter(t => t.done).length;
1407
+ html += `<div class="task-group"><div class="task-group-title" onclick="toggleGroup('${key}')"><span class="arrow">${col ? '▸' : '▾'}</span>${esc(g.title)}<span class="mini">${doneN}/${g.items.length}</span></div>`;
1408
+ if (!col) {
1409
+ for (const t of g.items) {
1410
+ html += `<div class="task ${t.done ? 'done' : ''}"><span class="cb"></span><span class="tx">${inline(t.text)}</span></div>`;
1411
+ }
1412
+ }
1413
+ html += `</div>`;
1414
+ });
1415
+ } else {
1416
+ html += '<div class="empty-panel">tasks.md 尚未产出</div>';
1417
+ }
1418
+ } else if (a.exists && a.files.length) {
1419
+ if (a.files.length > 1) {
1420
+ if (curFile >= a.files.length) curFile = 0;
1421
+ html += '<div class="file-tabs">';
1422
+ a.files.forEach((f, i) => {
1423
+ html += `<div class="file-tab ${i === curFile ? 'on' : ''}" onclick="curFile=${i};renderDetail()">${esc(f.rel)}</div>`;
1424
+ });
1425
+ html += '</div>';
1426
+ }
1427
+ const f = a.files[Math.min(curFile, a.files.length - 1)];
1428
+ html += `<div class="md">${renderMd(f.content)}</div>`;
1429
+ } else {
1430
+ html += `<div class="empty-panel">「${esc(a.generates)}」未产出<br><span style="font-size:12px">该工件将在流程推进到对应阶段后生成</span></div>`;
1431
+ }
1432
+ html += '</div></div>'; // /tab-body /content-col
1433
+
1434
+ /* ===== 右侧概览栏 ===== */
1435
+ html += '<div class="overview-col">';
1436
+
1437
+ // 生命周期竖排时间线
1438
+ let curIdx = LC_STAGES.indexOf(lc.stage);
1439
+ let doneThrough = curIdx - 1; // 当前阶段之前的所有阶段都算已完成, 时间线才体现得出进度
1440
+ if (lc.stage === 'reviewed') { curIdx = -1; doneThrough = 4; }
1441
+ if (lc.stage === 'archived') { curIdx = -1; doneThrough = 5; }
1442
+ html += '<div class="ov-card"><div class="ov-head">流程阶段</div><div class="lc-vert">';
1443
+ LC_STAGES.forEach((s, i) => {
1444
+ const cls = i <= doneThrough ? 'done' : (i === curIdx ? 'cur' : '');
1445
+ const cmd = LC_CMDS[s];
1446
+ html += `<div class="lc-vstep ${cls}"><span class="rail"><span class="nd2"></span></span><span class="lb">${LC_LABELS[s]}</span>${cmd ? `<span class="lc-cmd" title="${esc(cmd.tip)}">${esc(cmd.text)}</span>` : ''}</div>`;
1447
+ });
1448
+ html += '</div></div>';
1449
+
1450
+ // 下一步提示
1451
+ const attn = (lc.stage === 'proposing') || (lc.incomplete && lc.incomplete.length > 0 && lc.stage !== 'archived');
1452
+ html += `<div class="next-action ${attn ? 'attn' : ''}" title="建议的下一步动作">${esc(lc.nextAction)}</div>`;
1453
+
1454
+ // 产物清单卡
1455
+ const producedN = c.artifacts.filter(x => x.exists).length;
1456
+ const incompleteN = c.artifacts.filter(x => x.exists && x.missing && x.missing.length).length;
1457
+ html += `<div class="ov-card"><div class="ov-head">产物清单 <span class="mini">${producedN}/${c.artifacts.length}${incompleteN ? ` · <span style="color:var(--red)">${incompleteN} 不完整</span>` : ' · 结构完整'}</span></div>`;
1458
+ for (const x of c.artifacts) {
1459
+ const status = x.exists
1460
+ ? ((x.missing && x.missing.length)
1461
+ ? `<span class="art-st" style="color:var(--amber)">⚠</span><span class="art-miss" style="font-size:11px;color:var(--red);flex-shrink:0;max-width:40%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(x.missing.join('、'))}">缺${x.missing.length}节</span>`
1462
+ : '<span class="art-st ok">✓</span>')
1463
+ : '<span class="art-st no">○</span>';
1464
+ const usable = tabUsable(c, x);
1465
+ html += `<div class="art-row ${usable ? 'link' : 'noart'}" ${usable ? `onclick="jumpToArtifact('${esc(x.id)}')"` : ''} title="${esc(x.generates)}">
1466
+ <span class="fn">${esc(artLabel(x))} · ${esc(x.generates)}</span>${status}</div>`;
1467
+ }
1468
+ html += '</div>';
1469
+
1470
+ // 健康检查卡
1471
+ html += '<div class="ov-card"><div class="ov-head">健康检查</div>';
1472
+ if (c.health.issues.length) {
1473
+ for (const i of c.health.issues) html += `<div class="issue ${i.level}">${i.level === 'error' ? '✗' : '⚠'} ${esc(i.msg)}</div>`;
1474
+ } else {
1475
+ html += '<div class="issue ok">✓ 无违规 · 无警告</div>';
1476
+ }
1477
+ html += '</div>';
1478
+
1479
+ html += '</div></div></div>'; // /overview-col /body-cols /detail
1480
+ document.getElementById('main').innerHTML = html;
1481
+ if (searchQ && !showingSearch) { try { highlightTerm(document.getElementById('main'), searchQ); } catch (e) {} }
1482
+ }
1483
+
1484
+ /* ---------- 顶栏 ---------- */
1485
+ function updateStats() {
1486
+ const wsList = visibleWorkspaces();
1487
+ const total = wsList.reduce((n, w) => n + w.active.length + w.archived.length, 0);
1488
+ const active = wsList.reduce((n, w) => n + w.active.length, 0);
1489
+ const byStage = {};
1490
+ for (const w of wsList) for (const c of w.active) byStage[c.lifecycle.stage] = (byStage[c.lifecycle.stage] || 0) + 1;
1491
+ const errs = wsList.reduce((n, w) => n + w.active.reduce((m, c) => m + c.health.issues.filter(i => i.level === 'error').length, 0), 0);
1492
+ // 零值项不占位("提议中 0 / 待审查 0" 恒占宽度却不传递信息), 完整口径落在 title 里
1493
+ const seg = (k, label) => (byStage[k] ? `<span>${label} <b>${byStage[k]}</b></span>` : '');
1494
+ const nProp = byStage.proposing || 0, nApply = byStage.applying || 0, nReview = byStage.applied || 0;
1495
+ document.getElementById('meta').textContent = '生成于 ' + new Date(DATA.generatedAt).toLocaleString('zh-CN');
1496
+ const statsEl = document.getElementById('stats');
1497
+ statsEl.innerHTML =
1498
+ `<span>变更 <b>${total}</b></span><span>进行中 <b>${active}</b></span>`
1499
+ + seg('proposing', '提议中') + seg('applying', '实施中') + seg('applied', '待审查')
1500
+ + `<span class="${errs ? 'bad' : ''}">违规 <b>${errs}</b></span>`;
1501
+ statsEl.title = `变更 ${total} · 进行中 ${active}(提议中 ${nProp} / 实施中 ${nApply} / 待审查 ${nReview})· 违规 ${errs}`;
1502
+ }
1503
+ function resetView() {
1504
+ sel = null;
1505
+ renderSidebar();
1506
+ updateStats();
1507
+ document.getElementById('main').innerHTML = EMPTY_HTML;
1508
+ }
1509
+ (function init() {
1510
+ const ROOTS = DATA.roots || [];
1511
+ const rootSelEl = document.getElementById('rootFilter');
1512
+ let saved = '';
1513
+ try { saved = localStorage.getItem('imsflow.root') || ''; } catch (e) {}
1514
+ if (!ROOTS.includes(saved)) saved = ROOTS[0] || '';
1515
+ selRootFilter = saved;
1516
+ rootSelEl.innerHTML = '<option value="">全部(含子仓库)</option>'
1517
+ + ROOTS.map(r => `<option value="${esc(r)}" title="${esc(r)}">${esc(r.split(/[\\/]/).pop())}(仅根目录)</option>`).join('');
1518
+ rootSelEl.value = selRootFilter;
1519
+ rootSelEl.onchange = () => {
1520
+ selRootFilter = rootSelEl.value;
1521
+ try { localStorage.setItem('imsflow.root', selRootFilter); } catch (e) {}
1522
+ resetView();
1523
+ if (searchQ) runSearch(); // 搜索激活时按新路径过滤重新搜索
1524
+ };
1525
+ updateStats();
1526
+ renderSidebar();
1527
+ // 恢复重扫前选中的变更(serve 模式整页重载后从 sessionStorage 找回)
1528
+ try {
1529
+ const savedSel = sessionStorage.getItem('imsflow.sel');
1530
+ if (savedSel) {
1531
+ const s = JSON.parse(savedSel);
1532
+ const wi = DATA.workspaces.findIndex(w => w.root === s.root);
1533
+ if (wi >= 0) {
1534
+ const ws = DATA.workspaces[wi];
1535
+ let picked = false;
1536
+ if (ws.active.some(x => x.name === s.chName)) { select(wi, s.chName, false); picked = true; }
1537
+ else if (ws.archived.some(x => x.name === s.chName)) { select(wi, s.chName, true); picked = true; }
1538
+ if (picked && typeof s.tab === 'number') {
1539
+ const c = currentChange();
1540
+ if (c && s.tab >= 0 && s.tab < c.artifacts.length && tabUsable(c, c.artifacts[s.tab])) { curTab = s.tab; renderDetail(); }
1541
+ }
1542
+ }
1543
+ }
1544
+ } catch (e) {}
1545
+ })();
1546
+
1547
+ /* ---------- 重新扫描按钮(需 imsflow serve 服务模式;快照模式给出引导) ---------- */
1548
+ // 重扫/切根整页重载前, 把当前选中变更存入 sessionStorage(init 时找回)
1549
+ function saveCurrentSel() {
1550
+ try {
1551
+ if (sel && DATA.workspaces[sel.wsIdx]) {
1552
+ sessionStorage.setItem('imsflow.sel', JSON.stringify({ root: DATA.workspaces[sel.wsIdx].root, chName: sel.chName, archived: sel.archived, tab: curTab }));
1553
+ } else {
1554
+ sessionStorage.removeItem('imsflow.sel');
1555
+ }
1556
+ } catch (e) {}
1557
+ }
1558
+ (function initRescan() {
1559
+ const btn = document.getElementById('rescanBtn');
1560
+ const served = /^https?:$/.test(location.protocol);
1561
+ if (!served) btn.title = '快照模式:页面无法触发重扫。终端重跑 imsflow,或用 imsflow serve 启动服务模式';
1562
+ btn.onclick = async () => {
1563
+ if (!served) {
1564
+ alert('当前是快照模式(本地文件直接打开),页面无法触发重新扫描。\n\n两种刷新方式:\n 1. 终端重新执行 imsflow(生成新快照并自动打开)\n 2. 使用 imsflow serve 启动服务模式,页面内即可一键重扫');
1565
+ return;
1566
+ }
1567
+ btn.disabled = true;
1568
+ btn.classList.add('spinning'); btn.title = '扫描中…约 10 秒';
1569
+ try {
1570
+ saveCurrentSel();
1571
+ const r = await fetch('/api/rescan');
1572
+ if (!r.ok) throw new Error('HTTP ' + r.status + (r.status === 500 ? ' ' + (await r.text().catch(() => '')).slice(0, 120) : ''));
1573
+ location.reload();
1574
+ } catch (e) {
1575
+ alert('重新扫描失败: ' + e.message + '\n\n常见原因: imsflow serve 进程不在了(关闭终端或 Ctrl+C 都会把它关掉)。重启: imsflow serve');
1576
+ btn.disabled = false;
1577
+ btn.classList.remove('spinning'); btn.title = '重新扫描全部工作区';
1578
+ }
1579
+ };
1580
+ })();
1581
+
1582
+ /* ---------- 工作区管理面板:页面增删扫描根 / 勾选本次扫描(需 serve 模式) ---------- */
1583
+ (function initWsPanel() {
1584
+ const btn = document.getElementById('wsBtn');
1585
+ const panel = document.getElementById('wsPanel');
1586
+ const served = /^https?:$/.test(location.protocol);
1587
+ let configured = null; // serve 模式: /api/roots 返回的已配置根; 快照模式: null(退化为 DATA.roots 只读)
1588
+
1589
+ function setMsg(text) {
1590
+ const el = document.getElementById('wsMsg');
1591
+ if (el) el.textContent = text || '';
1592
+ }
1593
+
1594
+ function render() {
1595
+ const editable = served && configured !== null;
1596
+ const list = configured || DATA.roots || [];
1597
+ const scanned = new Set(DATA.roots || []);
1598
+ let html = '<h4>扫描工作区</h4>'
1599
+ + '<div class="ws-sub">勾选 = 本次扫描包含 · × = 从 projects.json 移除(只改配置,不动文件)</div>';
1600
+ if (list.length === 0) html += '<div class="ws-hint">(未配置任何扫描根)</div>';
1601
+ for (const r of list) {
1602
+ html += '<div class="ws-row" title="' + esc(r) + '">'
1603
+ + '<input type="checkbox" value="' + esc(r) + '"' + (scanned.has(r) ? ' checked' : '') + (editable ? '' : ' disabled') + '>'
1604
+ + '<span class="ws-path">' + esc(r) + '</span>'
1605
+ + (editable ? '<button class="ws-del" data-root="' + esc(r) + '" title="移除该扫描根">×</button>' : '')
1606
+ + '</div>';
1607
+ }
1608
+ html += '<div class="ws-add">'
1609
+ + '<input id="wsAddPath" type="text" placeholder="输入目录绝对路径,如 D:\\project\\company\\供应链"' + (editable ? '' : ' disabled') + '>'
1610
+ + '<button id="wsAddBtn"' + (editable ? '' : ' disabled') + '>添加</button>'
1611
+ + '</div>'
1612
+ + '<div class="ws-foot">'
1613
+ + '<button id="wsScanBtn"' + (editable ? '' : ' disabled') + '>扫描选中</button>'
1614
+ + '<span class="ws-msg" id="wsMsg"></span>'
1615
+ + '</div>';
1616
+ if (!served) html += '<div class="ws-hint">快照模式只读。页面内增删 / 勾选需服务模式:<code>imsflow serve</code></div>';
1617
+ panel.innerHTML = html;
1618
+ if (!editable) return;
1619
+ document.getElementById('wsAddBtn').onclick = doAdd;
1620
+ document.getElementById('wsScanBtn').onclick = doScan;
1621
+ const input = document.getElementById('wsAddPath');
1622
+ input.onkeydown = e => { if (e.key === 'Enter') doAdd(); };
1623
+ panel.querySelectorAll('.ws-del').forEach(b => {
1624
+ b.onclick = () => { doRemove(b.dataset.root); };
1625
+ });
1626
+ }
1627
+
1628
+ // 统一 POST: 成功后存选态整页重载, 失败把错误显示在面板内
1629
+ async function post(url, body) {
1630
+ try {
1631
+ const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
1632
+ const j = await r.json().catch(() => ({}));
1633
+ if (!r.ok || j.ok === false) { setMsg(j.error || 'HTTP ' + r.status); return false; }
1634
+ saveCurrentSel();
1635
+ location.reload();
1636
+ return true;
1637
+ } catch (e) {
1638
+ setMsg('请求失败: ' + e.message);
1639
+ return false;
1640
+ }
1641
+ }
1642
+
1643
+ async function doAdd() {
1644
+ const p = document.getElementById('wsAddPath').value.trim().replace(/^["']+|["']+$/g, '');
1645
+ if (!p) return;
1646
+ document.getElementById('wsAddBtn').disabled = true;
1647
+ const ok = await post('/api/roots', { action: 'add', root: p });
1648
+ if (!ok) document.getElementById('wsAddBtn').disabled = false; // 成功会整页重载, 走不到这里
1649
+ }
1650
+
1651
+ async function doRemove(root) {
1652
+ await post('/api/roots', { action: 'remove', root });
1653
+ }
1654
+
1655
+ async function doScan() {
1656
+ const checked = [...panel.querySelectorAll('.ws-row input:checked')].map(i => i.value);
1657
+ if (!checked.length) { setMsg('至少勾选一个扫描根'); return; }
1658
+ document.getElementById('wsScanBtn').disabled = true;
1659
+ const ok = await post('/api/rescan', { roots: checked });
1660
+ if (!ok) document.getElementById('wsScanBtn').disabled = false;
1661
+ }
1662
+
1663
+ const scrim = document.getElementById('wsScrim');
1664
+ const closePanel = () => { panel.hidden = true; if (scrim) scrim.hidden = true; };
1665
+ btn.onclick = async e => {
1666
+ e.stopPropagation();
1667
+ if (!panel.hidden) { closePanel(); return; }
1668
+ panel.hidden = false;
1669
+ if (scrim) scrim.hidden = false;
1670
+ render();
1671
+ if (served && configured === null) {
1672
+ try {
1673
+ const r = await fetch('/api/roots');
1674
+ const j = await r.json();
1675
+ if (j.ok) { configured = j.roots; render(); }
1676
+ } catch (e2) { /* 接口失败保持 DATA.roots 只读态 */ }
1677
+ }
1678
+ };
1679
+ // 点击遮罩 / 面板外任意处关闭;Esc 也可关
1680
+ document.addEventListener('click', e => {
1681
+ if (!panel.hidden && !panel.contains(e.target) && !btn.contains(e.target)) closePanel();
1682
+ });
1683
+ document.addEventListener('keydown', e => { if (e.key === 'Escape' && !panel.hidden) closePanel(); });
1684
+ })();
1685
+
1686
+ /* ---------- 主题切换(初值已由 <head> 内联脚本落下,此处只处理手动切换) ---------- */
1687
+ (function initTheme() {
1688
+ const btn = document.getElementById('themeBtn');
1689
+ const root = document.documentElement;
1690
+ if (!btn) return;
1691
+ btn.onclick = () => {
1692
+ const next = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
1693
+ root.setAttribute('data-theme', next);
1694
+ try { localStorage.setItem('imsflow.theme', next); } catch (e) {}
1695
+ };
1696
+ })();
1697
+ </script>
1698
+ </body>
1699
+ </html>