dsh-hooks 0.4.0 → 0.6.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/lib/client.js CHANGED
@@ -52,6 +52,108 @@ window.__ModuleLoader__.load({
52
52
  return null;
53
53
  }
54
54
  }
55
+ /** POST a JSON action and surface the envelope result (error message included). */
56
+ async function postFeishu(path, body, fetchFn) {
57
+ try {
58
+ const response = await fetchFn(path, {
59
+ method: "POST",
60
+ headers: {
61
+ "content-type": "application/json",
62
+ accept: "application/json"
63
+ },
64
+ body: JSON.stringify(body)
65
+ });
66
+ const envelope = await response.json();
67
+ if (!response.ok || !envelope.ok) return {
68
+ ok: false,
69
+ error: envelope.error?.message ?? `HTTP ${response.status}`
70
+ };
71
+ const value = envelope.value ?? {};
72
+ return {
73
+ ok: true,
74
+ setup: value.setup,
75
+ message: typeof value.message === "string" ? value.message : void 0,
76
+ resultMaxChars: typeof value.resultMaxChars === "number" ? value.resultMaxChars : void 0
77
+ };
78
+ } catch (error) {
79
+ console.warn(`[dsh-hooks-ui] POST ${path} failed: ${error instanceof Error ? error.message : String(error)}`);
80
+ return {
81
+ ok: false,
82
+ error: "网络请求失败"
83
+ };
84
+ }
85
+ }
86
+ async function fetchFeishuStatus(fetchFn = fetch) {
87
+ return getJson("/dsh-hooks/feishu/status", fetchFn);
88
+ }
89
+ async function postFeishuSetup(profile, resultMaxChars, fetchFn = fetch) {
90
+ const body = { profile };
91
+ if (resultMaxChars !== void 0) body.resultMaxChars = resultMaxChars;
92
+ return postFeishu("/dsh-hooks/feishu/setup", body, fetchFn);
93
+ }
94
+ async function postFeishuConfig(resultMaxChars, fetchFn = fetch) {
95
+ return postFeishu("/dsh-hooks/feishu/config", { resultMaxChars }, fetchFn);
96
+ }
97
+ async function postFeishuCancel(fetchFn = fetch) {
98
+ return postFeishu("/dsh-hooks/feishu/cancel", {}, fetchFn);
99
+ }
100
+ async function postFeishuTest(fetchFn = fetch) {
101
+ return postFeishu("/dsh-hooks/feishu/test", {}, fetchFn);
102
+ }
103
+ /** POST a JSON action; returns the envelope value plus its error message. */
104
+ async function postValue(path, body, fetchFn) {
105
+ try {
106
+ const response = await fetchFn(path, {
107
+ method: "POST",
108
+ headers: {
109
+ "content-type": "application/json",
110
+ accept: "application/json"
111
+ },
112
+ body: JSON.stringify(body)
113
+ });
114
+ const envelope = await response.json();
115
+ if (!response.ok || !envelope.ok) return {
116
+ ok: false,
117
+ error: envelope.error?.message ?? `HTTP ${response.status}`
118
+ };
119
+ const value = envelope.value ?? {};
120
+ return {
121
+ ok: true,
122
+ message: typeof value.message === "string" ? value.message : void 0,
123
+ preview: typeof value.preview === "string" ? value.preview : void 0,
124
+ hookCount: typeof value.hookCount === "number" ? value.hookCount : void 0,
125
+ patchFile: typeof value.patchFile === "string" ? value.patchFile : void 0,
126
+ backupPath: typeof value.backupPath === "string" ? value.backupPath : void 0,
127
+ disconnected: typeof value.disconnected === "boolean" ? value.disconnected : void 0,
128
+ existed: typeof value.existed === "boolean" ? value.existed : void 0,
129
+ removedHooks: typeof value.removedHooks === "boolean" ? value.removedHooks : void 0
130
+ };
131
+ } catch (error) {
132
+ console.warn(`[dsh-hooks-ui] POST ${path} failed: ${error instanceof Error ? error.message : String(error)}`);
133
+ return {
134
+ ok: false,
135
+ error: "网络请求失败"
136
+ };
137
+ }
138
+ }
139
+ async function postNotifyTest(channel, url, slack, fetchFn = fetch) {
140
+ const body = { channel };
141
+ if (url !== void 0 && url !== "") body.url = url;
142
+ if (slack) body.slack = true;
143
+ return postValue("/dsh-hooks/notify/test", body, fetchFn);
144
+ }
145
+ async function postHooksSave(profile, hooks, fetchFn = fetch) {
146
+ return postValue("/dsh-hooks/hooks/save", {
147
+ profile,
148
+ hooks
149
+ }, fetchFn);
150
+ }
151
+ async function postFeishuDisconnect(profile, removeHooks, fetchFn = fetch) {
152
+ return postValue("/dsh-hooks/feishu/disconnect", {
153
+ profile,
154
+ removeHooks
155
+ }, fetchFn);
156
+ }
55
157
  /** `HH:MM:SS` local time for a timestamp. */
56
158
  function formatTime(ts) {
57
159
  const date = new Date(ts);
@@ -86,10 +188,14 @@ window.__ModuleLoader__.load({
86
188
  //#endregion
87
189
  //#region src/client/settings-card.tsx
88
190
  /**
89
- * The dsh-hooks settings card: status badges, execution-history timeline,
90
- * and a manual event tester all served by the core plugin's /dsh-hooks/*
91
- * routes. Degrades gracefully: fetch failures show an inline notice, never
92
- * a crash. Registered into the shell's `web-ui.plugin.item` slot.
191
+ * The dsh-hooks settings card: status badges (incl. live runner stats), a
192
+ * manual event tester, notify-channel quick tests, the Feishu connect flow
193
+ * (QR scan + truncation editor with preview + test card + disconnect), the
194
+ * hook list / editor (saves back to cordis.patch.yml with a backup), and a
195
+ * collapsed-by-default execution-history timeline at the bottom — all
196
+ * served by the core plugin's /dsh-hooks/* routes. Degrades gracefully:
197
+ * fetch failures show an inline notice with a retry button, never a crash.
198
+ * Registered into the shell's `settings.section` slot.
93
199
  */
94
200
  const EVENTS = [
95
201
  "turn/start",
@@ -107,19 +213,132 @@ window.__ModuleLoader__.load({
107
213
  "agent/error",
108
214
  "agent/status"
109
215
  ];
216
+ const TURN_END_REASONS = [
217
+ "completed",
218
+ "error",
219
+ "aborted",
220
+ "blocked",
221
+ "max-tokens",
222
+ "interrupted"
223
+ ];
224
+ const DEFAULT_TRUNCATE = 300;
225
+ function loadStored(key, fallback) {
226
+ try {
227
+ return localStorage.getItem(key) ?? fallback;
228
+ } catch {
229
+ return fallback;
230
+ }
231
+ }
232
+ function storeValue(key, value) {
233
+ try {
234
+ localStorage.setItem(key, value);
235
+ } catch {}
236
+ }
237
+ /** Serialize one hook as a cordis.patch.yml snippet for copy-paste. */
238
+ function hookToYaml(hook) {
239
+ const q = (s) => `'${s.replace(/'/g, "''")}'`;
240
+ const lines = [`- on: ${q(hook.on)}`];
241
+ if (hook.when !== void 0 && hook.when !== "") lines.push(` when: ${q(hook.when)}`);
242
+ if (hook.match !== void 0 && Object.keys(hook.match).length > 0) {
243
+ lines.push(" match:");
244
+ for (const [field, pattern] of Object.entries(hook.match)) lines.push(` ${field}: ${q(pattern)}`);
245
+ }
246
+ if (hook.notify !== void 0 && hook.notify !== null) {
247
+ lines.push(" notify:");
248
+ lines.push(` channel: ${q(hook.notify.channel)}`);
249
+ if (hook.notify.url !== void 0 && hook.notify.url !== "") lines.push(` url: ${q(hook.notify.url)}`);
250
+ if (hook.notify.slack === true) lines.push(" slack: true");
251
+ } else if (hook.run !== void 0 && hook.run !== "") lines.push(` run: ${q(hook.run)}`);
252
+ if (hook.timeoutMs !== void 0 && hook.timeoutMs !== 1e4) lines.push(` timeoutMs: ${hook.timeoutMs}`);
253
+ if (hook.retries !== void 0 && hook.retries !== 0) lines.push(` retries: ${hook.retries}`);
254
+ if (hook.retryDelayMs !== void 0 && hook.retryDelayMs !== 500) lines.push(` retryDelayMs: ${hook.retryDelayMs}`);
255
+ return lines.join("\n");
256
+ }
257
+ async function copyText(text) {
258
+ try {
259
+ await navigator.clipboard.writeText(text);
260
+ return true;
261
+ } catch {
262
+ try {
263
+ const area = document.createElement("textarea");
264
+ area.value = text;
265
+ document.body.appendChild(area);
266
+ area.select();
267
+ const ok = document.execCommand("copy");
268
+ document.body.removeChild(area);
269
+ return ok;
270
+ } catch {
271
+ return false;
272
+ }
273
+ }
274
+ }
275
+ function parseNum(value) {
276
+ const parsed = Number(value);
277
+ return Number.isFinite(parsed) ? parsed : void 0;
278
+ }
279
+ function toWire(draft) {
280
+ return {
281
+ on: draft.on,
282
+ when: draft.when === "" ? void 0 : draft.when,
283
+ match: draft.match !== void 0 && Object.keys(draft.match).length > 0 ? draft.match : void 0,
284
+ run: draft.notify === void 0 || draft.notify === null ? draft.run?.trim() || void 0 : void 0,
285
+ notify: draft.notify ?? null,
286
+ timeoutMs: draft.timeoutMs,
287
+ retries: draft.retries,
288
+ retryDelayMs: draft.retryDelayMs
289
+ };
290
+ }
291
+ const NEW_HOOK = {
292
+ on: "turn/end",
293
+ when: "completed",
294
+ run: ""
295
+ };
110
296
  /** Settings-slot component; the shell's slot machinery supplies the props. */
111
297
  function HooksSettingsCard(_props) {
112
298
  const [status, setStatus] = (0, react.useState)(null);
113
299
  const [history, setHistory] = (0, react.useState)(null);
300
+ const [historyOpen, setHistoryOpenState] = (0, react.useState)(() => {
301
+ try {
302
+ return localStorage.getItem("dsh-hooks.historyOpen") === "1";
303
+ } catch {
304
+ return false;
305
+ }
306
+ });
114
307
  const [loadError, setLoadError] = (0, react.useState)(false);
115
308
  const [event, setEvent] = (0, react.useState)("turn/end");
116
309
  const [reason, setReason] = (0, react.useState)("completed");
117
310
  const [tool, setTool] = (0, react.useState)("");
118
311
  const [testResult, setTestResult] = (0, react.useState)(null);
312
+ const [notifyChannel, setNotifyChannel] = (0, react.useState)("webhook");
313
+ const [notifyUrl, setNotifyUrl] = (0, react.useState)("");
314
+ const [notifySlack, setNotifySlack] = (0, react.useState)(false);
315
+ const [notifyResult, setNotifyResult] = (0, react.useState)(null);
316
+ const [feishu, setFeishu] = (0, react.useState)(null);
317
+ const [profile, setProfile] = (0, react.useState)(() => loadStored("dsh-hooks.profile", "web"));
318
+ const [setupTruncate, setSetupTruncate] = (0, react.useState)(String(DEFAULT_TRUNCATE));
319
+ const [truncateDraft, setTruncateDraft] = (0, react.useState)(null);
320
+ const [configMessage, setConfigMessage] = (0, react.useState)(null);
321
+ const [reconnecting, setReconnecting] = (0, react.useState)(false);
322
+ const [feishuError, setFeishuError] = (0, react.useState)(null);
323
+ const [testMessage, setTestMessage] = (0, react.useState)(null);
324
+ const [countdown, setCountdown] = (0, react.useState)(null);
325
+ const [editing, setEditing] = (0, react.useState)(false);
326
+ const [draftHooks, setDraftHooks] = (0, react.useState)([]);
327
+ const [saveMessage, setSaveMessage] = (0, react.useState)(null);
328
+ const [copiedIndex, setCopiedIndex] = (0, react.useState)(null);
329
+ const setHistoryOpen = (open) => {
330
+ setHistoryOpenState(open);
331
+ storeValue("dsh-hooks.historyOpen", open ? "1" : "0");
332
+ };
119
333
  const refresh = (0, react.useCallback)(async () => {
120
- const [statusInfo, records] = await Promise.all([fetchStatus(), fetchHistory(30)]);
334
+ const [statusInfo, records, feishuInfo] = await Promise.all([
335
+ fetchStatus(),
336
+ fetchHistory(30),
337
+ fetchFeishuStatus()
338
+ ]);
121
339
  setStatus(statusInfo);
122
340
  setHistory(records);
341
+ setFeishu(feishuInfo);
123
342
  setLoadError(statusInfo === null && records === null);
124
343
  }, []);
125
344
  (0, react.useEffect)(() => {
@@ -127,6 +346,29 @@ window.__ModuleLoader__.load({
127
346
  const timer = setInterval(() => void refresh(), 5e3);
128
347
  return () => clearInterval(timer);
129
348
  }, [refresh]);
349
+ const pending = feishu?.setup?.status === "pending";
350
+ (0, react.useEffect)(() => {
351
+ if (!pending) return;
352
+ const poll = setInterval(() => void refresh(), 2e3);
353
+ const tick = setInterval(() => {
354
+ setCountdown(remainingSeconds(feishu?.setup?.expiresAtMs));
355
+ }, 1e3);
356
+ return () => {
357
+ clearInterval(poll);
358
+ clearInterval(tick);
359
+ };
360
+ }, [
361
+ pending,
362
+ feishu?.setup?.expiresAtMs,
363
+ refresh
364
+ ]);
365
+ (0, react.useEffect)(() => {
366
+ setTestResult(null);
367
+ }, [
368
+ event,
369
+ reason,
370
+ tool
371
+ ]);
130
372
  const runTest = async (execute) => {
131
373
  const result = await postTest({
132
374
  event,
@@ -137,6 +379,218 @@ window.__ModuleLoader__.load({
137
379
  setTestResult(result);
138
380
  if (execute) refresh();
139
381
  };
382
+ const sendNotifyTest = async () => {
383
+ setNotifyResult(null);
384
+ const result = await postNotifyTest(notifyChannel, notifyUrl.trim() || void 0, notifySlack);
385
+ if (!result.ok) setNotifyResult({
386
+ ok: false,
387
+ text: result.error ?? "发送失败"
388
+ });
389
+ else setNotifyResult({
390
+ ok: true,
391
+ text: result.message ?? "已发送",
392
+ preview: result.preview
393
+ });
394
+ };
395
+ const connectFeishu = async () => {
396
+ setFeishuError(null);
397
+ setTestMessage(null);
398
+ setConfigMessage(null);
399
+ setCountdown(null);
400
+ const parsed = Number(setupTruncate);
401
+ const result = await postFeishuSetup(profile.trim() !== "" ? profile.trim() : "web", Number.isFinite(parsed) ? parsed : void 0);
402
+ if (!result.ok) {
403
+ setFeishuError(result.error ?? "启动扫码失败");
404
+ return;
405
+ }
406
+ if (result.setup !== void 0) {
407
+ setFeishu({
408
+ configured: false,
409
+ appId: null,
410
+ targetKind: null,
411
+ target: null,
412
+ setup: result.setup,
413
+ resultMaxChars: Number.isFinite(parsed) ? parsed : DEFAULT_TRUNCATE,
414
+ preview: ""
415
+ });
416
+ setCountdown(remainingSeconds(result.setup.expiresAtMs));
417
+ }
418
+ };
419
+ const cancelFeishu = async () => {
420
+ await postFeishuCancel();
421
+ refresh();
422
+ };
423
+ const sendTestCard = async () => {
424
+ setTestMessage(null);
425
+ setFeishuError(null);
426
+ const result = await postFeishuTest();
427
+ if (!result.ok) setFeishuError(result.error ?? "发送失败");
428
+ else setTestMessage(result.message ?? "已发送");
429
+ };
430
+ const saveTruncate = async () => {
431
+ const value = Number(truncateDraft);
432
+ if (!Number.isFinite(value)) {
433
+ setConfigMessage({
434
+ ok: false,
435
+ text: "请输入数字"
436
+ });
437
+ return;
438
+ }
439
+ const result = await postFeishuConfig(value);
440
+ if (!result.ok) {
441
+ setConfigMessage({
442
+ ok: false,
443
+ text: result.error ?? "保存失败"
444
+ });
445
+ return;
446
+ }
447
+ setTruncateDraft(null);
448
+ setConfigMessage({
449
+ ok: true,
450
+ text: `已保存:卡片内容最长 ${result.resultMaxChars} 字符`
451
+ });
452
+ refresh();
453
+ };
454
+ const disconnectFeishu = async () => {
455
+ if (!window.confirm("确定断开飞书连接?将删除本机凭据文件。")) return;
456
+ const removeHooks = window.confirm("同时从 cordis.patch.yml 移除飞书通知 hooks?\n(推荐移除,否则这些 hook 每次触发都会失败)");
457
+ setFeishuError(null);
458
+ const result = await postFeishuDisconnect(profile.trim() !== "" ? profile.trim() : "web", removeHooks);
459
+ if (!result.ok) setFeishuError(result.error ?? "断开失败");
460
+ else setConfigMessage({
461
+ ok: true,
462
+ text: result.message ?? "已断开"
463
+ });
464
+ refresh();
465
+ };
466
+ const startEdit = () => {
467
+ if (editing) {
468
+ setEditing(false);
469
+ setDraftHooks([]);
470
+ setSaveMessage(null);
471
+ return;
472
+ }
473
+ const current = status?.hooks ?? [];
474
+ setDraftHooks(current.map((hook) => ({
475
+ on: hook.on,
476
+ when: hook.when,
477
+ match: hook.match !== void 0 ? { ...hook.match } : void 0,
478
+ run: hook.run,
479
+ notify: hook.notify === void 0 ? null : {
480
+ channel: hook.notify.channel,
481
+ url: hook.notify.url,
482
+ slack: hook.notify.slack
483
+ },
484
+ timeoutMs: hook.timeoutMs,
485
+ retries: hook.retries,
486
+ retryDelayMs: hook.retryDelayMs
487
+ })));
488
+ setSaveMessage(null);
489
+ setEditing(true);
490
+ };
491
+ const patchDraft = (index, patch) => {
492
+ setDraftHooks((list) => list.map((hook, i) => i === index ? {
493
+ ...hook,
494
+ ...patch
495
+ } : hook));
496
+ };
497
+ const patchAction = (index, channel) => {
498
+ setDraftHooks((list) => list.map((hook, i) => {
499
+ if (i !== index) return hook;
500
+ if (channel === "") return {
501
+ ...hook,
502
+ notify: null,
503
+ run: hook.run ?? ""
504
+ };
505
+ return {
506
+ ...hook,
507
+ notify: {
508
+ channel,
509
+ url: hook.notify?.url,
510
+ slack: hook.notify?.slack
511
+ },
512
+ run: void 0
513
+ };
514
+ }));
515
+ };
516
+ const patchMatchKey = (index, oldKey, newKey) => {
517
+ setDraftHooks((list) => list.map((hook, i) => {
518
+ if (i !== index || hook.match === void 0) return hook;
519
+ const match = { ...hook.match };
520
+ if (oldKey !== newKey) {
521
+ delete match[oldKey];
522
+ if (newKey !== "") match[newKey] = hook.match[oldKey];
523
+ }
524
+ return {
525
+ ...hook,
526
+ match
527
+ };
528
+ }));
529
+ };
530
+ const patchMatchValue = (index, field, value) => {
531
+ setDraftHooks((list) => list.map((hook, i) => i === index ? {
532
+ ...hook,
533
+ match: {
534
+ ...hook.match ?? {},
535
+ [field]: value
536
+ }
537
+ } : hook));
538
+ };
539
+ const removeMatch = (index, field) => {
540
+ setDraftHooks((list) => list.map((hook, i) => {
541
+ if (i !== index || hook.match === void 0) return hook;
542
+ const match = { ...hook.match };
543
+ delete match[field];
544
+ return {
545
+ ...hook,
546
+ match
547
+ };
548
+ }));
549
+ };
550
+ const addMatch = (index) => {
551
+ setDraftHooks((list) => list.map((hook, i) => i === index ? {
552
+ ...hook,
553
+ match: {
554
+ ...hook.match ?? {},
555
+ "": ""
556
+ }
557
+ } : hook));
558
+ };
559
+ const removeDraft = (index) => {
560
+ setDraftHooks((list) => list.filter((_, i) => i !== index));
561
+ };
562
+ const saveHooks = async () => {
563
+ const wire = draftHooks.map(toWire);
564
+ for (const [i, hook] of wire.entries()) if ((hook.run === void 0 || hook.run === "") && (hook.notify === void 0 || hook.notify === null)) {
565
+ setSaveMessage({
566
+ ok: false,
567
+ text: `hook #${i + 1}:请填写 run 命令或选择通知渠道`
568
+ });
569
+ return;
570
+ }
571
+ const result = await postHooksSave(profile.trim() !== "" ? profile.trim() : "web", wire);
572
+ if (!result.ok) {
573
+ setSaveMessage({
574
+ ok: false,
575
+ text: result.error ?? "保存失败"
576
+ });
577
+ return;
578
+ }
579
+ setSaveMessage({
580
+ ok: true,
581
+ text: result.message ?? "已保存"
582
+ });
583
+ setEditing(false);
584
+ setDraftHooks([]);
585
+ refresh();
586
+ };
587
+ const copyHookYaml = async (hook, index) => {
588
+ if (await copyText(hookToYaml(hook))) {
589
+ setCopiedIndex(index);
590
+ setTimeout(() => setCopiedIndex((current) => current === index ? null : current), 1500);
591
+ }
592
+ };
593
+ const truncateValue = truncateDraft ?? String(feishu?.resultMaxChars ?? DEFAULT_TRUNCATE);
140
594
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
141
595
  className: "dh-card",
142
596
  children: [
@@ -159,57 +613,30 @@ window.__ModuleLoader__.load({
159
613
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
160
614
  className: "dh-badge",
161
615
  children: [status.historyCount, " 记录"]
616
+ }),
617
+ status.stats.inFlight > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
618
+ className: "dh-badge",
619
+ children: [status.stats.inFlight, " 运行中"]
620
+ }),
621
+ status.stats.recentFailures > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
622
+ className: "dh-badge dh-badge-bad",
623
+ children: [status.stats.recentFailures, " 失败"]
162
624
  })
163
625
  ] })
164
626
  })]
165
627
  }),
166
- loadError && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
628
+ loadError && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
167
629
  className: "dh-error-banner",
168
- children: "无法访问 /dsh-hooks/* 路由:请确认 dsh-hooks 核心插件已安装且 dsh web 已重启。"
630
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "无法访问 /dsh-hooks/* 路由:请确认 dsh-hooks 核心插件已安装且 dsh web 已重启。" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
631
+ type: "button",
632
+ className: "dh-button dh-error-retry",
633
+ onClick: () => {
634
+ setLoadError(false);
635
+ refresh();
636
+ },
637
+ children: "重试"
638
+ })]
169
639
  }),
170
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
171
- className: "dh-section-title",
172
- children: "执行历史(最近 30 条)"
173
- }), history === null || history.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
174
- className: "dh-empty",
175
- children: history === null ? "加载中…" : "暂无记录"
176
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
177
- className: "dh-timeline",
178
- children: [...history].reverse().map((record, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
179
- className: "dh-record",
180
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
181
- className: "dh-record-main",
182
- children: [
183
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
184
- className: "dh-record-top",
185
- children: [
186
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
187
- className: "dh-record-time",
188
- children: formatTime(record.ts)
189
- }),
190
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
191
- className: "dh-record-event",
192
- children: record.event
193
- }),
194
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
195
- className: `dh-outcome ${outcomeClass(record.outcome)}`,
196
- children: outcomeLabel(record.outcome)
197
- })
198
- ]
199
- }),
200
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
201
- className: "dh-record-command",
202
- title: record.command,
203
- children: record.command
204
- }),
205
- record.error !== void 0 && record.error !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
206
- className: "dh-record-error",
207
- children: record.error.slice(0, 200)
208
- })
209
- ]
210
- })
211
- }, `${record.ts}-${index}`))
212
- })] }),
213
640
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
214
641
  className: "dh-section-title",
215
642
  children: "手动测试"
@@ -300,10 +727,605 @@ window.__ModuleLoader__.load({
300
727
  }, line.index))]
301
728
  })
302
729
  ]
303
- })] })
730
+ })] }),
731
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
732
+ className: "dh-section-title",
733
+ children: "通知渠道测试"
734
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
735
+ className: "dh-test-form",
736
+ children: [
737
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
738
+ className: "dh-test-row",
739
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
740
+ className: "dh-field",
741
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
742
+ className: "dh-field-label",
743
+ children: "渠道"
744
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
745
+ className: "dh-select",
746
+ value: notifyChannel,
747
+ onChange: (e) => setNotifyChannel(e.target.value),
748
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
749
+ value: "webhook",
750
+ children: "webhook(HTTP JSON)"
751
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
752
+ value: "desktop",
753
+ children: "desktop(系统通知)"
754
+ })]
755
+ })]
756
+ }), notifyChannel === "webhook" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
757
+ className: "dh-field",
758
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
759
+ className: "dh-field-label",
760
+ children: "URL(留空用 DSH_HOOKS_WEBHOOK_URL)"
761
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
762
+ className: "dh-input",
763
+ value: notifyUrl,
764
+ onChange: (e) => setNotifyUrl(e.target.value),
765
+ placeholder: "https://hooks.slack.com/…"
766
+ })]
767
+ })]
768
+ }),
769
+ notifyChannel === "webhook" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
770
+ className: "dh-check",
771
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
772
+ type: "checkbox",
773
+ checked: notifySlack,
774
+ onChange: (e) => setNotifySlack(e.target.checked)
775
+ }), "Slack 风格单行摘要"]
776
+ }),
777
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
778
+ className: "dh-buttons",
779
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
780
+ type: "button",
781
+ className: "dh-button dh-button-primary",
782
+ onClick: () => void sendNotifyTest(),
783
+ children: "发送测试通知"
784
+ })
785
+ }),
786
+ notifyResult !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
787
+ className: notifyResult.ok ? "dh-test-line dh-test-line-match" : "dh-feishu-error",
788
+ children: notifyResult.text
789
+ }), notifyResult.ok && notifyResult.preview !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
790
+ className: "dh-feishu-hint",
791
+ children: ["发送内容:", notifyResult.preview]
792
+ })] })
793
+ ]
794
+ })] }),
795
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
796
+ className: "dh-section-title",
797
+ children: "飞书通知"
798
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
799
+ className: "dh-feishu",
800
+ children: pending ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
801
+ className: "dh-feishu-qr",
802
+ children: [
803
+ feishu?.setup?.qrDataUrl !== void 0 && feishu?.setup?.qrDataUrl !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
804
+ className: "dh-feishu-qr-img",
805
+ src: feishu.setup.qrDataUrl,
806
+ alt: "飞书扫码授权二维码"
807
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
808
+ className: "dh-feishu-link",
809
+ href: feishu?.setup?.qrUrl,
810
+ target: "_blank",
811
+ rel: "noreferrer",
812
+ children: "在浏览器中打开飞书授权链接"
813
+ }),
814
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
815
+ className: "dh-feishu-line",
816
+ children: ["请用飞书扫码", countdown !== null && countdown > 0 ? `(${countdown}s 内有效)` : ""]
817
+ }),
818
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
819
+ className: "dh-buttons",
820
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
821
+ type: "button",
822
+ className: "dh-button",
823
+ onClick: () => void cancelFeishu(),
824
+ children: "取消"
825
+ })
826
+ })
827
+ ]
828
+ }) : feishu?.configured === true && !reconnecting ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
829
+ className: "dh-feishu-status",
830
+ children: [
831
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
832
+ className: "dh-feishu-line dh-feishu-ok",
833
+ children: [
834
+ "✅ 已连接 · 应用 ",
835
+ feishu.appId ?? "?",
836
+ feishu.targetKind !== null && feishu.target !== null ? `(接收者 ${feishu.targetKind}: ${feishu.target})` : ""
837
+ ]
838
+ }),
839
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
840
+ className: "dh-feishu-row",
841
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
842
+ className: "dh-field dh-field-narrow",
843
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
844
+ className: "dh-field-label",
845
+ children: "卡片截断长度(50–5000 字符)"
846
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
847
+ className: "dh-input",
848
+ type: "number",
849
+ min: 50,
850
+ max: 5e3,
851
+ value: truncateValue,
852
+ onChange: (e) => setTruncateDraft(e.target.value)
853
+ })]
854
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
855
+ type: "button",
856
+ className: "dh-button",
857
+ onClick: () => void saveTruncate(),
858
+ children: "保存"
859
+ })]
860
+ }),
861
+ feishu.preview !== void 0 && feishu.preview !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
862
+ className: "dh-feishu-preview",
863
+ title: "按当前截断长度生成的卡片正文预览",
864
+ children: ["卡片预览:", feishu.preview]
865
+ }),
866
+ configMessage !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
867
+ className: configMessage.ok ? "dh-feishu-line dh-feishu-ok" : "dh-feishu-error",
868
+ children: configMessage.text
869
+ }),
870
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
871
+ className: "dh-buttons",
872
+ children: [
873
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
874
+ type: "button",
875
+ className: "dh-button dh-button-primary",
876
+ onClick: () => void sendTestCard(),
877
+ children: "发送测试卡片"
878
+ }),
879
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
880
+ type: "button",
881
+ className: "dh-button",
882
+ onClick: () => setReconnecting(true),
883
+ children: "重新连接"
884
+ }),
885
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
886
+ type: "button",
887
+ className: "dh-button dh-button-danger",
888
+ onClick: () => void disconnectFeishu(),
889
+ children: "断开连接"
890
+ })
891
+ ]
892
+ }),
893
+ testMessage !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
894
+ className: "dh-feishu-line dh-feishu-ok",
895
+ children: testMessage
896
+ }),
897
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
898
+ className: "dh-feishu-hint",
899
+ children: "截断长度即时生效;重新扫码会覆盖现有应用凭据与本 profile 的飞书 hooks。"
900
+ })
901
+ ]
902
+ }) : feishu?.setup?.status === "failed" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
903
+ className: "dh-feishu-status",
904
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
905
+ className: "dh-feishu-error",
906
+ children: ["连接失败:", feishu.setup.error ?? "未知错误"]
907
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
908
+ className: "dh-buttons",
909
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
910
+ type: "button",
911
+ className: "dh-button dh-button-primary",
912
+ onClick: () => void connectFeishu(),
913
+ children: "重试"
914
+ })
915
+ })]
916
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
917
+ className: "dh-feishu-form",
918
+ children: [
919
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
920
+ className: "dh-test-row",
921
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
922
+ className: "dh-field",
923
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
924
+ className: "dh-field-label",
925
+ children: "profile(写入哪个 profile 的 cordis.patch.yml)"
926
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
927
+ className: "dh-input",
928
+ value: profile,
929
+ onChange: (e) => {
930
+ setProfile(e.target.value);
931
+ storeValue("dsh-hooks.profile", e.target.value);
932
+ },
933
+ placeholder: "web"
934
+ })]
935
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
936
+ className: "dh-field dh-field-narrow",
937
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
938
+ className: "dh-field-label",
939
+ children: "卡片截断长度(50–5000)"
940
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
941
+ className: "dh-input",
942
+ type: "number",
943
+ min: 50,
944
+ max: 5e3,
945
+ value: setupTruncate,
946
+ onChange: (e) => setSetupTruncate(e.target.value)
947
+ })]
948
+ })]
949
+ }),
950
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
951
+ className: "dh-buttons",
952
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
953
+ type: "button",
954
+ className: "dh-button dh-button-primary",
955
+ onClick: () => void connectFeishu(),
956
+ children: "扫码连接飞书"
957
+ }), reconnecting && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
958
+ type: "button",
959
+ className: "dh-button",
960
+ onClick: () => setReconnecting(false),
961
+ children: "返回"
962
+ })]
963
+ }),
964
+ feishuError !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
965
+ className: "dh-feishu-error",
966
+ children: feishuError
967
+ }),
968
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
969
+ className: "dh-feishu-hint",
970
+ children: "将创建名为「DSH 通知机器人」的飞书应用(仅 im:message:send_as_bot 权限),扫码者本人接收通知卡片;配置写入 ~/.dsh/profiles/<profile>/cordis.patch.yml,重启 dsh web 后生效。"
971
+ })
972
+ ]
973
+ })
974
+ })] }),
975
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
976
+ className: "dh-section-head",
977
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("h3", {
978
+ className: "dh-section-title",
979
+ children: ["当前 hooks", status !== null ? ` · ${status.hookCount}` : ""]
980
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
981
+ type: "button",
982
+ className: "dh-button dh-toggle",
983
+ onClick: startEdit,
984
+ children: editing ? "取消编辑" : "编辑"
985
+ })]
986
+ }), editing ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
987
+ className: "dh-hook-editor",
988
+ children: [
989
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
990
+ className: "dh-test-row",
991
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
992
+ className: "dh-field dh-field-narrow",
993
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
994
+ className: "dh-field-label",
995
+ children: "profile(写入哪个 cordis.patch.yml)"
996
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
997
+ className: "dh-input",
998
+ value: profile,
999
+ onChange: (e) => {
1000
+ setProfile(e.target.value);
1001
+ storeValue("dsh-hooks.profile", e.target.value);
1002
+ },
1003
+ placeholder: "web"
1004
+ })]
1005
+ })
1006
+ }),
1007
+ draftHooks.map((hook, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1008
+ className: "dh-hook dh-hook-editing",
1009
+ children: [
1010
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1011
+ className: "dh-test-row",
1012
+ children: [
1013
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1014
+ className: "dh-field",
1015
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1016
+ className: "dh-field-label",
1017
+ children: "事件"
1018
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
1019
+ className: "dh-select",
1020
+ value: hook.on,
1021
+ onChange: (e) => patchDraft(index, {
1022
+ on: e.target.value,
1023
+ when: e.target.value === "turn/end" ? hook.when : void 0
1024
+ }),
1025
+ children: EVENTS.map((name) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1026
+ value: name,
1027
+ children: name
1028
+ }, name))
1029
+ })]
1030
+ }),
1031
+ hook.on === "turn/end" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1032
+ className: "dh-field",
1033
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1034
+ className: "dh-field-label",
1035
+ children: "when(可选)"
1036
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
1037
+ className: "dh-select",
1038
+ value: hook.when ?? "",
1039
+ onChange: (e) => patchDraft(index, { when: e.target.value === "" ? void 0 : e.target.value }),
1040
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1041
+ value: "",
1042
+ children: "全部原因"
1043
+ }), TURN_END_REASONS.map((name) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1044
+ value: name,
1045
+ children: name
1046
+ }, name))]
1047
+ })]
1048
+ }),
1049
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1050
+ className: "dh-field",
1051
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1052
+ className: "dh-field-label",
1053
+ children: "动作"
1054
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
1055
+ className: "dh-select",
1056
+ value: hook.notify !== void 0 && hook.notify !== null ? hook.notify.channel : "",
1057
+ onChange: (e) => patchAction(index, e.target.value),
1058
+ children: [
1059
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1060
+ value: "",
1061
+ children: "执行命令(run)"
1062
+ }),
1063
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1064
+ value: "webhook",
1065
+ children: "通知 webhook"
1066
+ }),
1067
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1068
+ value: "desktop",
1069
+ children: "通知 desktop"
1070
+ })
1071
+ ]
1072
+ })]
1073
+ })
1074
+ ]
1075
+ }),
1076
+ hook.notify !== void 0 && hook.notify !== null ? hook.notify.channel === "webhook" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1077
+ className: "dh-test-row",
1078
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1079
+ className: "dh-field",
1080
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1081
+ className: "dh-field-label",
1082
+ children: "URL(留空用 DSH_HOOKS_WEBHOOK_URL)"
1083
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1084
+ className: "dh-input",
1085
+ value: hook.notify.url ?? "",
1086
+ onChange: (e) => patchDraft(index, { notify: {
1087
+ ...hook.notify,
1088
+ url: e.target.value
1089
+ } }),
1090
+ placeholder: "https://hooks.slack.com/…"
1091
+ })]
1092
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1093
+ className: "dh-check",
1094
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1095
+ type: "checkbox",
1096
+ checked: hook.notify.slack === true,
1097
+ onChange: (e) => patchDraft(index, { notify: {
1098
+ ...hook.notify,
1099
+ slack: e.target.checked
1100
+ } })
1101
+ }), "Slack 单行摘要"]
1102
+ })]
1103
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1104
+ className: "dh-feishu-hint",
1105
+ children: "desktop:弹出系统桌面通知(内容为事件摘要)。"
1106
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1107
+ className: "dh-input",
1108
+ value: hook.run ?? "",
1109
+ onChange: (e) => patchDraft(index, { run: e.target.value }),
1110
+ placeholder: "node notify-feishu.mjs"
1111
+ }),
1112
+ Object.entries(hook.match ?? {}).map(([field, pattern], mi) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1113
+ className: "dh-test-row",
1114
+ children: [
1115
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1116
+ className: "dh-input dh-match-key",
1117
+ value: field,
1118
+ onChange: (e) => patchMatchKey(index, field, e.target.value),
1119
+ placeholder: "字段(tool)"
1120
+ }),
1121
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1122
+ className: "dh-input",
1123
+ value: pattern,
1124
+ onChange: (e) => patchMatchValue(index, field, e.target.value),
1125
+ placeholder: "正则(^(rm|git|ssh))"
1126
+ }),
1127
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1128
+ type: "button",
1129
+ className: "dh-button",
1130
+ onClick: () => removeMatch(index, field),
1131
+ children: "×"
1132
+ })
1133
+ ]
1134
+ }, mi)),
1135
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1136
+ className: "dh-test-row",
1137
+ children: [
1138
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1139
+ type: "button",
1140
+ className: "dh-button dh-button-small",
1141
+ onClick: () => addMatch(index),
1142
+ children: "+ 添加匹配字段"
1143
+ }),
1144
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1145
+ className: "dh-field",
1146
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1147
+ className: "dh-field-label",
1148
+ children: "timeoutMs"
1149
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1150
+ className: "dh-input",
1151
+ type: "number",
1152
+ value: hook.timeoutMs ?? "",
1153
+ onChange: (e) => patchDraft(index, { timeoutMs: parseNum(e.target.value) }),
1154
+ placeholder: "10000"
1155
+ })]
1156
+ }),
1157
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1158
+ className: "dh-field",
1159
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1160
+ className: "dh-field-label",
1161
+ children: "retries"
1162
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1163
+ className: "dh-input",
1164
+ type: "number",
1165
+ value: hook.retries ?? "",
1166
+ onChange: (e) => patchDraft(index, { retries: parseNum(e.target.value) }),
1167
+ placeholder: "0"
1168
+ })]
1169
+ }),
1170
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1171
+ className: "dh-field",
1172
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1173
+ className: "dh-field-label",
1174
+ children: "retryDelayMs"
1175
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1176
+ className: "dh-input",
1177
+ type: "number",
1178
+ value: hook.retryDelayMs ?? "",
1179
+ onChange: (e) => patchDraft(index, { retryDelayMs: parseNum(e.target.value) }),
1180
+ placeholder: "500"
1181
+ })]
1182
+ }),
1183
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1184
+ type: "button",
1185
+ className: "dh-button dh-button-danger",
1186
+ onClick: () => removeDraft(index),
1187
+ children: "删除"
1188
+ })
1189
+ ]
1190
+ })
1191
+ ]
1192
+ }, index)),
1193
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1194
+ className: "dh-buttons",
1195
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1196
+ type: "button",
1197
+ className: "dh-button",
1198
+ onClick: () => setDraftHooks((list) => [...list, { ...NEW_HOOK }]),
1199
+ children: "+ 新增 hook"
1200
+ })
1201
+ }),
1202
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1203
+ className: "dh-buttons",
1204
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1205
+ type: "button",
1206
+ className: "dh-button dh-button-primary",
1207
+ onClick: () => void saveHooks(),
1208
+ children: "保存到 cordis.patch.yml"
1209
+ })
1210
+ }),
1211
+ saveMessage !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1212
+ className: saveMessage.ok ? "dh-test-line dh-test-line-match" : "dh-feishu-error",
1213
+ children: saveMessage.text
1214
+ }),
1215
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1216
+ className: "dh-feishu-hint",
1217
+ children: "保存后写回配置文件并自动备份原文件;如未立即生效请重启 dsh web。"
1218
+ })
1219
+ ]
1220
+ }) : status === null || status.hooks.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1221
+ className: "dh-empty",
1222
+ children: status === null ? "加载中…" : "暂无 hook,点「编辑」添加"
1223
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1224
+ className: "dh-hook-list",
1225
+ children: status.hooks.map((hook) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1226
+ className: "dh-hook",
1227
+ children: [
1228
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1229
+ className: "dh-hook-head",
1230
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1231
+ className: "dh-hook-event",
1232
+ children: [
1233
+ "[",
1234
+ hook.index,
1235
+ "] ",
1236
+ hook.on,
1237
+ hook.when !== void 0 && hook.when !== "" ? ` · ${hook.when}` : ""
1238
+ ]
1239
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1240
+ className: "dh-hook-action",
1241
+ title: hook.run ?? `notify:${hook.notify?.channel}`,
1242
+ children: hook.run !== void 0 && hook.run !== "" ? `run: ${hook.run}` : `notify: ${hook.notify?.channel ?? "?"}${hook.notify?.url ? ` ${hook.notify.url}` : ""}${hook.notify?.slack ? " (slack)" : ""}`
1243
+ })]
1244
+ }),
1245
+ hook.match !== void 0 && Object.keys(hook.match).length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1246
+ className: "dh-hook-match",
1247
+ children: Object.entries(hook.match).map(([field, pattern]) => `${field} =~ /${pattern}/`).join(",")
1248
+ }),
1249
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1250
+ className: "dh-hook-meta",
1251
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1252
+ type: "button",
1253
+ className: "dh-button dh-button-small",
1254
+ onClick: () => void copyHookYaml(hook, hook.index),
1255
+ children: copiedIndex === hook.index ? "已复制 ✓" : "复制 YAML"
1256
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1257
+ className: "dh-feishu-hint",
1258
+ children: [
1259
+ "timeout ",
1260
+ hook.timeoutMs ?? 1e4,
1261
+ "ms · retries ",
1262
+ hook.retries ?? 0
1263
+ ]
1264
+ })]
1265
+ })
1266
+ ]
1267
+ }, hook.index))
1268
+ })] }),
1269
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1270
+ className: "dh-section-head",
1271
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("h3", {
1272
+ className: "dh-section-title",
1273
+ children: ["执行历史(最近 30 条)", status !== null && status.historyCount > 0 ? ` · ${status.historyCount} 条` : ""]
1274
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1275
+ type: "button",
1276
+ className: "dh-button dh-toggle",
1277
+ onClick: () => setHistoryOpen(!historyOpen),
1278
+ "aria-expanded": historyOpen,
1279
+ children: historyOpen ? "收起 ▲" : "展开 ▼"
1280
+ })]
1281
+ }), historyOpen && (history === null || history.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1282
+ className: "dh-empty",
1283
+ children: history === null ? "加载中…" : "暂无记录"
1284
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1285
+ className: "dh-timeline",
1286
+ children: [...history].reverse().map((record, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1287
+ className: "dh-record",
1288
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1289
+ className: "dh-record-main",
1290
+ children: [
1291
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1292
+ className: "dh-record-top",
1293
+ children: [
1294
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1295
+ className: "dh-record-time",
1296
+ children: formatTime(record.ts)
1297
+ }),
1298
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1299
+ className: "dh-record-event",
1300
+ children: record.event
1301
+ }),
1302
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1303
+ className: `dh-outcome ${outcomeClass(record.outcome)}`,
1304
+ children: outcomeLabel(record.outcome)
1305
+ })
1306
+ ]
1307
+ }),
1308
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1309
+ className: "dh-record-command",
1310
+ title: record.command,
1311
+ children: record.command
1312
+ }),
1313
+ record.error !== void 0 && record.error !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1314
+ className: "dh-record-error",
1315
+ children: record.error.slice(0, 200)
1316
+ })
1317
+ ]
1318
+ })
1319
+ }, `${record.ts}-${index}`))
1320
+ }))] })
304
1321
  ]
305
1322
  });
306
1323
  }
1324
+ /** Seconds until the QR expires, or null when unknown/expired. */
1325
+ function remainingSeconds(expiresAtMs) {
1326
+ if (expiresAtMs === void 0) return null;
1327
+ return Math.max(0, Math.round((expiresAtMs - Date.now()) / 1e3));
1328
+ }
307
1329
  function outcomeClass(outcome) {
308
1330
  switch (outcomeTone(outcome)) {
309
1331
  case "ok": return "dh-outcome-ok";
@@ -314,7 +1336,7 @@ window.__ModuleLoader__.load({
314
1336
  }
315
1337
  //#endregion
316
1338
  //#region src/client/settings-card.module.css?inline
317
- var settings_card_module_default = ":root {\n --dsh-hooks-border: #80849038;\n --dsh-hooks-muted: #767c85;\n --dsh-hooks-accent: #4d8df7;\n --dsh-hooks-ok: #3fb56b;\n --dsh-hooks-bad: #e5534b;\n --dsh-hooks-warn: #d9a13c;\n}\n\n.dh-card {\n flex-direction: column;\n gap: 14px;\n padding: 12px 4px;\n font-size: 13px;\n line-height: 1.5;\n display: flex;\n}\n\n.dh-card-head {\n align-items: center;\n gap: 10px;\n display: flex;\n}\n\n.dh-card-title {\n font-size: 14px;\n font-weight: 600;\n}\n\n.dh-badges {\n gap: 6px;\n display: flex;\n}\n\n.dh-badge {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n background: #80849029;\n border-radius: 9px;\n padding: 1px 7px;\n font-size: 11px;\n}\n\n.dh-section-title {\n color: var(--dsh-hooks-muted);\n text-transform: uppercase;\n letter-spacing: .04em;\n margin: 0 0 8px;\n font-size: 12px;\n font-weight: 600;\n}\n\n.dh-timeline {\n flex-direction: column;\n gap: 6px;\n display: flex;\n}\n\n.dh-record {\n border: 1px solid var(--dsh-hooks-border);\n border-radius: 6px;\n gap: 8px;\n padding: 7px 9px;\n display: flex;\n}\n\n.dh-record-main {\n flex: 1;\n min-width: 0;\n}\n\n.dh-record-top {\n align-items: baseline;\n gap: 6px;\n display: flex;\n}\n\n.dh-record-time {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n font-size: 11px;\n}\n\n.dh-record-event {\n white-space: nowrap;\n text-overflow: ellipsis;\n font-weight: 600;\n overflow: hidden;\n}\n\n.dh-record-command {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n text-overflow: ellipsis;\n text-align: left;\n direction: rtl;\n font-size: 12px;\n overflow: hidden;\n}\n\n.dh-outcome {\n white-space: nowrap;\n border-radius: 9px;\n align-self: flex-start;\n padding: 1px 7px;\n font-size: 11px;\n}\n\n.dh-outcome-ok {\n color: var(--dsh-hooks-ok);\n background: #3fb56b29;\n}\n\n.dh-outcome-bad {\n color: var(--dsh-hooks-bad);\n background: #e5534b29;\n}\n\n.dh-outcome-warn {\n color: var(--dsh-hooks-warn);\n background: #d9a13c29;\n}\n\n.dh-outcome-neutral {\n color: var(--dsh-hooks-muted);\n background: #80849029;\n}\n\n.dh-record-error {\n color: var(--dsh-hooks-bad);\n white-space: pre-wrap;\n word-break: break-all;\n margin-top: 4px;\n font-size: 11px;\n}\n\n.dh-empty {\n color: var(--dsh-hooks-muted);\n padding: 6px 2px;\n font-size: 12px;\n}\n\n.dh-test-form {\n flex-direction: column;\n gap: 8px;\n display: flex;\n}\n\n.dh-test-row {\n gap: 8px;\n display: flex;\n}\n\n.dh-field {\n flex-direction: column;\n flex: 1;\n gap: 3px;\n min-width: 0;\n display: flex;\n}\n\n.dh-field-label {\n color: var(--dsh-hooks-muted);\n font-size: 11px;\n}\n\n.dh-input, .dh-select {\n border: 1px solid var(--dsh-hooks-border);\n color: inherit;\n box-sizing: border-box;\n background: #8084901f;\n border-radius: 5px;\n outline: none;\n width: 100%;\n padding: 5px 8px;\n font-size: 12px;\n}\n\n.dh-input:focus, .dh-select:focus {\n border-color: var(--dsh-hooks-accent);\n}\n\n.dh-buttons {\n gap: 8px;\n display: flex;\n}\n\n.dh-button {\n border: 1px solid var(--dsh-hooks-border);\n color: inherit;\n cursor: pointer;\n background: #8084901f;\n border-radius: 5px;\n padding: 5px 12px;\n font-size: 12px;\n}\n\n.dh-button:hover {\n background: #80849038;\n}\n\n.dh-button-primary {\n background: var(--dsh-hooks-accent);\n border-color: var(--dsh-hooks-accent);\n color: #fff;\n}\n\n.dh-button-primary:hover {\n background: #3c7de8;\n}\n\n.dh-test-results {\n flex-direction: column;\n gap: 4px;\n display: flex;\n}\n\n.dh-test-line {\n word-break: break-all;\n border-radius: 5px;\n padding: 4px 8px;\n font-size: 12px;\n}\n\n.dh-test-line-match {\n color: var(--dsh-hooks-ok);\n background: #3fb56b24;\n}\n\n.dh-test-line-skip {\n color: var(--dsh-hooks-muted);\n background: #8084901a;\n}\n\n.dh-error-banner {\n color: var(--dsh-hooks-bad);\n background: #e5534b1f;\n border: 1px solid #e5534b66;\n border-radius: 6px;\n padding: 8px 10px;\n font-size: 12px;\n}\n";
1339
+ var settings_card_module_default = ":root {\n --dsh-hooks-border: #80849038;\n --dsh-hooks-muted: #767c85;\n --dsh-hooks-accent: #4d8df7;\n --dsh-hooks-ok: #3fb56b;\n --dsh-hooks-bad: #e5534b;\n --dsh-hooks-warn: #d9a13c;\n}\n\n.dh-card {\n flex-direction: column;\n gap: 14px;\n padding: 12px 4px;\n font-size: 13px;\n line-height: 1.5;\n display: flex;\n}\n\n.dh-card-head {\n align-items: center;\n gap: 10px;\n display: flex;\n}\n\n.dh-card-title {\n font-size: 14px;\n font-weight: 600;\n}\n\n.dh-badges {\n gap: 6px;\n display: flex;\n}\n\n.dh-badge {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n background: #80849029;\n border-radius: 9px;\n padding: 1px 7px;\n font-size: 11px;\n}\n\n.dh-section-title {\n color: var(--dsh-hooks-muted);\n text-transform: uppercase;\n letter-spacing: .04em;\n margin: 0 0 8px;\n font-size: 12px;\n font-weight: 600;\n}\n\n.dh-section-head {\n justify-content: space-between;\n align-items: center;\n gap: 8px;\n margin: 0 0 8px;\n display: flex;\n}\n\n.dh-section-head .dh-section-title {\n margin: 0;\n}\n\n.dh-toggle {\n padding: 2px 10px;\n font-size: 11px;\n}\n\n.dh-field-narrow {\n flex: 0 0 150px;\n}\n\n.dh-feishu-row {\n align-items: flex-end;\n gap: 8px;\n display: flex;\n}\n\n.dh-timeline {\n flex-direction: column;\n gap: 6px;\n display: flex;\n}\n\n.dh-record {\n border: 1px solid var(--dsh-hooks-border);\n border-radius: 6px;\n gap: 8px;\n padding: 7px 9px;\n display: flex;\n}\n\n.dh-record-main {\n flex: 1;\n min-width: 0;\n}\n\n.dh-record-top {\n align-items: baseline;\n gap: 6px;\n display: flex;\n}\n\n.dh-record-time {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n font-size: 11px;\n}\n\n.dh-record-event {\n white-space: nowrap;\n text-overflow: ellipsis;\n font-weight: 600;\n overflow: hidden;\n}\n\n.dh-record-command {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n text-overflow: ellipsis;\n text-align: left;\n direction: rtl;\n font-size: 12px;\n overflow: hidden;\n}\n\n.dh-outcome {\n white-space: nowrap;\n border-radius: 9px;\n align-self: flex-start;\n padding: 1px 7px;\n font-size: 11px;\n}\n\n.dh-outcome-ok {\n color: var(--dsh-hooks-ok);\n background: #3fb56b29;\n}\n\n.dh-outcome-bad {\n color: var(--dsh-hooks-bad);\n background: #e5534b29;\n}\n\n.dh-outcome-warn {\n color: var(--dsh-hooks-warn);\n background: #d9a13c29;\n}\n\n.dh-outcome-neutral {\n color: var(--dsh-hooks-muted);\n background: #80849029;\n}\n\n.dh-record-error {\n color: var(--dsh-hooks-bad);\n white-space: pre-wrap;\n word-break: break-all;\n margin-top: 4px;\n font-size: 11px;\n}\n\n.dh-empty {\n color: var(--dsh-hooks-muted);\n padding: 6px 2px;\n font-size: 12px;\n}\n\n.dh-test-form {\n flex-direction: column;\n gap: 8px;\n display: flex;\n}\n\n.dh-test-row {\n gap: 8px;\n display: flex;\n}\n\n.dh-field {\n flex-direction: column;\n flex: 1;\n gap: 3px;\n min-width: 0;\n display: flex;\n}\n\n.dh-field-label {\n color: var(--dsh-hooks-muted);\n font-size: 11px;\n}\n\n.dh-input, .dh-select {\n border: 1px solid var(--dsh-hooks-border);\n color: inherit;\n box-sizing: border-box;\n background: #8084901f;\n border-radius: 5px;\n outline: none;\n width: 100%;\n padding: 5px 8px;\n font-size: 12px;\n}\n\n.dh-input:focus, .dh-select:focus {\n border-color: var(--dsh-hooks-accent);\n}\n\n.dh-buttons {\n gap: 8px;\n display: flex;\n}\n\n.dh-button {\n border: 1px solid var(--dsh-hooks-border);\n color: inherit;\n cursor: pointer;\n background: #8084901f;\n border-radius: 5px;\n padding: 5px 12px;\n font-size: 12px;\n}\n\n.dh-button:hover {\n background: #80849038;\n}\n\n.dh-button-primary {\n background: var(--dsh-hooks-accent);\n border-color: var(--dsh-hooks-accent);\n color: #fff;\n}\n\n.dh-button-primary:hover {\n background: #3c7de8;\n}\n\n.dh-test-results {\n flex-direction: column;\n gap: 4px;\n display: flex;\n}\n\n.dh-test-line {\n word-break: break-all;\n border-radius: 5px;\n padding: 4px 8px;\n font-size: 12px;\n}\n\n.dh-test-line-match {\n color: var(--dsh-hooks-ok);\n background: #3fb56b24;\n}\n\n.dh-test-line-skip {\n color: var(--dsh-hooks-muted);\n background: #8084901a;\n}\n\n.dh-error-banner {\n color: var(--dsh-hooks-bad);\n background: #e5534b1f;\n border: 1px solid #e5534b66;\n border-radius: 6px;\n padding: 8px 10px;\n font-size: 12px;\n}\n\n.dh-feishu {\n flex-direction: column;\n gap: 8px;\n display: flex;\n}\n\n.dh-feishu-form, .dh-feishu-status, .dh-feishu-qr {\n flex-direction: column;\n align-items: flex-start;\n gap: 8px;\n display: flex;\n}\n\n.dh-feishu-qr-img {\n border: 1px solid var(--dsh-hooks-border);\n box-sizing: border-box;\n background: #fff;\n border-radius: 8px;\n width: 220px;\n height: 220px;\n padding: 6px;\n}\n\n.dh-feishu-line {\n font-size: 12px;\n}\n\n.dh-feishu-ok {\n color: var(--dsh-hooks-ok);\n}\n\n.dh-feishu-error {\n color: var(--dsh-hooks-bad);\n word-break: break-all;\n font-size: 12px;\n}\n\n.dh-feishu-hint {\n color: var(--dsh-hooks-muted);\n font-size: 11px;\n}\n\n.dh-feishu-link {\n color: var(--dsh-hooks-accent);\n font-size: 12px;\n}\n\n.dh-badge-bad {\n color: var(--dsh-hooks-bad);\n background: #e5534b29;\n}\n\n.dh-error-retry {\n flex-shrink: 0;\n margin-left: 8px;\n padding: 2px 10px;\n font-size: 11px;\n}\n\n.dh-check {\n color: var(--dsh-hooks-muted);\n align-items: center;\n gap: 6px;\n font-size: 12px;\n display: flex;\n}\n\n.dh-button-small {\n padding: 2px 8px;\n font-size: 11px;\n}\n\n.dh-button-danger {\n color: var(--dsh-hooks-bad);\n border-color: #e5534b80;\n}\n\n.dh-button-danger:hover {\n background: #e5534b24;\n}\n\n.dh-hook-list, .dh-hook-editor {\n flex-direction: column;\n gap: 6px;\n display: flex;\n}\n\n.dh-hook {\n border: 1px solid var(--dsh-hooks-border);\n border-radius: 6px;\n flex-direction: column;\n gap: 4px;\n padding: 7px 9px;\n display: flex;\n}\n\n.dh-hook-editing {\n border-color: #4d8df766;\n gap: 8px;\n padding: 9px;\n}\n\n.dh-hook-head {\n align-items: baseline;\n gap: 8px;\n min-width: 0;\n display: flex;\n}\n\n.dh-hook-event {\n white-space: nowrap;\n font-size: 12px;\n font-weight: 600;\n}\n\n.dh-hook-action {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n text-overflow: ellipsis;\n text-align: left;\n direction: rtl;\n flex: 1;\n min-width: 0;\n font-size: 12px;\n overflow: hidden;\n}\n\n.dh-hook-match {\n color: var(--dsh-hooks-accent);\n word-break: break-all;\n font-size: 11px;\n}\n\n.dh-hook-meta {\n align-items: center;\n gap: 8px;\n display: flex;\n}\n\n.dh-match-key {\n flex: 0 0 130px;\n}\n\n.dh-feishu-preview {\n color: var(--dsh-hooks-muted);\n border-left: 2px solid var(--dsh-hooks-border);\n word-break: break-all;\n max-height: 120px;\n padding: 4px 8px;\n font-size: 11px;\n overflow: hidden;\n}\n";
318
1340
  //#endregion
319
1341
  //#region src/client/index.ts
320
1342
  const name = "dsh-hooks";