hypercore-cli 1.1.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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -0
  3. package/dist/api-XGC7D5AW.js +162 -0
  4. package/dist/auth-DNQWYQKT.js +21 -0
  5. package/dist/background-2EGCAAQH.js +14 -0
  6. package/dist/backlog-Q2NZCLNY.js +24 -0
  7. package/dist/chunk-2CMSCWQW.js +162 -0
  8. package/dist/chunk-2LJ2DVEB.js +167 -0
  9. package/dist/chunk-3RPFCQKJ.js +288 -0
  10. package/dist/chunk-43OLRXM5.js +263 -0
  11. package/dist/chunk-4DVYJAJL.js +57 -0
  12. package/dist/chunk-6OL3GA3P.js +173 -0
  13. package/dist/chunk-AUHU7ALH.js +2023 -0
  14. package/dist/chunk-B6A2AKLN.js +139 -0
  15. package/dist/chunk-BE46C7JW.js +46 -0
  16. package/dist/chunk-CUVAUOXL.js +58 -0
  17. package/dist/chunk-GH7E2OJE.js +223 -0
  18. package/dist/chunk-GOOTEPBK.js +271 -0
  19. package/dist/chunk-GPPMJYSM.js +133 -0
  20. package/dist/chunk-GU2FZQ6A.js +69 -0
  21. package/dist/chunk-IOPKN5GD.js +190 -0
  22. package/dist/chunk-IXOIOGR5.js +1505 -0
  23. package/dist/chunk-KRPOPWGA.js +251 -0
  24. package/dist/chunk-MGLJ53QN.js +219 -0
  25. package/dist/chunk-MV4TTRYX.js +533 -0
  26. package/dist/chunk-OPZYEVYR.js +150 -0
  27. package/dist/chunk-QTSLP47C.js +166 -0
  28. package/dist/chunk-R3GPQC7I.js +393 -0
  29. package/dist/chunk-RKB2JOV2.js +43 -0
  30. package/dist/chunk-RNG3K465.js +80 -0
  31. package/dist/chunk-TGTYKBGC.js +86 -0
  32. package/dist/chunk-U5SGAIMM.js +681 -0
  33. package/dist/chunk-V5UHPPSY.js +140 -0
  34. package/dist/chunk-WHLVZCQY.js +245 -0
  35. package/dist/chunk-XDRCBMZZ.js +66 -0
  36. package/dist/chunk-XOS6HPEF.js +134 -0
  37. package/dist/chunk-ZSBHUGWR.js +262 -0
  38. package/dist/claude-NSQ442XD.js +12 -0
  39. package/dist/commands-CK3WFAGI.js +128 -0
  40. package/dist/commands-U63OEO5J.js +1044 -0
  41. package/dist/commands-ZE6GD3WC.js +232 -0
  42. package/dist/config-4EW42BSF.js +8 -0
  43. package/dist/config-loader-SXO674TF.js +24 -0
  44. package/dist/diagnose-AFW3ZTZ4.js +12 -0
  45. package/dist/display-IIUBEYWN.js +58 -0
  46. package/dist/extractor-QV53W2YJ.js +129 -0
  47. package/dist/history-WMSCHERZ.js +180 -0
  48. package/dist/index.d.ts +1 -0
  49. package/dist/index.js +406 -0
  50. package/dist/instance-registry-YSIJXSO7.js +15 -0
  51. package/dist/keybindings-JAAMLH3G.js +15 -0
  52. package/dist/loader-WHNTZTLP.js +58 -0
  53. package/dist/network-MM6YWPGO.js +279 -0
  54. package/dist/notify-HPTALZDC.js +14 -0
  55. package/dist/openai-compat-UQWJXBEK.js +12 -0
  56. package/dist/permissions-JUKXMNDH.js +10 -0
  57. package/dist/prompt-QV45TXRL.js +166 -0
  58. package/dist/quality-ST7PPNFR.js +16 -0
  59. package/dist/repl-RT3AHL7M.js +3375 -0
  60. package/dist/roadmap-5OBEKROY.js +17 -0
  61. package/dist/server-PORT7OEG.js +57 -0
  62. package/dist/session-4VUNDWLH.js +21 -0
  63. package/dist/skills-V4A35XKG.js +175 -0
  64. package/dist/store-Y4LU5QTO.js +25 -0
  65. package/dist/team-HO7Z4SIM.js +385 -0
  66. package/dist/telemetry-6R4EIE6O.js +30 -0
  67. package/dist/test-runner-ZQH5Y6OJ.js +619 -0
  68. package/dist/theme-3SYJ3UQA.js +14 -0
  69. package/dist/upgrade-7TGI3SXO.js +83 -0
  70. package/dist/verify-JUDKTPKZ.js +14 -0
  71. package/dist/web/static/app.js +562 -0
  72. package/dist/web/static/index.html +132 -0
  73. package/dist/web/static/mirror.css +1001 -0
  74. package/dist/web/static/mirror.html +184 -0
  75. package/dist/web/static/mirror.js +1125 -0
  76. package/dist/web/static/onboard.css +302 -0
  77. package/dist/web/static/onboard.html +140 -0
  78. package/dist/web/static/onboard.js +260 -0
  79. package/dist/web/static/style.css +602 -0
  80. package/dist/web/static/workspace.css +1568 -0
  81. package/dist/web/static/workspace.html +408 -0
  82. package/dist/web/static/workspace.js +1683 -0
  83. package/dist/web-Z5HSCQHW.js +39 -0
  84. package/package.json +67 -0
@@ -0,0 +1,1125 @@
1
+ /**
2
+ * HyperMirror - 内观引擎 Dashboard Frontend
3
+ */
4
+
5
+ const $ = (sel) => document.querySelector(sel);
6
+ const $$ = (sel) => document.querySelectorAll(sel);
7
+
8
+ const state = {
9
+ activeTab: 'overview',
10
+ refreshTimer: null,
11
+ testPollTimer: null,
12
+ selectedSuiteId: '',
13
+ testHistoryStatusFilter: 'all',
14
+ testHistoryVersionFilter: 'all',
15
+ testHistoryQuery: '',
16
+ testsSnapshot: null,
17
+ selectedRunId: '',
18
+ selectedRunLoading: false,
19
+ selectedRunError: '',
20
+ selectedRunDetail: null,
21
+ autoConfigSaving: false,
22
+ };
23
+
24
+ const TEST_PREFS_STORAGE_KEY = 'hypermirror.testprefs.v1';
25
+
26
+ // ===== Utilities =====
27
+
28
+ function escapeHtml(text) {
29
+ const div = document.createElement('div');
30
+ div.textContent = text;
31
+ return div.innerHTML;
32
+ }
33
+
34
+ async function fetchJSON(url) {
35
+ const res = await fetch(url);
36
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
37
+ return res.json();
38
+ }
39
+
40
+ async function postJSON(url, body = {}) {
41
+ const res = await fetch(url, {
42
+ method: 'POST',
43
+ headers: { 'Content-Type': 'application/json' },
44
+ body: JSON.stringify(body),
45
+ });
46
+ const text = await res.text();
47
+ let json = {};
48
+ try {
49
+ json = text ? JSON.parse(text) : {};
50
+ } catch {
51
+ json = {};
52
+ }
53
+ if (!res.ok) {
54
+ throw new Error(json.error || `HTTP ${res.status}`);
55
+ }
56
+ return json;
57
+ }
58
+
59
+ function relativeTime(isoStr) {
60
+ if (!isoStr) return '';
61
+ const diff = Date.now() - new Date(isoStr).getTime();
62
+ const mins = Math.floor(diff / 60000);
63
+ if (mins < 1) return '刚刚';
64
+ if (mins < 60) return `${mins}m ago`;
65
+ const hours = Math.floor(mins / 60);
66
+ if (hours < 24) return `${hours}h ago`;
67
+ const days = Math.floor(hours / 24);
68
+ return `${days}d ago`;
69
+ }
70
+
71
+ const WUXING_MAP = {
72
+ '木': { icon: '\uD83C\uDF33', cls: 'wx-wood', en: 'Wood' },
73
+ '火': { icon: '\uD83D\uDD25', cls: 'wx-fire', en: 'Fire' },
74
+ '水': { icon: '\uD83C\uDF0A', cls: 'wx-water', en: 'Water' },
75
+ '金': { icon: '\u2699\uFE0F', cls: 'wx-metal', en: 'Metal' },
76
+ '土': { icon: '\uD83C\uDFD4\uFE0F', cls: 'wx-earth', en: 'Earth' },
77
+ };
78
+
79
+ const PRIORITY_LABELS = { S: 'S', A: 'A', B: 'B', C: 'C' };
80
+ const STATUS_LABELS = { idea: 'Idea', planned: 'Planned', developing: 'Dev', done: 'Done', archived: 'Archived' };
81
+
82
+ function badgePriority(p) {
83
+ return `<span class="badge badge-priority-${p}">${p}</span>`;
84
+ }
85
+ function badgeStatus(s) {
86
+ return `<span class="badge badge-status badge-status-${s}">${STATUS_LABELS[s] || s}</span>`;
87
+ }
88
+ function badgeWuxing(w) {
89
+ const info = WUXING_MAP[w];
90
+ return info ? `<span class="badge-wuxing" title="${info.en}">${info.icon}</span>` : '';
91
+ }
92
+ function badgeType(t) {
93
+ return `<span class="badge badge-type">${t}</span>`;
94
+ }
95
+ function badgeSeverity(s) {
96
+ return `<span class="badge badge-severity badge-severity-${s}">${s}</span>`;
97
+ }
98
+ function badgeVerdict(v) {
99
+ const labels = { effective: '有效', ineffective: '无效', pending: '待观察', 'no-data': '无数据' };
100
+ return `<span class="badge badge-verdict badge-verdict-${v}">${labels[v] || v}</span>`;
101
+ }
102
+
103
+ function emptyState(icon, text) {
104
+ return `<div class="mirror-empty"><div class="mirror-empty-icon">${icon}</div><div>${text}</div></div>`;
105
+ }
106
+
107
+ function loadTestPreferences() {
108
+ try {
109
+ const raw = localStorage.getItem(TEST_PREFS_STORAGE_KEY);
110
+ if (!raw) return;
111
+ const parsed = JSON.parse(raw);
112
+ if (!parsed || typeof parsed !== 'object') return;
113
+
114
+ if (typeof parsed.selectedSuiteId === 'string') state.selectedSuiteId = parsed.selectedSuiteId;
115
+ if (typeof parsed.statusFilter === 'string') state.testHistoryStatusFilter = parsed.statusFilter;
116
+ if (typeof parsed.versionFilter === 'string') state.testHistoryVersionFilter = parsed.versionFilter;
117
+ if (typeof parsed.query === 'string') state.testHistoryQuery = parsed.query;
118
+ } catch {
119
+ // ignore bad localStorage payload
120
+ }
121
+ }
122
+
123
+ function saveTestPreferences() {
124
+ try {
125
+ const payload = {
126
+ selectedSuiteId: state.selectedSuiteId || '',
127
+ statusFilter: state.testHistoryStatusFilter || 'all',
128
+ versionFilter: state.testHistoryVersionFilter || 'all',
129
+ query: state.testHistoryQuery || '',
130
+ };
131
+ localStorage.setItem(TEST_PREFS_STORAGE_KEY, JSON.stringify(payload));
132
+ } catch {
133
+ // ignore storage quota or privacy-mode errors
134
+ }
135
+ }
136
+
137
+ function formatDuration(ms) {
138
+ if (!ms || ms <= 0) return '-';
139
+ const sec = Math.round(ms / 1000);
140
+ if (sec < 60) return `${sec}s`;
141
+ const min = Math.floor(sec / 60);
142
+ const remain = sec % 60;
143
+ return `${min}m ${remain}s`;
144
+ }
145
+
146
+ function formatDateTime(isoStr) {
147
+ if (!isoStr) return '-';
148
+ const date = new Date(isoStr);
149
+ if (Number.isNaN(date.getTime())) return '-';
150
+ return date.toLocaleString();
151
+ }
152
+
153
+ function toMonospaceStatus(status) {
154
+ if (!status) return '';
155
+ const map = {
156
+ running: 'RUNNING',
157
+ passed: 'PASSED',
158
+ failed: 'FAILED',
159
+ timeout: 'TIMEOUT',
160
+ };
161
+ return map[status] || String(status).toUpperCase();
162
+ }
163
+
164
+ function syncTestPolling(running) {
165
+ if (running) {
166
+ if (!state.testPollTimer) {
167
+ state.testPollTimer = setInterval(loadTestsCard, 2000);
168
+ }
169
+ } else if (state.testPollTimer) {
170
+ clearInterval(state.testPollTimer);
171
+ state.testPollTimer = null;
172
+ }
173
+ }
174
+
175
+ function rerenderTestsCardFromState() {
176
+ if (state.testsSnapshot) {
177
+ renderTestsCard(state.testsSnapshot, null);
178
+ }
179
+ }
180
+
181
+ async function loadTestRunDetail(runId) {
182
+ if (!runId) return;
183
+ state.selectedRunId = runId;
184
+ state.selectedRunLoading = true;
185
+ state.selectedRunError = '';
186
+ rerenderTestsCardFromState();
187
+
188
+ try {
189
+ const detail = await fetchJSON(`/api/admin/tests/runs/${encodeURIComponent(runId)}`);
190
+ state.selectedRunDetail = detail;
191
+ state.selectedRunError = '';
192
+ } catch (err) {
193
+ state.selectedRunDetail = null;
194
+ state.selectedRunError = String(err?.message || err || 'unknown error');
195
+ } finally {
196
+ state.selectedRunLoading = false;
197
+ rerenderTestsCardFromState();
198
+ }
199
+ }
200
+
201
+ function renderTestsCard(data, err) {
202
+ const body = $('#card-tests-body');
203
+ if (!body) return;
204
+
205
+ if (err) {
206
+ body.innerHTML = emptyState('⚠', `测试状态加载失败: ${escapeHtml(String(err.message || err))}`);
207
+ syncTestPolling(false);
208
+ return;
209
+ }
210
+
211
+ state.testsSnapshot = data;
212
+
213
+ const manifest = data?.manifest || {};
214
+ const suites = Array.isArray(manifest.suites) ? manifest.suites : [];
215
+ const running = data?.running || null;
216
+ const latest = data?.latest || null;
217
+ const history = Array.isArray(data?.history) ? data.history : [];
218
+ const statusFilter = state.testHistoryStatusFilter || 'all';
219
+ const versionFilter = state.testHistoryVersionFilter || 'all';
220
+ const query = (state.testHistoryQuery || '').trim().toLowerCase();
221
+
222
+ const selectedId = state.selectedSuiteId
223
+ || running?.suiteId
224
+ || manifest.activeSuiteId
225
+ || suites[0]?.id
226
+ || '';
227
+ state.selectedSuiteId = selectedId;
228
+ saveTestPreferences();
229
+
230
+ const automation = data?.automation || null;
231
+ const autoConfig = automation?.config || {};
232
+ const autoRuntime = automation?.runtime || {};
233
+ const autoEnabled = !!autoConfig.enabled;
234
+ const autoSuiteId = autoConfig.suiteId || selectedId;
235
+ const autoInterval = Number(autoConfig.intervalMinutes) || 30;
236
+ const autoSchedulerState = autoRuntime.schedulerState || 'idle';
237
+ const autoSuiteOptions = suites.map((suite) => {
238
+ const selected = suite.id === autoSuiteId ? 'selected' : '';
239
+ const label = `${suite.name} (v${suite.version})`;
240
+ return `<option value="${escapeHtml(suite.id)}" ${selected}>${escapeHtml(label)}</option>`;
241
+ }).join('');
242
+ const autoStatusLabel = autoEnabled ? (autoSchedulerState === 'active' ? 'ACTIVE' : 'PAUSED') : 'OFF';
243
+ const autoStatusClass = autoEnabled
244
+ ? (autoSchedulerState === 'active' ? 'test-status-passed' : 'test-status-timeout')
245
+ : 'test-status-idle';
246
+ const autoNextRunText = autoRuntime.nextRunAt ? formatDateTime(autoRuntime.nextRunAt) : '未计划';
247
+ const autoLastAttemptText = autoRuntime.lastAttemptAt ? formatDateTime(autoRuntime.lastAttemptAt) : '暂无';
248
+ const autoLastRunText = autoRuntime.lastRunId || '暂无';
249
+ const autoLastErrorText = autoRuntime.lastError || '无';
250
+
251
+ const versionOptions = Array.from(new Set(history.map((item) => item.suiteVersion).filter(Boolean)));
252
+ const filteredHistory = history.filter((item) => {
253
+ if (statusFilter !== 'all' && item.status !== statusFilter) return false;
254
+ if (versionFilter !== 'all' && item.suiteVersion !== versionFilter) return false;
255
+ if (query) {
256
+ const searchable = `${item.runId} ${item.suiteId} ${item.suiteName} ${item.suiteVersion}`.toLowerCase();
257
+ if (!searchable.includes(query)) return false;
258
+ }
259
+ return true;
260
+ });
261
+
262
+ const completedRuns = filteredHistory.filter(item => item.status !== 'running');
263
+ const passedCount = completedRuns.filter(item => item.status === 'passed').length;
264
+ const failedCount = completedRuns.filter(item => item.status === 'failed').length;
265
+ const timeoutCount = completedRuns.filter(item => item.status === 'timeout').length;
266
+ const passRate = completedRuns.length > 0 ? `${Math.round((passedCount / completedRuns.length) * 100)}%` : '-';
267
+ const avgDurationMs = completedRuns.length > 0
268
+ ? Math.round(completedRuns.reduce((sum, item) => sum + (item.durationMs || 0), 0) / completedRuns.length)
269
+ : 0;
270
+ const latestFailed = history.find(item => item.status === 'failed' || item.status === 'timeout');
271
+
272
+ if (!state.selectedRunId && filteredHistory.length > 0) {
273
+ state.selectedRunId = filteredHistory[0].runId;
274
+ } else if (
275
+ state.selectedRunId
276
+ && filteredHistory.length > 0
277
+ && !filteredHistory.some(item => item.runId === state.selectedRunId)
278
+ ) {
279
+ state.selectedRunId = filteredHistory[0].runId;
280
+ state.selectedRunDetail = null;
281
+ state.selectedRunError = '';
282
+ } else if (filteredHistory.length === 0) {
283
+ state.selectedRunId = '';
284
+ state.selectedRunDetail = null;
285
+ state.selectedRunError = '';
286
+ }
287
+
288
+ const suiteOptions = suites.map((suite) => {
289
+ const selected = suite.id === selectedId ? 'selected' : '';
290
+ const label = `${suite.name} (v${suite.version})`;
291
+ return `<option value="${escapeHtml(suite.id)}" ${selected}>${escapeHtml(label)}</option>`;
292
+ }).join('');
293
+
294
+ const latestTitle = latest
295
+ ? `${latest.suiteName} v${latest.suiteVersion}`
296
+ : '暂无历史运行';
297
+ const latestStatus = latest
298
+ ? `<span class="test-status test-status-${latest.status}">${toMonospaceStatus(latest.status)}</span>`
299
+ : '<span class="test-status test-status-idle">IDLE</span>';
300
+ const latestMeta = latest
301
+ ? `${formatDuration(latest.durationMs)} · ${latest.endedAt ? new Date(latest.endedAt).toLocaleString() : '-'}`
302
+ : '-';
303
+
304
+ const runningLine = running
305
+ ? `<div class="test-running-line">运行中: ${escapeHtml(running.suiteName)} v${escapeHtml(running.suiteVersion)} · ${formatDuration(running.durationMs)}</div>`
306
+ : '';
307
+
308
+ const output = running?.outputTail || latest?.outputTail || '';
309
+ const outputBlock = output
310
+ ? `<pre class="test-log">${escapeHtml(output)}</pre>`
311
+ : '<div class="test-log-empty">暂无日志输出</div>';
312
+
313
+ const autoConfigPanel = `
314
+ <div class="test-auto-panel">
315
+ <div class="test-auto-header">
316
+ <div class="test-auto-title">自动回归策略</div>
317
+ <span class="test-status ${autoStatusClass}">${escapeHtml(autoStatusLabel)}</span>
318
+ </div>
319
+ <div class="test-auto-controls">
320
+ <label class="test-auto-toggle">
321
+ <input id="admin-test-auto-enabled" type="checkbox" ${autoEnabled ? 'checked' : ''}>
322
+ 启用自动回归
323
+ </label>
324
+ <label>
325
+ 套件
326
+ <select id="admin-test-auto-suite">
327
+ ${autoSuiteOptions}
328
+ </select>
329
+ </label>
330
+ <label>
331
+ 间隔(分钟)
332
+ <input id="admin-test-auto-interval" type="number" min="5" max="1440" step="1" value="${escapeHtml(String(autoInterval))}">
333
+ </label>
334
+ <button id="btn-save-auto-tests" class="btn-test-secondary" ${state.autoConfigSaving ? 'disabled' : ''}>
335
+ ${state.autoConfigSaving ? '保存中…' : '保存自动策略'}
336
+ </button>
337
+ <button id="btn-run-auto-now" class="btn-test-secondary" ${running ? 'disabled' : ''}>立即执行策略</button>
338
+ </div>
339
+ <div class="test-auto-meta">
340
+ <span>下次运行: <strong>${escapeHtml(autoNextRunText)}</strong></span>
341
+ <span>最近触发: <strong>${escapeHtml(autoLastAttemptText)}</strong></span>
342
+ <span>最近运行ID: <strong>${escapeHtml(autoLastRunText)}</strong></span>
343
+ </div>
344
+ <div class="test-auto-error ${autoRuntime.lastError ? 'has-error' : ''}">
345
+ 最近错误: ${escapeHtml(autoLastErrorText)}
346
+ </div>
347
+ </div>
348
+ `;
349
+
350
+ const historyHtml = filteredHistory.length === 0
351
+ ? '<div class="test-history-empty">当前筛选条件下无记录</div>'
352
+ : filteredHistory.map((item) => `
353
+ <div class="test-history-row ${item.runId === state.selectedRunId ? 'active' : ''}" data-run-id="${escapeHtml(item.runId)}">
354
+ <span class="test-history-suite">${escapeHtml(item.suiteName)} v${escapeHtml(item.suiteVersion)}</span>
355
+ <span class="test-status test-status-${item.status}">${toMonospaceStatus(item.status)}</span>
356
+ <span class="test-history-duration">${escapeHtml(formatDuration(item.durationMs))}</span>
357
+ <span class="test-history-time">${escapeHtml(item.endedAt ? new Date(item.endedAt).toLocaleString() : item.startedAt || '-')}</span>
358
+ </div>
359
+ `).join('');
360
+
361
+ const statusOptions = [
362
+ { value: 'all', label: '全部状态' },
363
+ { value: 'passed', label: 'PASSED' },
364
+ { value: 'failed', label: 'FAILED' },
365
+ { value: 'timeout', label: 'TIMEOUT' },
366
+ { value: 'running', label: 'RUNNING' },
367
+ ];
368
+ const statusOptionsHtml = statusOptions
369
+ .map(opt => `<option value="${opt.value}" ${statusFilter === opt.value ? 'selected' : ''}>${opt.label}</option>`)
370
+ .join('');
371
+ const versionOptionsHtml = [
372
+ `<option value="all" ${versionFilter === 'all' ? 'selected' : ''}>全部版本</option>`,
373
+ ...versionOptions.map(ver => `<option value="${escapeHtml(ver)}" ${versionFilter === ver ? 'selected' : ''}>v${escapeHtml(ver)}</option>`),
374
+ ].join('');
375
+
376
+ let detailBlock = '';
377
+ if (state.selectedRunId) {
378
+ if (state.selectedRunLoading) {
379
+ detailBlock = `
380
+ <div class="test-detail-panel">
381
+ <div class="test-detail-title">日志详情 #${escapeHtml(state.selectedRunId)}</div>
382
+ <div class="test-detail-loading">加载中…</div>
383
+ </div>`;
384
+ } else if (state.selectedRunError) {
385
+ detailBlock = `
386
+ <div class="test-detail-panel">
387
+ <div class="test-detail-title">日志详情 #${escapeHtml(state.selectedRunId)}</div>
388
+ <div class="test-detail-error">加载失败: ${escapeHtml(state.selectedRunError)}</div>
389
+ </div>`;
390
+ } else if (state.selectedRunDetail?.info?.runId === state.selectedRunId) {
391
+ const detail = state.selectedRunDetail;
392
+ const info = detail.info || {};
393
+ detailBlock = `
394
+ <div class="test-detail-panel">
395
+ <div class="test-detail-title">
396
+ ${escapeHtml(info.suiteName || '')} v${escapeHtml(info.suiteVersion || '')}
397
+ · #${escapeHtml(info.runId || '')}
398
+ · <span class="test-status test-status-${escapeHtml(info.status || 'failed')}">${toMonospaceStatus(info.status || 'failed')}</span>
399
+ </div>
400
+ <div class="test-detail-meta">
401
+ ${escapeHtml(formatDuration(info.durationMs))} · ${escapeHtml(info.endedAt ? new Date(info.endedAt).toLocaleString() : info.startedAt || '-')}
402
+ </div>
403
+ <div class="test-detail-actions">
404
+ <button class="btn-test-rerun-suite" data-suite-id="${escapeHtml(info.suiteId || '')}" ${running ? 'disabled' : ''}>重跑此套件</button>
405
+ <button class="btn-test-log-download" data-run-id="${escapeHtml(info.runId || '')}">下载日志</button>
406
+ <button class="btn-test-json-download" data-run-id="${escapeHtml(info.runId || '')}">导出 JSON</button>
407
+ </div>
408
+ <pre class="test-detail-log">${escapeHtml(detail.output || '')}</pre>
409
+ </div>`;
410
+ }
411
+ }
412
+
413
+ body.innerHTML = `
414
+ <div class="test-toolbar">
415
+ <div class="test-toolbar-left">
416
+ <label for="admin-test-suite-select">测试套件</label>
417
+ <select id="admin-test-suite-select" ${running ? 'disabled' : ''}>
418
+ ${suiteOptions}
419
+ </select>
420
+ <span class="test-schema">schema v${escapeHtml(String(manifest.schemaVersion || 1))}</span>
421
+ </div>
422
+ <button id="btn-run-tests" class="btn-run-tests" ${running ? 'disabled' : ''}>
423
+ ${running ? '运行中…' : '一键启动测试'}
424
+ </button>
425
+ </div>
426
+ ${runningLine}
427
+ <div class="test-latest-row">
428
+ <div class="test-latest-title">${escapeHtml(latestTitle)}</div>
429
+ <div class="test-latest-status">${latestStatus}</div>
430
+ <div class="test-latest-meta">${escapeHtml(latestMeta)}</div>
431
+ </div>
432
+ ${outputBlock}
433
+ ${autoConfigPanel}
434
+ <div class="test-history-title">最近运行记录</div>
435
+ <div class="test-history-summary">
436
+ <span class="test-summary-chip">通过率 <strong>${escapeHtml(passRate)}</strong></span>
437
+ <span class="test-summary-chip">失败 <strong>${failedCount}</strong></span>
438
+ <span class="test-summary-chip">超时 <strong>${timeoutCount}</strong></span>
439
+ <span class="test-summary-chip">平均耗时 <strong>${escapeHtml(formatDuration(avgDurationMs))}</strong></span>
440
+ <span class="test-summary-chip">最近失败 <strong>${escapeHtml(latestFailed ? relativeTime(latestFailed.endedAt || latestFailed.startedAt) : '无')}</strong></span>
441
+ </div>
442
+ <div class="test-history-filters">
443
+ <label>
444
+ 状态
445
+ <select id="admin-test-filter-status">${statusOptionsHtml}</select>
446
+ </label>
447
+ <label>
448
+ 版本
449
+ <select id="admin-test-filter-version">${versionOptionsHtml}</select>
450
+ </label>
451
+ <label class="test-query-label">
452
+ 搜索
453
+ <input
454
+ id="admin-test-filter-query"
455
+ type="text"
456
+ value="${escapeHtml(state.testHistoryQuery || '')}"
457
+ placeholder="suite / runId / version"
458
+ />
459
+ </label>
460
+ <button id="admin-test-filter-reset" class="btn-test-filter-reset">重置筛选</button>
461
+ <span class="test-history-count">显示 ${filteredHistory.length} / ${history.length}</span>
462
+ </div>
463
+ <div class="test-history-list">${historyHtml}</div>
464
+ ${detailBlock}
465
+ `;
466
+
467
+ if (
468
+ state.selectedRunId
469
+ && !state.selectedRunLoading
470
+ && !state.selectedRunError
471
+ && state.selectedRunDetail?.info?.runId !== state.selectedRunId
472
+ ) {
473
+ queueMicrotask(() => {
474
+ loadTestRunDetail(state.selectedRunId);
475
+ });
476
+ }
477
+
478
+ syncTestPolling(!!running);
479
+ }
480
+
481
+ async function loadTestsCard() {
482
+ try {
483
+ const data = await fetchJSON('/api/admin/tests');
484
+ renderTestsCard(data, null);
485
+ } catch (err) {
486
+ renderTestsCard(null, err);
487
+ }
488
+ }
489
+
490
+ async function runTestsFromMirror(forcedSuiteId) {
491
+ const btn = $('#btn-run-tests');
492
+ if (btn) {
493
+ btn.disabled = true;
494
+ btn.textContent = '运行中…';
495
+ }
496
+
497
+ try {
498
+ const suiteId = forcedSuiteId || $('#admin-test-suite-select')?.value || undefined;
499
+ if (suiteId) state.selectedSuiteId = suiteId;
500
+ saveTestPreferences();
501
+ const data = await postJSON('/api/admin/tests/run', { suiteId });
502
+ renderTestsCard(data, null);
503
+ if (data?.running?.runId) {
504
+ await loadTestRunDetail(data.running.runId);
505
+ }
506
+ } catch (err) {
507
+ const body = $('#card-tests-body');
508
+ if (body) {
509
+ body.insertAdjacentHTML(
510
+ 'afterbegin',
511
+ `<div class="test-inline-error">启动失败: ${escapeHtml(String(err.message || err))}</div>`
512
+ );
513
+ }
514
+ await loadTestsCard();
515
+ }
516
+ }
517
+
518
+ async function saveAutoRegressionConfig() {
519
+ if (state.autoConfigSaving) return;
520
+
521
+ const enabledInput = $('#admin-test-auto-enabled');
522
+ const suiteInput = $('#admin-test-auto-suite');
523
+ const intervalInput = $('#admin-test-auto-interval');
524
+
525
+ const enabled = !!enabledInput?.checked;
526
+ const suiteId = suiteInput?.value || state.selectedSuiteId || '';
527
+ const intervalMinutes = Number(intervalInput?.value || 0);
528
+
529
+ if (!suiteId) {
530
+ throw new Error('请选择自动回归套件');
531
+ }
532
+ if (!Number.isFinite(intervalMinutes) || intervalMinutes < 5 || intervalMinutes > 1440) {
533
+ throw new Error('自动回归间隔需在 5-1440 分钟');
534
+ }
535
+
536
+ state.autoConfigSaving = true;
537
+ const saveBtn = $('#btn-save-auto-tests');
538
+ if (saveBtn) {
539
+ saveBtn.disabled = true;
540
+ saveBtn.textContent = '保存中…';
541
+ }
542
+
543
+ try {
544
+ const snapshot = await postJSON('/api/admin/tests/auto', {
545
+ enabled,
546
+ suiteId,
547
+ intervalMinutes: Math.floor(intervalMinutes),
548
+ });
549
+ renderTestsCard(snapshot, null);
550
+ } finally {
551
+ state.autoConfigSaving = false;
552
+ const refreshedBtn = $('#btn-save-auto-tests');
553
+ if (refreshedBtn) {
554
+ refreshedBtn.disabled = false;
555
+ refreshedBtn.textContent = '保存自动策略';
556
+ }
557
+ }
558
+ }
559
+
560
+ // ===== Tab Switching =====
561
+
562
+ function switchTab(tab) {
563
+ state.activeTab = tab;
564
+ $$('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
565
+ $$('.tab-content').forEach(c => c.classList.toggle('active', c.id === `tab-${tab}`));
566
+
567
+ // Load data on first visit
568
+ const loaders = {
569
+ overview: loadOverview,
570
+ backlog: loadBacklog,
571
+ quality: loadQuality,
572
+ diagnose: loadDiagnose,
573
+ verify: loadVerify,
574
+ roadmap: loadRoadmap,
575
+ telemetry: loadTelemetry,
576
+ };
577
+ if (loaders[tab]) loaders[tab]();
578
+
579
+ // Auto-refresh only on overview
580
+ clearInterval(state.refreshTimer);
581
+ if (tab === 'overview') {
582
+ state.refreshTimer = setInterval(loadOverview, 15000);
583
+ }
584
+ }
585
+
586
+ // ===== Overview Tab =====
587
+
588
+ async function loadOverview() {
589
+ try {
590
+ const [statusRes, qualityRes, testsRes] = await Promise.allSettled([
591
+ fetchJSON('/api/admin/status'),
592
+ fetchJSON('/api/admin/quality'),
593
+ fetchJSON('/api/admin/tests'),
594
+ ]);
595
+
596
+ const status = statusRes.status === 'fulfilled' ? statusRes.value : null;
597
+ const quality = qualityRes.status === 'fulfilled' ? qualityRes.value : null;
598
+ const tests = testsRes.status === 'fulfilled' ? testsRes.value : null;
599
+
600
+ // Health Score
601
+ const healthBody = $('#card-health-body');
602
+ if (quality) {
603
+ const score = quality.score;
604
+ const cls = score >= 80 ? 'score-high' : score >= 50 ? 'score-mid' : 'score-low';
605
+ const checksHtml = quality.checks.map(c =>
606
+ `<div class="health-check"><span class="dot ${c.ok ? 'ok' : 'fail'}"></span>${escapeHtml(c.name)}: ${escapeHtml(c.detail)}</div>`
607
+ ).join('');
608
+ healthBody.innerHTML = `
609
+ <div class="health-row">
610
+ <div class="health-score ${cls}">${score}</div>
611
+ <div class="health-checks">${checksHtml}</div>
612
+ </div>`;
613
+ } else {
614
+ healthBody.innerHTML = emptyState('\u2764', '质量数据加载失败');
615
+ }
616
+
617
+ // Backlog Stats
618
+ const blBody = $('#card-backlog-stats-body');
619
+ if (status && status.backlog) {
620
+ const bl = status.backlog;
621
+ const bs = bl.byStatus || {};
622
+ blBody.innerHTML = `
623
+ <div class="stat-grid">
624
+ <div class="stat-item"><div class="stat-value">${bl.total}</div><div class="stat-label">总计</div></div>
625
+ <div class="stat-item"><div class="stat-value">${bs.idea || 0}</div><div class="stat-label">Idea</div></div>
626
+ <div class="stat-item"><div class="stat-value">${bs.planned || 0}</div><div class="stat-label">Planned</div></div>
627
+ <div class="stat-item"><div class="stat-value">${bs.developing || 0}</div><div class="stat-label">Dev</div></div>
628
+ <div class="stat-item"><div class="stat-value">${bs.done || 0}</div><div class="stat-label">Done</div></div>
629
+ </div>`;
630
+ } else {
631
+ blBody.innerHTML = emptyState('\uD83D\uDCCB', '暂无需求');
632
+ }
633
+
634
+ // Wuxing Distribution
635
+ const wxBody = $('#card-wuxing-body');
636
+ if (status && status.backlog && status.backlog.byWuxing) {
637
+ const bw = status.backlog.byWuxing;
638
+ const total = Object.values(bw).reduce((s, v) => s + v, 0) || 1;
639
+ const order = ['木', '火', '水', '金', '土'];
640
+ wxBody.innerHTML = `<div class="wuxing-bars">${order.map(w => {
641
+ const count = bw[w] || 0;
642
+ const pct = Math.round(count / total * 100);
643
+ const info = WUXING_MAP[w];
644
+ return `<div class="wuxing-row">
645
+ <span class="wuxing-icon">${info.icon}</span>
646
+ <span class="wuxing-name">${w}</span>
647
+ <div class="wuxing-bar-track"><div class="wuxing-bar-fill ${info.cls}" style="width:${pct}%"></div></div>
648
+ <span class="wuxing-count">${count}</span>
649
+ </div>`;
650
+ }).join('')}</div>`;
651
+ } else {
652
+ wxBody.innerHTML = emptyState('\u262F', '暂无数据');
653
+ }
654
+
655
+ // Developing
656
+ const devBody = $('#card-developing-body');
657
+ if (status && status.developing && status.developing.length > 0) {
658
+ devBody.innerHTML = status.developing.map(d =>
659
+ `<div class="dev-item">
660
+ <span class="dev-id">#${d.id}</span>
661
+ ${badgeWuxing(d.wuxing)}
662
+ ${badgePriority(d.priority)}
663
+ <span class="dev-title">${escapeHtml(d.title)}</span>
664
+ </div>`
665
+ ).join('');
666
+ } else {
667
+ devBody.innerHTML = emptyState('\uD83D\uDEE0', '无进行中任务');
668
+ }
669
+
670
+ // Milestones
671
+ const msBody = $('#card-milestones-body');
672
+ if (status && status.milestones && status.milestones.length > 0) {
673
+ msBody.innerHTML = status.milestones.map(m =>
674
+ `<div class="milestone-mini">
675
+ <span class="badge-status badge-status-${m.status === 'active' ? 'developing' : m.status === 'done' ? 'done' : 'idea'}">${m.status}</span>
676
+ <span class="milestone-title">${escapeHtml(m.name)}</span>
677
+ <span class="milestone-percent" style="font-family:var(--font-mono);color:var(--text-muted)">${escapeHtml(m.version)}</span>
678
+ </div>`
679
+ ).join('');
680
+ } else {
681
+ msBody.innerHTML = emptyState('\uD83C\uDFAF', '暂无里程碑');
682
+ }
683
+
684
+ // Telemetry Summary
685
+ const tlBody = $('#card-telemetry-summary-body');
686
+ if (status && status.telemetry) {
687
+ const tl = status.telemetry;
688
+ tlBody.innerHTML = `
689
+ <div class="stat-grid">
690
+ <div class="stat-item"><div class="stat-value">${tl.totalEvents}</div><div class="stat-label">事件数</div></div>
691
+ <div class="stat-item"><div class="stat-value">${tl.avgSessionRounds ?? '-'}</div><div class="stat-label">平均轮次</div></div>
692
+ <div class="stat-item"><div class="stat-value">${tl.topErrors?.length || 0}</div><div class="stat-label">错误类型</div></div>
693
+ </div>`;
694
+ } else {
695
+ tlBody.innerHTML = emptyState('\uD83D\uDCE1', '暂无遥测数据');
696
+ }
697
+
698
+ if (testsRes.status === 'fulfilled') {
699
+ renderTestsCard(tests, null);
700
+ } else {
701
+ renderTestsCard(null, testsRes.reason);
702
+ }
703
+
704
+ $('#last-updated').textContent = new Date().toLocaleTimeString();
705
+ } catch (err) {
706
+ console.error('[Overview]', err);
707
+ }
708
+ }
709
+
710
+ // ===== Backlog Tab =====
711
+
712
+ async function loadBacklog() {
713
+ const list = $('#backlog-list');
714
+ try {
715
+ const params = new URLSearchParams();
716
+ const status = $('#filter-status').value;
717
+ const priority = $('#filter-priority').value;
718
+ const wuxing = $('#filter-wuxing').value;
719
+ const type = $('#filter-type').value;
720
+ if (status) params.set('status', status);
721
+ if (priority) params.set('priority', priority);
722
+ if (wuxing) params.set('wuxing', wuxing);
723
+ if (type) params.set('type', type);
724
+
725
+ const data = await fetchJSON(`/api/admin/backlog?${params}`);
726
+ if (!data.items || data.items.length === 0) {
727
+ list.innerHTML = emptyState('\uD83D\uDCCB', '暂无符合条件的需求');
728
+ return;
729
+ }
730
+
731
+ list.innerHTML = data.items.map(item => `
732
+ <div class="list-item">
733
+ <span class="item-id">#${item.id}</span>
734
+ <div class="item-body">
735
+ <div class="item-title">${escapeHtml(item.title)}</div>
736
+ <div class="item-meta">
737
+ ${badgePriority(item.priority)}
738
+ ${badgeStatus(item.status)}
739
+ ${badgeWuxing(item.wuxing)}
740
+ ${badgeType(item.type)}
741
+ ${item.targetVersion ? `<span class="badge badge-type">${escapeHtml(item.targetVersion)}</span>` : ''}
742
+ <span style="color:var(--text-muted);font-size:10px">${relativeTime(item.updatedAt)}</span>
743
+ </div>
744
+ ${item.description ? `<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${escapeHtml(item.description.slice(0, 120))}</div>` : ''}
745
+ </div>
746
+ </div>
747
+ `).join('');
748
+ } catch (err) {
749
+ list.innerHTML = emptyState('\u26A0', `加载失败: ${escapeHtml(String(err))}`);
750
+ }
751
+ }
752
+
753
+ // ===== Quality Tab =====
754
+
755
+ async function loadQuality() {
756
+ const container = $('#quality-report');
757
+ try {
758
+ const report = await fetchJSON('/api/admin/quality');
759
+ const score = report.score;
760
+ const cls = score >= 80 ? 'score-high' : score >= 50 ? 'score-mid' : 'score-low';
761
+
762
+ container.innerHTML = `
763
+ <div class="quality-score-header">
764
+ <div class="health-score ${cls}">${score}</div>
765
+ <div>
766
+ <div style="font-size:14px;font-weight:600">质量评分</div>
767
+ <div style="font-size:12px;color:var(--text-secondary)">${report.timestamp ? new Date(report.timestamp).toLocaleString() : ''}</div>
768
+ </div>
769
+ </div>
770
+ <div class="quality-checks-grid">
771
+ ${report.checks.map(c => `
772
+ <div class="quality-check-card ${c.ok ? 'check-ok' : 'check-fail'}">
773
+ <div class="quality-check-name">${c.ok ? '\u2705' : '\u274C'} ${escapeHtml(c.name)}</div>
774
+ <div class="quality-check-detail">${escapeHtml(c.detail)}</div>
775
+ ${c.value !== undefined ? `<div class="quality-check-value">${c.value}${c.unit ? ' ' + c.unit : ''}</div>` : ''}
776
+ </div>
777
+ `).join('')}
778
+ </div>`;
779
+ } catch (err) {
780
+ container.innerHTML = emptyState('\u26A0', `质量检查失败: ${escapeHtml(String(err))}`);
781
+ }
782
+ }
783
+
784
+ // ===== Diagnose Tab =====
785
+
786
+ async function loadDiagnose() {
787
+ const list = $('#diagnose-list');
788
+ try {
789
+ const days = $('#diagnose-days').value;
790
+ const data = await fetchJSON(`/api/admin/diagnose?days=${days}`);
791
+ if (!data.findings || data.findings.length === 0) {
792
+ list.innerHTML = emptyState('\u2705', '未发现问题,系统健康');
793
+ return;
794
+ }
795
+
796
+ list.innerHTML = data.findings.map(f => `
797
+ <div class="list-item">
798
+ <div class="item-body">
799
+ <div class="item-title">${escapeHtml(f.title)}</div>
800
+ <div class="item-meta">
801
+ ${badgeSeverity(f.severity)}
802
+ ${badgeWuxing(f.wuxing || '木')}
803
+ <span class="badge badge-type">${escapeHtml(f.rule || f.source || '')}</span>
804
+ </div>
805
+ <div style="font-size:11px;color:var(--text-muted);margin-top:4px">${escapeHtml(f.description || f.evidence || '')}</div>
806
+ ${f.suggestion ? `<div style="font-size:11px;color:var(--accent);margin-top:2px">\uD83D\uDCA1 ${escapeHtml(f.suggestion)}</div>` : ''}
807
+ </div>
808
+ </div>
809
+ `).join('');
810
+ } catch (err) {
811
+ list.innerHTML = emptyState('\u26A0', `诊断失败: ${escapeHtml(String(err))}`);
812
+ }
813
+ }
814
+
815
+ // ===== Verify Tab =====
816
+
817
+ async function loadVerify() {
818
+ const summaryBar = $('#verify-summary');
819
+ const list = $('#verify-list');
820
+ try {
821
+ const report = await fetchJSON('/api/admin/verify');
822
+ const s = report.summary || {};
823
+
824
+ summaryBar.innerHTML = `
825
+ <div class="verify-stat"><strong>${s.total || 0}</strong> 待验证</div>
826
+ <div class="verify-stat" style="color:var(--green)"><strong>${s.effective || 0}</strong> 有效</div>
827
+ <div class="verify-stat" style="color:var(--red)"><strong>${s.ineffective || 0}</strong> 无效</div>
828
+ <div class="verify-stat" style="color:var(--yellow)"><strong>${s.pending || 0}</strong> 待观察</div>
829
+ <div class="verify-stat" style="color:var(--text-muted)"><strong>${s.noData || 0}</strong> 无数据</div>
830
+ `;
831
+
832
+ if (!report.results || report.results.length === 0) {
833
+ list.innerHTML = emptyState('\uD83D\uDD0D', '暂无待验证的需求');
834
+ return;
835
+ }
836
+
837
+ list.innerHTML = report.results.map(r => `
838
+ <div class="list-item">
839
+ <span class="item-id">#${r.itemId}</span>
840
+ <div class="item-body">
841
+ <div class="item-title">${escapeHtml(r.title)}</div>
842
+ <div class="item-meta">
843
+ ${badgeVerdict(r.verdict)}
844
+ <span class="badge badge-type">${escapeHtml(r.baselineKey)}</span>
845
+ ${r.changePercent !== null ? `<span style="font-family:var(--font-mono);font-size:11px;color:${r.changePercent <= 0 ? 'var(--green)' : 'var(--red)'}">${r.changePercent > 0 ? '+' : ''}${r.changePercent}%</span>` : ''}
846
+ </div>
847
+ <div style="font-size:11px;color:var(--text-muted);margin-top:4px">${escapeHtml(r.detail)}</div>
848
+ </div>
849
+ </div>
850
+ `).join('');
851
+ } catch (err) {
852
+ list.innerHTML = emptyState('\u26A0', `验证失败: ${escapeHtml(String(err))}`);
853
+ }
854
+ }
855
+
856
+ // ===== Roadmap Tab =====
857
+
858
+ async function loadRoadmap() {
859
+ const container = $('#roadmap-content');
860
+ try {
861
+ const roadmap = await fetchJSON('/api/admin/roadmap');
862
+ if (!roadmap.versions || roadmap.versions.length === 0) {
863
+ container.innerHTML = emptyState('\uD83D\uDDFA', '暂无路线图数据');
864
+ return;
865
+ }
866
+
867
+ container.innerHTML = roadmap.versions.map(ver => {
868
+ const total = ver.stats ? ver.stats.total : (ver.items ? ver.items.length : 0);
869
+ const done = ver.stats ? ver.stats.done : (ver.items ? ver.items.filter(i => i.status === 'done').length : 0);
870
+ const pct = ver.stats ? ver.stats.progress : (total > 0 ? Math.round(done / total * 100) : 0);
871
+
872
+ return `
873
+ <div class="roadmap-version">
874
+ <div class="roadmap-version-header">
875
+ <span class="roadmap-version-tag">${escapeHtml(ver.version || 'Unversioned')}</span>
876
+ <div class="roadmap-progress">
877
+ <div class="roadmap-progress-bar"><div class="roadmap-progress-fill" style="width:${pct}%"></div></div>
878
+ <span>${done}/${total} (${pct}%)</span>
879
+ </div>
880
+ </div>
881
+ <div class="roadmap-items">
882
+ ${(ver.items || []).map(item => `
883
+ <div class="roadmap-item">
884
+ <span class="roadmap-item-dot ${item.status}"></span>
885
+ ${badgePriority(item.priority)}
886
+ ${badgeWuxing(item.wuxing)}
887
+ <span style="flex:1">${escapeHtml(item.title)}</span>
888
+ ${badgeStatus(item.status)}
889
+ </div>
890
+ `).join('')}
891
+ </div>
892
+ </div>`;
893
+ }).join('');
894
+ } catch (err) {
895
+ container.innerHTML = emptyState('\u26A0', `路线图加载失败: ${escapeHtml(String(err))}`);
896
+ }
897
+ }
898
+
899
+ // ===== Telemetry Tab =====
900
+
901
+ async function loadTelemetry() {
902
+ const container = $('#telemetry-content');
903
+ try {
904
+ const days = $('#telemetry-days').value;
905
+ const summary = await fetchJSON(`/api/admin/telemetry?days=${days}`);
906
+
907
+ const cmdEntries = Object.entries(summary.cmdCounts || {});
908
+ const errors = summary.errors || [];
909
+
910
+ container.innerHTML = `
911
+ <div class="telemetry-grid">
912
+ <!-- 概览统计 -->
913
+ <div class="telemetry-card">
914
+ <div class="telemetry-card-title">概览 (${days}d)</div>
915
+ <div class="stat-grid">
916
+ <div class="stat-item"><div class="stat-value">${summary.totalEvents || 0}</div><div class="stat-label">总事件</div></div>
917
+ <div class="stat-item"><div class="stat-value">${summary.totalSessions || 0}</div><div class="stat-label">会话数</div></div>
918
+ <div class="stat-item"><div class="stat-value">${summary.avgSessionRounds ?? '-'}</div><div class="stat-label">平均轮次</div></div>
919
+ </div>
920
+ </div>
921
+
922
+ <!-- 命令统计 -->
923
+ <div class="telemetry-card">
924
+ <div class="telemetry-card-title">命令调用</div>
925
+ ${cmdEntries.length === 0 ? '<div style="color:var(--text-muted);font-size:12px">暂无命令调用记录</div>' :
926
+ cmdEntries.sort((a, b) => b[1].total - a[1].total).map(([cmd, stats]) => `
927
+ <div class="cmd-row">
928
+ <span class="cmd-name">${escapeHtml(cmd)}</span>
929
+ <span class="cmd-stats">${stats.total} 次${stats.fail > 0 ? ` <span class="cmd-fail">(${stats.fail} fail)</span>` : ''}</span>
930
+ </div>
931
+ `).join('')}
932
+ </div>
933
+
934
+ <!-- 错误列表 -->
935
+ <div class="telemetry-card">
936
+ <div class="telemetry-card-title">错误记录</div>
937
+ ${errors.length === 0 ? '<div style="color:var(--text-muted);font-size:12px">无错误记录</div>' :
938
+ errors.slice(0, 10).map(e => `
939
+ <div class="error-row">
940
+ <div class="error-msg">${escapeHtml(e.message || e.error || JSON.stringify(e))}</div>
941
+ <div class="error-count">${e.count || 1}x</div>
942
+ </div>
943
+ `).join('')}
944
+ </div>
945
+ </div>`;
946
+ } catch (err) {
947
+ container.innerHTML = emptyState('\u26A0', `遥测加载失败: ${escapeHtml(String(err))}`);
948
+ }
949
+ }
950
+
951
+ // ===== Init =====
952
+
953
+ document.addEventListener('DOMContentLoaded', () => {
954
+ // Tab switching
955
+ $$('.tab-btn').forEach(btn => {
956
+ btn.addEventListener('click', () => switchTab(btn.dataset.tab));
957
+ });
958
+
959
+ // Refresh button
960
+ $('#btn-refresh').addEventListener('click', () => {
961
+ const btn = $('#btn-refresh');
962
+ btn.classList.add('spinning');
963
+ const loaders = {
964
+ overview: loadOverview,
965
+ backlog: loadBacklog,
966
+ quality: loadQuality,
967
+ diagnose: loadDiagnose,
968
+ verify: loadVerify,
969
+ roadmap: loadRoadmap,
970
+ telemetry: loadTelemetry,
971
+ };
972
+ const loader = loaders[state.activeTab];
973
+ if (loader) {
974
+ loader().finally(() => btn.classList.remove('spinning'));
975
+ } else {
976
+ btn.classList.remove('spinning');
977
+ }
978
+ });
979
+
980
+ // Mirror tests: run button + suite selector
981
+ document.addEventListener('click', (e) => {
982
+ const target = e.target;
983
+ if (!(target instanceof HTMLElement)) return;
984
+ if (target.id === 'btn-run-tests') {
985
+ runTestsFromMirror();
986
+ return;
987
+ }
988
+ if (target.id === 'btn-save-auto-tests') {
989
+ saveAutoRegressionConfig().catch(async (err) => {
990
+ const body = $('#card-tests-body');
991
+ if (body) {
992
+ body.insertAdjacentHTML(
993
+ 'afterbegin',
994
+ `<div class="test-inline-error">自动策略保存失败: ${escapeHtml(String(err.message || err))}</div>`
995
+ );
996
+ }
997
+ await loadTestsCard();
998
+ });
999
+ return;
1000
+ }
1001
+ if (target.id === 'btn-run-auto-now') {
1002
+ const suiteId = $('#admin-test-auto-suite')?.value;
1003
+ if (suiteId) {
1004
+ state.selectedSuiteId = suiteId;
1005
+ saveTestPreferences();
1006
+ }
1007
+ runTestsFromMirror(suiteId || undefined);
1008
+ return;
1009
+ }
1010
+ if (target.id === 'admin-test-filter-reset') {
1011
+ state.testHistoryStatusFilter = 'all';
1012
+ state.testHistoryVersionFilter = 'all';
1013
+ state.testHistoryQuery = '';
1014
+ state.selectedRunDetail = null;
1015
+ state.selectedRunError = '';
1016
+ saveTestPreferences();
1017
+ rerenderTestsCardFromState();
1018
+ return;
1019
+ }
1020
+ if (target.classList.contains('btn-test-rerun-suite')) {
1021
+ const suiteId = target.dataset.suiteId;
1022
+ if (suiteId) {
1023
+ state.selectedSuiteId = suiteId;
1024
+ saveTestPreferences();
1025
+ runTestsFromMirror(suiteId);
1026
+ }
1027
+ return;
1028
+ }
1029
+ if (target.classList.contains('btn-test-log-download')) {
1030
+ const runId = target.dataset.runId;
1031
+ if (runId) {
1032
+ const url = `/api/admin/tests/runs/${encodeURIComponent(runId)}/download`;
1033
+ window.open(url, '_blank');
1034
+ }
1035
+ return;
1036
+ }
1037
+ if (target.classList.contains('btn-test-json-download')) {
1038
+ const runId = target.dataset.runId;
1039
+ if (runId) {
1040
+ const url = `/api/admin/tests/runs/${encodeURIComponent(runId)}/export`;
1041
+ window.open(url, '_blank');
1042
+ }
1043
+ return;
1044
+ }
1045
+ const row = target.closest('.test-history-row');
1046
+ if (row && row instanceof HTMLElement) {
1047
+ const runId = row.dataset.runId;
1048
+ if (runId) {
1049
+ loadTestRunDetail(runId);
1050
+ }
1051
+ }
1052
+ });
1053
+
1054
+ document.addEventListener('change', (e) => {
1055
+ const target = e.target;
1056
+ if (!(target instanceof HTMLSelectElement)) return;
1057
+ if (target.id === 'admin-test-suite-select') {
1058
+ state.selectedSuiteId = target.value;
1059
+ saveTestPreferences();
1060
+ return;
1061
+ }
1062
+ if (target.id === 'admin-test-filter-status') {
1063
+ state.testHistoryStatusFilter = target.value;
1064
+ state.selectedRunDetail = null;
1065
+ state.selectedRunError = '';
1066
+ saveTestPreferences();
1067
+ rerenderTestsCardFromState();
1068
+ return;
1069
+ }
1070
+ if (target.id === 'admin-test-filter-version') {
1071
+ state.testHistoryVersionFilter = target.value;
1072
+ state.selectedRunDetail = null;
1073
+ state.selectedRunError = '';
1074
+ saveTestPreferences();
1075
+ rerenderTestsCardFromState();
1076
+ }
1077
+ });
1078
+
1079
+ document.addEventListener('input', (e) => {
1080
+ const target = e.target;
1081
+ if (!(target instanceof HTMLInputElement)) return;
1082
+ if (target.id === 'admin-test-filter-query') {
1083
+ const caret = target.selectionStart ?? target.value.length;
1084
+ state.testHistoryQuery = target.value || '';
1085
+ state.selectedRunDetail = null;
1086
+ state.selectedRunError = '';
1087
+ saveTestPreferences();
1088
+ rerenderTestsCardFromState();
1089
+ queueMicrotask(() => {
1090
+ const input = $('#admin-test-filter-query');
1091
+ if (input instanceof HTMLInputElement) {
1092
+ input.focus();
1093
+ const pos = Math.min(caret, input.value.length);
1094
+ input.setSelectionRange(pos, pos);
1095
+ }
1096
+ });
1097
+ }
1098
+ });
1099
+
1100
+ // Filter change handlers
1101
+ ['filter-status', 'filter-priority', 'filter-wuxing', 'filter-type'].forEach(id => {
1102
+ const el = $(`#${id}`);
1103
+ if (el) el.addEventListener('change', loadBacklog);
1104
+ });
1105
+
1106
+ // Diagnose days change
1107
+ const diagDays = $('#diagnose-days');
1108
+ if (diagDays) diagDays.addEventListener('change', loadDiagnose);
1109
+
1110
+ // Telemetry days change
1111
+ const telDays = $('#telemetry-days');
1112
+ if (telDays) telDays.addEventListener('change', loadTelemetry);
1113
+
1114
+ // Keyboard shortcut: R to refresh
1115
+ document.addEventListener('keydown', (e) => {
1116
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA') return;
1117
+ if (e.key === 'r' || e.key === 'R') {
1118
+ $('#btn-refresh').click();
1119
+ }
1120
+ });
1121
+
1122
+ // Initial load
1123
+ loadTestPreferences();
1124
+ loadOverview();
1125
+ });