dsh-taskboard 0.3.3 → 0.4.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 (42) hide show
  1. package/README.md +22 -6
  2. package/lib/client.js +1192 -39
  3. package/lib/host/execution.js +6 -1
  4. package/lib/host/execution.js.map +1 -1
  5. package/lib/host/git.js +95 -2
  6. package/lib/host/git.js.map +1 -1
  7. package/lib/host/protocol-text.js +5 -3
  8. package/lib/host/protocol-text.js.map +1 -1
  9. package/lib/host/routes.js +184 -2
  10. package/lib/host/routes.js.map +1 -1
  11. package/lib/host/store.js +12 -0
  12. package/lib/host/store.js.map +1 -1
  13. package/lib/host/templates.js +166 -0
  14. package/lib/host/templates.js.map +1 -0
  15. package/lib/host/tools.js +202 -2
  16. package/lib/host/tools.js.map +1 -1
  17. package/lib/index.js +7 -2
  18. package/lib/index.js.map +1 -1
  19. package/lib/shared/api.js.map +1 -1
  20. package/lib/shared/protocol.js +277 -1
  21. package/lib/shared/protocol.js.map +1 -1
  22. package/package.json +1 -1
  23. package/src/client/api.ts +28 -0
  24. package/src/client/board/ImportModal.tsx +182 -0
  25. package/src/client/board/TaskBoard.tsx +45 -3
  26. package/src/client/board/TaskCard.tsx +9 -0
  27. package/src/client/board/TaskDetail.tsx +192 -8
  28. package/src/client/board/TaskFormModal.tsx +100 -18
  29. package/src/client/board/TemplateManager.tsx +121 -0
  30. package/src/client/controller.ts +152 -8
  31. package/src/client/styles.ts +153 -0
  32. package/src/host/execution.ts +10 -2
  33. package/src/host/git.ts +77 -0
  34. package/src/host/protocol-text.ts +5 -3
  35. package/src/host/routes.ts +215 -0
  36. package/src/host/store.ts +13 -0
  37. package/src/host/templates.ts +143 -0
  38. package/src/host/tools.ts +198 -2
  39. package/src/index.ts +6 -0
  40. package/src/shared/api.ts +54 -0
  41. package/src/shared/protocol.ts +344 -0
  42. package/src/shared/version.ts +1 -1
package/lib/client.js CHANGED
@@ -46,6 +46,20 @@ window.__ModuleLoader__.load({
46
46
  workspaceId,
47
47
  taskId
48
48
  }),
49
+ diff: (taskId, query) => {
50
+ const params = new URLSearchParams({ execution: query.execution });
51
+ if (query.commit !== void 0) params.set("commit", query.commit);
52
+ if (query.path !== void 0) params.set("path", query.path);
53
+ return unwrap(fetch(`/dsh-taskboard/tasks/${encodeURIComponent(taskId)}/diff?${params.toString()}`));
54
+ },
55
+ importPreview: (file) => post("/dsh-taskboard/import/preview", file),
56
+ importCommit: (mode, ledger) => post("/dsh-taskboard/import", {
57
+ mode,
58
+ ledger
59
+ }),
60
+ templates: () => unwrap(fetch("/dsh-taskboard/templates")),
61
+ templateUpsert: (body) => post("/dsh-taskboard/templates", body),
62
+ templateDelete: (id) => post("/dsh-taskboard/templates/delete", { id }),
49
63
  stream(onChange, onGap) {
50
64
  const es = new EventSource("/dsh-taskboard/events");
51
65
  let revision;
@@ -211,6 +225,14 @@ window.__ModuleLoader__.load({
211
225
  tasks: []
212
226
  };
213
227
  }
228
+ /** Checklist progress: how many items are checked (absent checklist → 0/0). */
229
+ function checklistProgress(task) {
230
+ const items = task.checklist ?? [];
231
+ return {
232
+ done: items.filter((i) => i.checked).length,
233
+ total: items.length
234
+ };
235
+ }
214
236
 
215
237
  //#endregion
216
238
  //#region src/client/controller.ts
@@ -269,7 +291,10 @@ window.__ModuleLoader__.load({
269
291
  sortBy: view.sortBy,
270
292
  composerOpen: false,
271
293
  secondaryOpen: false,
272
- diagOpen: false
294
+ diagOpen: false,
295
+ templates: [],
296
+ tplManagerOpen: false,
297
+ importOpen: false
273
298
  };
274
299
  }
275
300
  /**
@@ -402,25 +427,36 @@ window.__ModuleLoader__.load({
402
427
  select(id) {
403
428
  this.setState({ selectedId: id });
404
429
  }
405
- /** Show/hide the task form (create mode when opening). */
430
+ /** Show/hide the task form (create mode when opening); always blank (no template prefill). */
406
431
  setComposer(open) {
407
432
  this.setState({
408
433
  composerOpen: open,
409
- editingId: void 0
434
+ editingId: void 0,
435
+ templatePrefill: void 0
436
+ });
437
+ }
438
+ /** Open the create form prefilled from a chosen template (0.4.0). */
439
+ newFromTemplate(spec) {
440
+ this.setState({
441
+ composerOpen: true,
442
+ editingId: void 0,
443
+ templatePrefill: spec
410
444
  });
411
445
  }
412
- /** Open the form modal editing an existing task. */
446
+ /** Open the form modal editing an existing task (clears any template prefill). */
413
447
  openEditor(id) {
414
448
  this.setState({
415
449
  composerOpen: true,
416
- editingId: id
450
+ editingId: id,
451
+ templatePrefill: void 0
417
452
  });
418
453
  }
419
454
  /** Close the form modal whatever its mode. */
420
455
  closeForm() {
421
456
  this.setState({
422
457
  composerOpen: false,
423
- editingId: void 0
458
+ editingId: void 0,
459
+ templatePrefill: void 0
424
460
  });
425
461
  }
426
462
  /** Toggle the secondary tab. */
@@ -537,6 +573,42 @@ window.__ModuleLoader__.load({
537
573
  this.setState({ error: error instanceof Error ? error.message : String(error) });
538
574
  }
539
575
  }
576
+ /**
577
+ * Toggle one checklist item as the USER (0.4.0): flips the item, records
578
+ * `checkedBy: 'user'`, keeps other items as they are (one update call).
579
+ */
580
+ async toggleChecklistItem(task, itemId) {
581
+ const items = (task.checklist ?? []).map((item) => item.id === itemId ? item.checked ? {
582
+ id: item.id,
583
+ text: item.text,
584
+ checked: false
585
+ } : {
586
+ id: item.id,
587
+ text: item.text,
588
+ checked: true,
589
+ checkedBy: "user",
590
+ checkedAt: Date.now(),
591
+ ...item.note !== void 0 ? { note: item.note } : {}
592
+ } : item);
593
+ try {
594
+ await this.client.update(task.id, {
595
+ ifVersion: task.version,
596
+ checklist: items
597
+ });
598
+ await this.refresh();
599
+ } catch (error) {
600
+ this.setState({ error: error instanceof Error ? error.message : String(error) });
601
+ }
602
+ }
603
+ /** Diff view (0.4.0): one execution's commit or changed path; errors surface via throw. */
604
+ async fetchDiff(taskId, query) {
605
+ try {
606
+ return await this.client.diff(taskId, query);
607
+ } catch (error) {
608
+ this.setState({ error: error instanceof Error ? error.message : String(error) });
609
+ return;
610
+ }
611
+ }
540
612
  /** Append a user comment. */
541
613
  async comment(id, body) {
542
614
  try {
@@ -634,7 +706,7 @@ window.__ModuleLoader__.load({
634
706
  this.setState({ error: error instanceof Error ? error.message : String(error) });
635
707
  }
636
708
  }
637
- /** Duplicate a task into a fresh todo card (same project/urgency/prompt/execution/model/isolation). */
709
+ /** Duplicate a task into a fresh todo card (same project/urgency/prompt/execution/model/isolation/checklist). */
638
710
  async duplicate(task) {
639
711
  try {
640
712
  await this.client.create({
@@ -649,13 +721,109 @@ window.__ModuleLoader__.load({
649
721
  } : { mode: "claim" },
650
722
  model: task.model,
651
723
  isolation: task.isolation,
652
- ...task.presetId !== void 0 ? { presetId: task.presetId } : {}
724
+ ...task.presetId !== void 0 ? { presetId: task.presetId } : {},
725
+ ...task.checklist !== void 0 && task.checklist.length > 0 ? { checklist: task.checklist.map((i) => i.text) } : {}
653
726
  });
654
727
  await this.refresh();
655
728
  } catch (error) {
656
729
  this.setState({ error: error instanceof Error ? error.message : String(error) });
657
730
  }
658
731
  }
732
+ /** Load the template list (best effort; errors surface). */
733
+ async loadTemplates() {
734
+ try {
735
+ const value = await this.client.templates();
736
+ this.setState({
737
+ templates: value.templates,
738
+ error: void 0
739
+ });
740
+ return value.templates;
741
+ } catch (error) {
742
+ this.setState({ error: error instanceof Error ? error.message : String(error) });
743
+ return [];
744
+ }
745
+ }
746
+ /** Open the + 新建任务 dropdown's template list fresh (called on menu open). */
747
+ prepareTemplateMenu() {
748
+ if (this.state.templates.length === 0) this.loadTemplates();
749
+ }
750
+ /** Open the template manager modal. */
751
+ openTemplateManager() {
752
+ this.setState({ tplManagerOpen: true });
753
+ this.loadTemplates();
754
+ }
755
+ /** Close the template manager modal. */
756
+ closeTemplateManager() {
757
+ this.setState({ tplManagerOpen: false });
758
+ }
759
+ /** Create or replace a template; refreshes the list. */
760
+ async upsertTemplate(body) {
761
+ try {
762
+ await this.client.templateUpsert(body);
763
+ await this.loadTemplates();
764
+ return true;
765
+ } catch (error) {
766
+ this.setState({ error: error instanceof Error ? error.message : String(error) });
767
+ return false;
768
+ }
769
+ }
770
+ /** Delete a template by id; refreshes the list. */
771
+ async deleteTemplate(id) {
772
+ try {
773
+ await this.client.templateDelete(id);
774
+ await this.loadTemplates();
775
+ } catch (error) {
776
+ this.setState({ error: error instanceof Error ? error.message : String(error) });
777
+ }
778
+ }
779
+ /** 存为模板 from a task card: carries every configurable field incl. checklist texts. */
780
+ async saveAsTemplate(task) {
781
+ return this.upsertTemplate({
782
+ name: task.title.slice(0, 60),
783
+ task: {
784
+ title: task.title,
785
+ description: task.description.length > 0 ? task.description : void 0,
786
+ prompt: task.prompt.length > 0 ? task.prompt : void 0,
787
+ urgency: task.urgency,
788
+ execution: task.execution.mode === "scheduled" && task.execution.cron !== void 0 ? {
789
+ mode: "scheduled",
790
+ cron: task.execution.cron
791
+ } : { mode: "claim" },
792
+ model: task.model,
793
+ isolation: task.isolation,
794
+ ...task.presetId !== void 0 ? { presetId: task.presetId } : {},
795
+ ...task.checklist !== void 0 && task.checklist.length > 0 ? { checklist: task.checklist.map((i) => i.text) } : {}
796
+ }
797
+ });
798
+ }
799
+ /** Open the import modal. */
800
+ openImport() {
801
+ this.setState({ importOpen: true });
802
+ }
803
+ /** Close the import modal. */
804
+ closeImport() {
805
+ this.setState({ importOpen: false });
806
+ }
807
+ /** Dry-run an import file: classify its tasks against the live ledger. */
808
+ async importPreview(file) {
809
+ try {
810
+ return (await this.client.importPreview(file)).plan;
811
+ } catch (error) {
812
+ this.setState({ error: error instanceof Error ? error.message : String(error) });
813
+ return;
814
+ }
815
+ }
816
+ /** Commit an import; refreshes the ledger afterwards. */
817
+ async importCommit(mode, ledger) {
818
+ try {
819
+ const value = await this.client.importCommit(mode, ledger);
820
+ await this.refresh();
821
+ return value;
822
+ } catch (error) {
823
+ this.setState({ error: error instanceof Error ? error.message : String(error) });
824
+ return;
825
+ }
826
+ }
659
827
  /** Download the whole ledger as a JSON backup file. */
660
828
  exportJson() {
661
829
  const stamp = /* @__PURE__ */ new Date();
@@ -1241,6 +1409,159 @@ window.__ModuleLoader__.load({
1241
1409
  border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25));
1242
1410
  }
1243
1411
  .dsh-atb-diag-orphan-path { font-size: 11.5px; font-family: ui-monospace, Consolas, monospace; word-break: break-all; }
1412
+
1413
+ /* ---------- 0.4.0 checklist ---------- */
1414
+ .dsh-atb-cke { display: flex; flex-direction: column; gap: 6px; }
1415
+ .dsh-atb-cke-row { display: flex; align-items: center; gap: 8px; }
1416
+ .dsh-atb-cke-box { flex-shrink: 0; width: 15px; height: 15px; cursor: pointer; }
1417
+ .dsh-atb-cke-text {
1418
+ flex: 1; min-width: 0; font-size: 12.5px; padding: 6px 9px;
1419
+ border-radius: 8px; border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.3));
1420
+ background: var(--dsw-alias-bg-layer-1, transparent); color: inherit;
1421
+ }
1422
+ .dsh-atb-cke-del {
1423
+ flex-shrink: 0; border: none; background: none; cursor: pointer; padding: 4px;
1424
+ color: var(--dsw-alias-label-tertiary, gray); font-size: 12px; border-radius: 6px;
1425
+ }
1426
+ .dsh-atb-cke-del:hover { color: var(--dsw-alias-state-error-primary, #e5484d); background: rgba(229,72,77,.08); }
1427
+ .dsh-atb-cke-add {
1428
+ align-self: flex-start; border: 1px dashed var(--dsw-alias-border-l2, rgba(128,128,128,.4));
1429
+ background: none; color: var(--dsw-alias-label-secondary, inherit); cursor: pointer;
1430
+ font-size: 11.5px; padding: 5px 12px; border-radius: 8px;
1431
+ }
1432
+ .dsh-atb-cke-add:hover { color: var(--dsw-alias-state-business-primary, #3e63dd); border-color: var(--dsw-alias-state-business-primary, #3e63dd); }
1433
+ .dsh-atb-cke-hint { font-size: 10.5px; color: var(--dsw-alias-label-tertiary, gray); }
1434
+
1435
+ .dsh-atb-cl-progress { margin-left: 8px; font-size: 11px; font-weight: 400; color: var(--dsw-alias-label-tertiary, gray); }
1436
+ .dsh-atb-cl-progress[data-tone="bad"] { color: var(--dsw-alias-state-error-primary, #e5484d); font-weight: 600; }
1437
+ .dsh-atb-cl-items { display: flex; flex-direction: column; gap: 5px; }
1438
+ .dsh-atb-cl-item {
1439
+ display: flex; align-items: baseline; gap: 9px; padding: 6px 9px; border-radius: 8px;
1440
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.22));
1441
+ background: var(--dsw-alias-bg-layer-1, rgba(128,128,128,.03)); cursor: pointer;
1442
+ }
1443
+ .dsh-atb-cl-item:hover { border-color: var(--dsw-alias-border-l2, rgba(128,128,128,.4)); }
1444
+ .dsh-atb-cl-item input { flex-shrink: 0; transform: translateY(1px); cursor: pointer; }
1445
+ .dsh-atb-cl-item[data-checked="true"] .dsh-atb-cl-text { text-decoration: line-through; color: var(--dsw-alias-label-tertiary, gray); }
1446
+ .dsh-atb-cl-item[data-alert="true"] {
1447
+ border-color: rgba(229,72,77,.45); background: rgba(229,72,77,.06);
1448
+ }
1449
+ .dsh-atb-cl-text { flex: 1; min-width: 0; font-size: 12.5px; word-break: break-word; }
1450
+ .dsh-atb-cl-meta { flex-shrink: 0; font-size: 10.5px; color: var(--dsw-alias-label-tertiary, gray); display: flex; flex-direction: column; gap: 2px; align-items: flex-end; }
1451
+ .dsh-atb-cl-note { max-width: 280px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--dsw-alias-label-secondary, inherit); }
1452
+
1453
+ /* ---------- 0.4.0 report ---------- */
1454
+ .dsh-atb-rpt-summary { font-size: 12.5px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; margin-bottom: 8px; }
1455
+ .dsh-atb-rpt-sec { margin-bottom: 8px; }
1456
+ .dsh-atb-rpt-label { font-size: 11px; color: var(--dsw-alias-label-tertiary, gray); margin-bottom: 4px; }
1457
+ .dsh-atb-rpt-list { margin: 0; padding-left: 18px; font-size: 12px; line-height: 1.55; word-break: break-all; }
1458
+ .dsh-atb-rpt-risk {
1459
+ font-size: 12px; line-height: 1.55; white-space: pre-wrap; word-break: break-word;
1460
+ color: var(--dsw-alias-state-warn-primary, #f5a524);
1461
+ background: rgba(245,165,36,.08); border: 1px solid rgba(245,165,36,.3);
1462
+ border-radius: 8px; padding: 6px 10px;
1463
+ }
1464
+
1465
+ /* ---------- 0.4.0 diff viewer ---------- */
1466
+ .dsh-atb-iso-commit { display: flex; flex-direction: column; gap: 3px; }
1467
+ .dsh-atb-iso-commit-btn {
1468
+ display: flex; gap: 8px; font-size: 11.5px; align-items: baseline; text-align: left;
1469
+ border: none; background: none; padding: 2px 4px; margin: 0 -4px; border-radius: 6px; cursor: pointer;
1470
+ color: inherit; width: fit-content; max-width: 100%;
1471
+ }
1472
+ .dsh-atb-iso-commit-btn:hover { background: var(--dsw-alias-bg-layer-2, rgba(128,128,128,.08)); }
1473
+ .dsh-atb-iso-commit-btn code {
1474
+ font-family: ui-monospace, Consolas, monospace; font-size: 10.5px;
1475
+ color: var(--dsw-alias-state-business-primary, #3e63dd); flex-shrink: 0;
1476
+ }
1477
+ .dsh-atb-iso-commit-btn span { word-break: break-all; color: var(--dsw-alias-label-secondary, inherit); }
1478
+ .dsh-atb-iso-commit[data-open="true"] > .dsh-atb-iso-commit-btn code { font-weight: 700; }
1479
+ .dsh-atb-iso-dirty { display: flex; flex-direction: column; gap: 6px;
1480
+ font-size: 11.5px; color: var(--dsw-alias-state-error-primary, #e5484d);
1481
+ background: rgba(229,72,77,.09); border: 1px solid rgba(229,72,77,.35);
1482
+ border-radius: 8px; padding: 6px 10px; margin-bottom: 8px;
1483
+ }
1484
+ .dsh-atb-iso-dirty-toggle { border: none; background: none; cursor: pointer; padding: 0; text-align: left; color: inherit; font-size: inherit; }
1485
+ .dsh-atb-iso-dirty-files { display: flex; flex-direction: column; gap: 2px; }
1486
+ .dsh-atb-iso-dirty-file {
1487
+ border: none; background: none; cursor: pointer; text-align: left; padding: 1px 2px;
1488
+ font-size: 11px; color: var(--dsw-alias-label-secondary, inherit); border-radius: 4px; word-break: break-all;
1489
+ }
1490
+ .dsh-atb-iso-dirty-file:hover { background: rgba(128,128,128,.1); color: var(--dsw-alias-state-business-primary, #3e63dd); }
1491
+ .dsh-atb-iso-dirty-file code { font-family: ui-monospace, Consolas, monospace; font-size: 10px; margin-right: 6px; }
1492
+ .dsh-atb-diffview { margin-top: 6px; border-radius: 8px; overflow: hidden;
1493
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25)); }
1494
+ .dsh-atb-diffview-head { display: flex; align-items: center; gap: 10px; padding: 5px 10px;
1495
+ background: var(--dsw-alias-bg-layer-2, rgba(128,128,128,.08)); }
1496
+ .dsh-atb-diffview-title { font-family: ui-monospace, Consolas, monospace; font-size: 10.5px;
1497
+ color: var(--dsw-alias-state-business-primary, #3e63dd); word-break: break-all; }
1498
+ .dsh-atb-diffview-hint { font-size: 10.5px; color: var(--dsw-alias-label-tertiary, gray); }
1499
+ .dsh-atb-diffview-error { padding: 8px 10px; font-size: 11.5px; color: var(--dsw-alias-state-error-primary, #e5484d); }
1500
+ .dsh-atb-diffview-pre {
1501
+ margin: 0; padding: 8px 10px; max-height: 340px; overflow: auto;
1502
+ font-family: ui-monospace, Consolas, monospace; font-size: 10.5px; line-height: 1.5;
1503
+ white-space: pre; color: var(--dsw-alias-label-secondary, inherit);
1504
+ }
1505
+
1506
+ /* ---------- 0.4.0 new-task menu + template manager + import ---------- */
1507
+ .dsh-atb-newmenu { position: relative; display: inline-flex; }
1508
+ .dsh-atb-newmenu-backdrop { position: fixed; inset: 0; z-index: 40; }
1509
+ .dsh-atb-newmenu-list {
1510
+ position: absolute; top: calc(100% + 4px); left: 0; z-index: 41; min-width: 180px;
1511
+ display: flex; flex-direction: column; padding: 5px; border-radius: 10px;
1512
+ background: var(--dsw-alias-bg-overlay, #fff); color: var(--dsw-alias-label-primary, inherit);
1513
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25));
1514
+ box-shadow: var(--dsw-shadow-lv3, 0 12px 32px rgba(0,0,0,.18));
1515
+ }
1516
+ .dsh-atb-newmenu-opt {
1517
+ border: none; background: none; text-align: left; cursor: pointer; padding: 7px 10px;
1518
+ font-size: 12.5px; color: inherit; border-radius: 7px; white-space: nowrap;
1519
+ }
1520
+ .dsh-atb-newmenu-opt:hover { background: var(--dsw-alias-bg-layer-2, rgba(128,128,128,.1)); }
1521
+ .dsh-atb-newmenu-sep { height: 1px; margin: 4px 6px; background: var(--dsw-alias-border-l2, rgba(128,128,128,.25)); }
1522
+
1523
+ .dsh-atb-tplm { max-width: 560px; width: min(560px, 92vw); }
1524
+ .dsh-atb-tplm-list { display: flex; flex-direction: column; gap: 8px; }
1525
+ .dsh-atb-tplm-row {
1526
+ display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px;
1527
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25));
1528
+ }
1529
+ .dsh-atb-tplm-name {
1530
+ flex: 0 0 160px; font-size: 12.5px; padding: 5px 8px; border-radius: 7px;
1531
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.3));
1532
+ background: var(--dsw-alias-bg-layer-1, transparent); color: inherit;
1533
+ }
1534
+ .dsh-atb-tplm-meta { flex: 1; min-width: 0; font-size: 10.5px; color: var(--dsw-alias-label-tertiary, gray); }
1535
+ .dsh-atb-tplm-btns { display: flex; gap: 6px; flex-shrink: 0; }
1536
+
1537
+ .dsh-atb-imp { max-width: 600px; width: min(600px, 92vw); }
1538
+ .dsh-atb-imp-picker { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
1539
+ .dsh-atb-imp-picker input[type="file"] { font-size: 12px; }
1540
+ .dsh-atb-imp-filename { font-size: 11.5px; color: var(--dsw-alias-state-business-primary, #3e63dd); word-break: break-all; }
1541
+ .dsh-atb-imp-note { font-size: 11px; color: var(--dsw-alias-label-tertiary, gray); margin-bottom: 10px; }
1542
+ .dsh-atb-imp-error { font-size: 12px; color: var(--dsw-alias-state-error-primary, #e5484d); margin-bottom: 8px; }
1543
+ .dsh-atb-imp-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 12px; }
1544
+ .dsh-atb-imp-stat {
1545
+ display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 9px 6px; border-radius: 9px;
1546
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25));
1547
+ }
1548
+ .dsh-atb-imp-stat b { font-size: 17px; font-weight: 700; }
1549
+ .dsh-atb-imp-stat span { font-size: 10.5px; color: var(--dsw-alias-label-tertiary, gray); }
1550
+ .dsh-atb-imp-stat[data-tone="ok"] b { color: var(--dsw-alias-state-success-primary, #30a46c); }
1551
+ .dsh-atb-imp-stat[data-tone="warn"] b { color: var(--dsw-alias-state-warn-primary, #f5a524); }
1552
+ .dsh-atb-imp-stat[data-tone="bad"] b { color: var(--dsw-alias-state-error-primary, #e5484d); }
1553
+ .dsh-atb-imp-sec h4 { margin: 0 0 6px; font-size: 12px; }
1554
+ .dsh-atb-imp-sec { margin-bottom: 10px; }
1555
+ .dsh-atb-imp-list { display: flex; flex-direction: column; gap: 4px; max-height: 160px; overflow-y: auto; }
1556
+ .dsh-atb-imp-row {
1557
+ display: flex; align-items: center; gap: 10px; justify-content: space-between;
1558
+ padding: 5px 9px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.2));
1559
+ }
1560
+ .dsh-atb-imp-row[data-tone="bad"] { border-color: rgba(229,72,77,.35); }
1561
+ .dsh-atb-imp-row-title { font-size: 12px; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1562
+ .dsh-atb-imp-row-status { font-size: 10.5px; color: var(--dsw-alias-label-tertiary, gray); flex-shrink: 0; }
1563
+ .dsh-atb-imp-result { font-size: 12px; color: var(--dsw-alias-state-success-primary, #30a46c); margin-top: 10px; }
1564
+ .dsh-atb-badge[data-kind="checklist"] { color: var(--dsw-alias-label-secondary, inherit); }
1244
1565
  `;
1245
1566
  let injected = false;
1246
1567
  /** Inject the stylesheet once (idempotent). */
@@ -1485,7 +1806,7 @@ window.__ModuleLoader__.load({
1485
1806
  * @module dsh-taskboard/shared/version
1486
1807
  */
1487
1808
  /** The package version (must equal package.json "version"). */
1488
- const PLUGIN_VERSION = "0.3.3";
1809
+ const PLUGIN_VERSION = "0.4.0";
1489
1810
 
1490
1811
  //#endregion
1491
1812
  //#region src/client/board/TaskCard.tsx
@@ -1599,6 +1920,17 @@ window.__ModuleLoader__.load({
1599
1920
  className: "dsh-atb-badge",
1600
1921
  children: task.model.model
1601
1922
  }),
1923
+ task.checklist !== void 0 && task.checklist.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1924
+ className: "dsh-atb-badge",
1925
+ "data-kind": task.status === "in_review" && task.checklist.some((i) => !i.checked) ? "blocked" : "checklist",
1926
+ title: task.status === "in_review" && task.checklist.some((i) => !i.checked) ? "待验收:清单未全部勾选" : "验收清单进度",
1927
+ children: [
1928
+ "☑ ",
1929
+ task.checklist.filter((i) => i.checked).length,
1930
+ "/",
1931
+ task.checklist.length
1932
+ ]
1933
+ }),
1602
1934
  task.status === "done" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1603
1935
  className: "dsh-atb-badge",
1604
1936
  "data-kind": "done",
@@ -1813,6 +2145,179 @@ window.__ModuleLoader__.load({
1813
2145
  function shortHash(hash) {
1814
2146
  return hash === void 0 ? "" : hash.slice(0, 8);
1815
2147
  }
2148
+ /** Extract the path from one `git status --porcelain` line (rename-aware). */
2149
+ function porcelainPath(line) {
2150
+ let p = line.slice(3);
2151
+ const arrow = p.indexOf(" -> ");
2152
+ if (arrow >= 0) p = p.slice(arrow + 4);
2153
+ if (p.startsWith("\"") && p.endsWith("\"") && p.length > 1) p = p.slice(1, -1);
2154
+ return p;
2155
+ }
2156
+ /**
2157
+ * Lazy diff viewer (0.4.0): loads on mount, renders inside a capped <pre>.
2158
+ * @param spec - what to show: one commit hash, or one changed path.
2159
+ */
2160
+ function DiffView({ controller, task, execution, commit, path }) {
2161
+ const [state, setState] = (0, react.useState)({ loading: true });
2162
+ (0, react.useEffect)(() => {
2163
+ let alive = true;
2164
+ setState({ loading: true });
2165
+ controller.fetchDiff(task.id, {
2166
+ execution: execution.id,
2167
+ ...commit !== void 0 ? { commit } : { path: path ?? "" }
2168
+ }).then((result) => {
2169
+ if (!alive) return;
2170
+ if (result === void 0) setState({
2171
+ loading: false,
2172
+ failed: true
2173
+ });
2174
+ else setState({
2175
+ loading: false,
2176
+ diff: result.diff,
2177
+ truncated: result.truncated
2178
+ });
2179
+ });
2180
+ return () => {
2181
+ alive = false;
2182
+ };
2183
+ }, [
2184
+ controller,
2185
+ task.id,
2186
+ execution.id,
2187
+ commit,
2188
+ path
2189
+ ]);
2190
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2191
+ className: "dsh-atb-diffview",
2192
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2193
+ className: "dsh-atb-diffview-head",
2194
+ children: [
2195
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2196
+ className: "dsh-atb-diffview-title",
2197
+ children: commit !== void 0 ? `提交 ${shortHash(commit)}` : `文件 ${path}`
2198
+ }),
2199
+ state.loading && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2200
+ className: "dsh-atb-diffview-hint",
2201
+ children: "读取中…"
2202
+ }),
2203
+ state.truncated === true && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2204
+ className: "dsh-atb-diffview-hint",
2205
+ children: "⚠ 内容过长已截断"
2206
+ })
2207
+ ]
2208
+ }), state.failed === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2209
+ className: "dsh-atb-diffview-error",
2210
+ children: "获取失败(原因见看板顶部错误条;对象可能已随 worktree 删除丢失)"
2211
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
2212
+ className: "dsh-atb-diffview-pre",
2213
+ children: state.diff ?? ""
2214
+ })]
2215
+ });
2216
+ }
2217
+ /**
2218
+ * The DoD checklist block (0.4.0): user-togglable items, checker + evidence
2219
+ * per row; unchecked items highlight while the task sits in in_review.
2220
+ */
2221
+ function ChecklistBlock({ task, controller }) {
2222
+ const items = task.checklist ?? [];
2223
+ if (items.length === 0) return null;
2224
+ const { done, total } = checklistProgress(task);
2225
+ const unchecked = total - done;
2226
+ const reviewing = task.status === "in_review";
2227
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2228
+ className: "dsh-atb-fieldcard",
2229
+ "data-kind": "checklist",
2230
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2231
+ className: "dsh-atb-fieldcard-label",
2232
+ children: ["验收清单(DoD)", /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2233
+ className: "dsh-atb-cl-progress",
2234
+ "data-tone": reviewing && unchecked > 0 ? "bad" : void 0,
2235
+ children: [
2236
+ "☑ ",
2237
+ done,
2238
+ "/",
2239
+ total,
2240
+ reviewing && unchecked > 0 ? ` · ${unchecked} 项未完成` : done === total ? " · 全部完成" : ""
2241
+ ]
2242
+ })]
2243
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2244
+ className: "dsh-atb-cl-items",
2245
+ children: items.map((item) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2246
+ className: "dsh-atb-cl-item",
2247
+ "data-checked": item.checked ? "true" : void 0,
2248
+ "data-alert": reviewing && !item.checked ? "true" : void 0,
2249
+ children: [
2250
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2251
+ type: "checkbox",
2252
+ checked: item.checked,
2253
+ onChange: () => void controller.toggleChecklistItem(task, item.id)
2254
+ }),
2255
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2256
+ className: "dsh-atb-cl-text",
2257
+ children: item.text
2258
+ }),
2259
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2260
+ className: "dsh-atb-cl-meta",
2261
+ children: [item.checked ? `${item.checkedBy === "user" ? "👤 用户" : `🤖 ${shortId(item.checkedBy)}`} · ${fmtTime(item.checkedAt)}` : "未完成", item.note !== void 0 && item.note.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2262
+ className: "dsh-atb-cl-note",
2263
+ title: item.note,
2264
+ children: ["证据:", item.note]
2265
+ })]
2266
+ })
2267
+ ]
2268
+ }, item.id))
2269
+ })]
2270
+ });
2271
+ }
2272
+ /**
2273
+ * The structured execution report block (0.4.0): the newest execution that
2274
+ * carries one, rendered section by section for the reviewer.
2275
+ */
2276
+ function ReportBlock({ task }) {
2277
+ const execution = [...task.executions].reverse().find((e) => e.report !== void 0);
2278
+ const report = execution?.report;
2279
+ if (execution === void 0 || report === void 0) return null;
2280
+ const section = (label, rows) => rows !== void 0 && rows.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2281
+ className: "dsh-atb-rpt-sec",
2282
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2283
+ className: "dsh-atb-rpt-label",
2284
+ children: label
2285
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
2286
+ className: "dsh-atb-rpt-list",
2287
+ children: rows.map((row, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: row }, i))
2288
+ })]
2289
+ }) : null;
2290
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2291
+ className: "dsh-atb-fieldcard",
2292
+ "data-kind": "report",
2293
+ children: [
2294
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2295
+ className: "dsh-atb-fieldcard-label",
2296
+ children: ["执行报告", /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2297
+ className: "dsh-atb-cl-progress",
2298
+ children: ["由执行会话提交 · ", fmtTime(execution.endedAt ?? execution.startedAt)]
2299
+ })]
2300
+ }),
2301
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2302
+ className: "dsh-atb-rpt-summary",
2303
+ children: report.summary
2304
+ }),
2305
+ section("改动文件", report.changedFiles),
2306
+ section("自验情况", report.checks),
2307
+ section("产物", report.artifacts),
2308
+ report.risk.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2309
+ className: "dsh-atb-rpt-sec",
2310
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2311
+ className: "dsh-atb-rpt-label",
2312
+ children: "剩余风险"
2313
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2314
+ className: "dsh-atb-rpt-risk",
2315
+ children: report.risk
2316
+ })]
2317
+ })
2318
+ ]
2319
+ });
2320
+ }
1816
2321
  /**
1817
2322
  * The 0.3.0 isolation block: branch / baseline→head commits / change stats /
1818
2323
  * uncommitted-changes warning, plus the user-only git actions (merge /
@@ -1823,6 +2328,8 @@ window.__ModuleLoader__.load({
1823
2328
  const [confirmMerge, setConfirmMerge] = (0, react.useState)(false);
1824
2329
  const [confirmRemove, setConfirmRemove] = (0, react.useState)(null);
1825
2330
  const [busy, setBusy] = (0, react.useState)(false);
2331
+ const [openDiff, setOpenDiff] = (0, react.useState)(null);
2332
+ const [dirtyOpen, setDirtyOpen] = (0, react.useState)(false);
1826
2333
  const execution = latestIsolated(task);
1827
2334
  const running = task.executions.some((e) => e.outcome === "running");
1828
2335
  if (execution === void 0) return null;
@@ -1907,7 +2414,19 @@ window.__ModuleLoader__.load({
1907
2414
  className: "dsh-atb-iso-commits",
1908
2415
  children: [commits.slice(0, 10).map((c) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1909
2416
  className: "dsh-atb-iso-commit",
1910
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: shortHash(c.hash) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: c.subject })]
2417
+ "data-open": openDiff?.commit === c.hash ? "true" : void 0,
2418
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2419
+ type: "button",
2420
+ className: "dsh-atb-iso-commit-btn",
2421
+ title: "点击展开该提交的 diff",
2422
+ onClick: () => setOpenDiff(openDiff?.commit === c.hash ? null : { commit: c.hash }),
2423
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: shortHash(c.hash) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: c.subject })]
2424
+ }), openDiff?.commit === c.hash && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DiffView, {
2425
+ controller,
2426
+ task,
2427
+ execution,
2428
+ commit: c.hash
2429
+ })]
1911
2430
  }, c.hash)), commitTotal > 10 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1912
2431
  className: "dsh-atb-iso-more",
1913
2432
  children: [
@@ -1922,11 +2441,48 @@ window.__ModuleLoader__.load({
1922
2441
  }),
1923
2442
  dirtyTotal > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1924
2443
  className: "dsh-atb-iso-dirty",
1925
- title: dirty.join("\n"),
1926
2444
  children: [
1927
- "⚠ ",
1928
- dirtyTotal,
1929
- " 处未提交修改(合并前请让 agent 提交,或手动处理)"
2445
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2446
+ type: "button",
2447
+ className: "dsh-atb-iso-dirty-toggle",
2448
+ onClick: () => setDirtyOpen(!dirtyOpen),
2449
+ children: [
2450
+ "⚠ 有 ",
2451
+ dirtyTotal,
2452
+ " 处未提交修改(合并前请让 agent 提交,或手动处理)",
2453
+ dirtyOpen ? " ▲" : " ▼ 查看文件"
2454
+ ]
2455
+ }),
2456
+ dirtyOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2457
+ className: "dsh-atb-iso-dirty-files",
2458
+ children: [dirty.slice(0, 30).map((line, index) => {
2459
+ const filePath = porcelainPath(line);
2460
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2461
+ type: "button",
2462
+ className: "dsh-atb-iso-dirty-file",
2463
+ title: "点击查看该文件的未提交 diff",
2464
+ onClick: () => setOpenDiff(openDiff?.path === filePath ? null : { path: filePath }),
2465
+ children: [
2466
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: line.slice(0, 2) }),
2467
+ " ",
2468
+ filePath
2469
+ ]
2470
+ }, `${line}-${index}`);
2471
+ }), dirtyTotal > 30 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2472
+ className: "dsh-atb-iso-more",
2473
+ children: [
2474
+ "… 共 ",
2475
+ dirtyTotal,
2476
+ " 处(完整列表见任务台账)"
2477
+ ]
2478
+ })]
2479
+ }),
2480
+ openDiff?.path !== void 0 && dirtyOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DiffView, {
2481
+ controller,
2482
+ task,
2483
+ execution,
2484
+ path: openDiff.path
2485
+ })
1930
2486
  ]
1931
2487
  }),
1932
2488
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -2031,6 +2587,7 @@ window.__ModuleLoader__.load({
2031
2587
  const runningExecution = task.executions.find((e) => e.outcome === "running");
2032
2588
  const holder = task.status === "in_progress" ? task.claimedBy : void 0;
2033
2589
  const stale = now !== void 0 && isStaleClaim(task, now);
2590
+ const unchecked = (task.checklist ?? []).filter((i) => !i.checked).length;
2034
2591
  /** Jump to an execution's session; prompt precisely when it cannot open. */
2035
2592
  const jumpToSession = (sessionId) => {
2036
2593
  controller.openSession(sessionId).then((result) => {
@@ -2088,6 +2645,16 @@ window.__ModuleLoader__.load({
2088
2645
  tone: "urgent",
2089
2646
  children: "受阻"
2090
2647
  }),
2648
+ task.checklist !== void 0 && task.checklist.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Chip, {
2649
+ icon: "☑",
2650
+ tone: task.status === "in_review" && task.checklist.some((i) => !i.checked) ? "urgent" : void 0,
2651
+ children: [
2652
+ "清单 ",
2653
+ checklistProgress(task).done,
2654
+ "/",
2655
+ task.checklist.length
2656
+ ]
2657
+ }),
2091
2658
  task.branch !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Chip, {
2092
2659
  icon: "🌿",
2093
2660
  tone: void 0,
@@ -2140,6 +2707,17 @@ window.__ModuleLoader__.load({
2140
2707
  onClick: () => void controller.duplicate(task),
2141
2708
  children: "⧉ 复制"
2142
2709
  }),
2710
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2711
+ type: "button",
2712
+ className: "dsh-atb-detail-edit",
2713
+ title: "把此任务的配置(含清单)保存为模板,新建任务时可用",
2714
+ onClick: () => {
2715
+ controller.saveAsTemplate(task).then((ok) => {
2716
+ if (ok) showAlert("已存为模板(新建任务 ▼ 下拉可用,可在模板管理中改名)");
2717
+ });
2718
+ },
2719
+ children: "⌗ 存为模板"
2720
+ }),
2143
2721
  canRun && task.branch !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2144
2722
  type: "button",
2145
2723
  className: "dsh-atb-detail-run",
@@ -2221,6 +2799,11 @@ window.__ModuleLoader__.load({
2221
2799
  task,
2222
2800
  controller
2223
2801
  }),
2802
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ReportBlock, { task }),
2803
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChecklistBlock, {
2804
+ task,
2805
+ controller
2806
+ }),
2224
2807
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2225
2808
  className: "dsh-atb-detail-actions",
2226
2809
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -2231,7 +2814,8 @@ window.__ModuleLoader__.load({
2231
2814
  children: [
2232
2815
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2233
2816
  className: "dsh-atb-confirm-label",
2234
- children: "确认完成?"
2817
+ "data-tone": unchecked > 0 ? "bad" : void 0,
2818
+ children: unchecked > 0 ? `仍有 ${unchecked} 项清单未勾选,确认完成?` : "确认完成?"
2235
2819
  }),
2236
2820
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2237
2821
  type: "button",
@@ -2505,28 +3089,94 @@ window.__ModuleLoader__.load({
2505
3089
  });
2506
3090
  }
2507
3091
  /**
2508
- * The form modal. Without `task` it composes a new task; with `task` it
2509
- * edits that record (project, urgency, execution, model included the GUI
2510
- * is the owner surface).
3092
+ * The checklist (DoD) editor: toggle + text + remove per row, add button,
3093
+ * cap-enforced. Edit mode preserves checked state and notes (the GUI
3094
+ * replaces the whole list on save).
3095
+ */
3096
+ function ChecklistEditor({ rows, onChange, editing }) {
3097
+ const setRow = (index, patch) => {
3098
+ onChange(rows.map((row, i) => i === index ? {
3099
+ ...row,
3100
+ ...patch
3101
+ } : row));
3102
+ };
3103
+ const checked = rows.filter((r) => r.checked).length;
3104
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3105
+ className: "dsh-atb-cke",
3106
+ children: [
3107
+ rows.map((row, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3108
+ className: "dsh-atb-cke-row",
3109
+ children: [
3110
+ editing && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3111
+ type: "checkbox",
3112
+ className: "dsh-atb-cke-box",
3113
+ checked: row.checked,
3114
+ title: `勾选状态随保存保留(当前勾选人:${row.checkedBy ?? "未勾选"})`,
3115
+ onChange: (e) => setRow(index, { checked: e.target.checked })
3116
+ }),
3117
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3118
+ className: "dsh-atb-cke-text",
3119
+ value: row.text,
3120
+ maxLength: 200,
3121
+ placeholder: `验收项 ${index + 1}(完成标准)`,
3122
+ spellCheck: false,
3123
+ onChange: (e) => setRow(index, { text: e.target.value })
3124
+ }),
3125
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3126
+ type: "button",
3127
+ className: "dsh-atb-cke-del",
3128
+ title: "删除该验收项",
3129
+ onClick: () => onChange(rows.filter((_, i) => i !== index)),
3130
+ children: "✕"
3131
+ })
3132
+ ]
3133
+ }, row.id ?? `new-${index}`)),
3134
+ rows.length < 30 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3135
+ type: "button",
3136
+ className: "dsh-atb-cke-add",
3137
+ onClick: () => onChange([...rows, {
3138
+ text: "",
3139
+ checked: false
3140
+ }]),
3141
+ children: "+ 添加验收项"
3142
+ }),
3143
+ rows.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3144
+ className: "dsh-atb-cke-hint",
3145
+ children: editing ? `已勾选 ${checked}/${rows.length}(保存将整体覆盖清单,勾选状态保留)` : `共 ${rows.length} 项,执行会话按清单干活并逐项勾选,未完成项验收时高亮`
3146
+ })
3147
+ ]
3148
+ });
3149
+ }
3150
+ /**
3151
+ * The form modal. Without `task` it composes a new task (optionally
3152
+ * prefilled from a chosen template); with `task` it edits that record
3153
+ * (project, urgency, execution, model included — the GUI is the owner
3154
+ * surface).
2511
3155
  * @param controller - the controller.
2512
3156
  * @param task - the task being edited (create mode when absent).
2513
3157
  */
2514
3158
  function TaskFormModal({ controller, task }) {
2515
3159
  const state = controller.getSnapshot();
3160
+ const prefill = state.templatePrefill;
2516
3161
  const editing = task !== void 0;
2517
- const [title, setTitle] = (0, react.useState)(task?.title ?? "");
2518
- const [description, setDescription] = (0, react.useState)(task?.description ?? "");
2519
- const [prompt, setPrompt] = (0, react.useState)(task?.prompt ?? "");
3162
+ const [title, setTitle] = (0, react.useState)(task?.title ?? prefill?.title ?? "");
3163
+ const [description, setDescription] = (0, react.useState)(task?.description ?? prefill?.description ?? "");
3164
+ const [prompt, setPrompt] = (0, react.useState)(task?.prompt ?? prefill?.prompt ?? "");
2520
3165
  const [workspaceId, setWorkspaceId] = (0, react.useState)(task?.workspaceId ?? state.filters.workspaceId ?? state.workspaces[0]?.id ?? "");
2521
- const [urgency, setUrgency] = (0, react.useState)(task?.urgency ?? "normal");
2522
- const [mode, setMode] = (0, react.useState)(task?.execution.mode === "scheduled" ? "scheduled" : "claim");
2523
- const [cron, setCron] = (0, react.useState)(task?.execution.cron ?? "0 9 * * *");
3166
+ const [urgency, setUrgency] = (0, react.useState)(task?.urgency ?? (prefill?.urgency === "urgent" || prefill?.urgency === "relaxed" ? prefill.urgency : "normal"));
3167
+ const [mode, setMode] = (0, react.useState)(task?.execution.mode === "scheduled" || prefill?.execution?.mode === "scheduled" ? "scheduled" : "claim");
3168
+ const [cron, setCron] = (0, react.useState)(task?.execution.cron ?? prefill?.execution?.cron ?? "0 9 * * *");
2524
3169
  const [catalog, setCatalog] = (0, react.useState)([]);
2525
- const [model, setModel] = (0, react.useState)(task?.model !== void 0 ? JSON.stringify(task.model) : "");
2526
- const [presetId, setPresetId] = (0, react.useState)(task?.presetId ?? "");
3170
+ const [model, setModel] = (0, react.useState)(task?.model !== void 0 || prefill?.model !== void 0 ? JSON.stringify(task?.model ?? prefill?.model) : "");
3171
+ const initialPreset = task?.presetId ?? prefill?.presetId ?? "";
3172
+ const [presetId, setPresetId] = (0, react.useState)(initialPreset);
2527
3173
  const [presets, setPresets] = (0, react.useState)([]);
2528
3174
  const [presetDefault, setPresetDefault] = (0, react.useState)(void 0);
2529
- const [isolation, setIsolation] = (0, react.useState)(task?.isolation ?? loadDefaultIsolation());
3175
+ const [isolation, setIsolation] = (0, react.useState)(task?.isolation ?? (prefill?.isolation === "none" ? "none" : prefill?.isolation === "worktree" ? "worktree" : loadDefaultIsolation()));
3176
+ const [checkRows, setCheckRows] = (0, react.useState)(task?.checklist !== void 0 && task.checklist.length > 0 ? task.checklist.map((i) => ({ ...i })) : (prefill?.checklist ?? []).map((text) => ({
3177
+ text,
3178
+ checked: false
3179
+ })));
2530
3180
  const titleRef = (0, react.useRef)(null);
2531
3181
  (0, react.useEffect)(() => {
2532
3182
  titleRef.current?.focus();
@@ -2547,9 +3197,13 @@ window.__ModuleLoader__.load({
2547
3197
  face().then((roster) => {
2548
3198
  setPresets(roster.presets);
2549
3199
  setPresetDefault(roster.defaultId);
2550
- if (task?.presetId === void 0 && roster.defaultId !== void 0) setPresetId(roster.defaultId);
3200
+ if (task?.presetId === void 0 && initialPreset === "" && roster.defaultId !== void 0) setPresetId(roster.defaultId);
2551
3201
  }).catch(() => setPresets([]));
2552
- }, [controller, task?.presetId]);
3202
+ }, [
3203
+ controller,
3204
+ task?.presetId,
3205
+ initialPreset
3206
+ ]);
2553
3207
  const cronMatch = mode === "scheduled" ? parseCron(cron.trim()) : null;
2554
3208
  const nextRun = cronMatch !== null ? nextCronTime(cronMatch, Date.now()) : null;
2555
3209
  const cronBad = mode === "scheduled" && (cronMatch === null || nextRun === null);
@@ -2566,11 +3220,17 @@ window.__ModuleLoader__.load({
2566
3220
  };
2567
3221
  /** Preset payload: '' = follow the deployment default (submit omits). */
2568
3222
  const presetPayload = () => presetId.trim().length > 0 ? presetId.trim() : void 0;
3223
+ /** Checklist rows with non-empty text (blank rows are dropped on submit). */
3224
+ const filledRows = () => checkRows.map((r) => ({
3225
+ ...r,
3226
+ text: r.text.trim()
3227
+ })).filter((r) => r.text.length > 0);
2569
3228
  const submit = () => {
2570
3229
  if (!valid) return;
2571
3230
  const picked = model !== "" ? JSON.parse(model) : void 0;
2572
3231
  const isolationOut = isolationPayload();
2573
3232
  const presetOut = presetPayload();
3233
+ const rows = filledRows();
2574
3234
  if (editing) controller.update(task.id, task.version, {
2575
3235
  title,
2576
3236
  description,
@@ -2583,7 +3243,8 @@ window.__ModuleLoader__.load({
2583
3243
  } : { mode },
2584
3244
  model: picked ?? null,
2585
3245
  ...isolationOut !== void 0 && !isolationLocked ? { isolation: isolationOut } : {},
2586
- presetId: presetOut ?? null
3246
+ presetId: presetOut ?? null,
3247
+ checklist: rows.length > 0 ? rows : null
2587
3248
  });
2588
3249
  else controller.create({
2589
3250
  title,
@@ -2597,7 +3258,8 @@ window.__ModuleLoader__.load({
2597
3258
  } : { mode },
2598
3259
  model: picked,
2599
3260
  ...isolationOut !== void 0 ? { isolation: isolationOut } : {},
2600
- ...presetOut !== void 0 ? { presetId: presetOut } : {}
3261
+ ...presetOut !== void 0 ? { presetId: presetOut } : {},
3262
+ ...rows.length > 0 ? { checklist: rows.map((r) => r.text) } : {}
2601
3263
  });
2602
3264
  };
2603
3265
  /** Save the form, then immediately trigger a manual run of the task. */
@@ -2606,6 +3268,7 @@ window.__ModuleLoader__.load({
2606
3268
  const picked = model !== "" ? JSON.parse(model) : void 0;
2607
3269
  const isolationOut = isolationPayload();
2608
3270
  const presetOut = presetPayload();
3271
+ const rows = filledRows();
2609
3272
  if (editing) (async () => {
2610
3273
  if (await controller.update(task.id, task.version, {
2611
3274
  title,
@@ -2619,7 +3282,8 @@ window.__ModuleLoader__.load({
2619
3282
  } : { mode },
2620
3283
  model: picked ?? null,
2621
3284
  ...isolationOut !== void 0 && !isolationLocked ? { isolation: isolationOut } : {},
2622
- presetId: presetOut ?? null
3285
+ presetId: presetOut ?? null,
3286
+ checklist: rows.length > 0 ? rows : null
2623
3287
  })) await controller.run(task.id);
2624
3288
  })();
2625
3289
  else (async () => {
@@ -2635,7 +3299,8 @@ window.__ModuleLoader__.load({
2635
3299
  } : { mode },
2636
3300
  model: picked,
2637
3301
  ...isolationOut !== void 0 ? { isolation: isolationOut } : {},
2638
- ...presetOut !== void 0 ? { presetId: presetOut } : {}
3302
+ ...presetOut !== void 0 ? { presetId: presetOut } : {},
3303
+ ...rows.length > 0 ? { checklist: rows.map((r) => r.text) } : {}
2639
3304
  });
2640
3305
  if (id !== void 0) await controller.run(id);
2641
3306
  })();
@@ -2874,6 +3539,15 @@ window.__ModuleLoader__.load({
2874
3539
  className: "dsh-atb-isolation-note",
2875
3540
  children: "当前项目非 git 仓库,将在原目录执行(任务仍按默认配置创建,运行时自动降级)"
2876
3541
  })]
3542
+ }),
3543
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
3544
+ label: editing ? "验收清单(DoD)" : "验收清单(DoD,可选)",
3545
+ full: true,
3546
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChecklistEditor, {
3547
+ rows: checkRows,
3548
+ onChange: setCheckRows,
3549
+ editing
3550
+ })
2877
3551
  })
2878
3552
  ]
2879
3553
  }),
@@ -2916,6 +3590,431 @@ window.__ModuleLoader__.load({
2916
3590
  });
2917
3591
  }
2918
3592
 
3593
+ //#endregion
3594
+ //#region src/client/board/ImportModal.tsx
3595
+ /**
3596
+ * The ledger-import modal (0.4.0): pick a JSON file → dry-run preview
3597
+ * (create / overwrite / invalid classification) → commit as merge or
3598
+ * replace. Replace swaps the WHOLE ledger after an automatic backup and a
3599
+ * double confirmation. Files exported by ⬇ JSON import as-is.
3600
+ *
3601
+ * @module dsh-taskboard/client/board/ImportModal
3602
+ */
3603
+ /** One classified row (create / overwrite). */
3604
+ function PlanRow({ row }) {
3605
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3606
+ className: "dsh-atb-imp-row",
3607
+ title: row.id,
3608
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3609
+ className: "dsh-atb-imp-row-title",
3610
+ children: row.title
3611
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3612
+ className: "dsh-atb-imp-row-status",
3613
+ children: row.status
3614
+ })]
3615
+ });
3616
+ }
3617
+ /**
3618
+ * The import modal.
3619
+ * @param controller - the controller.
3620
+ */
3621
+ function ImportModal({ controller }) {
3622
+ const [fileName, setFileName] = (0, react.useState)("");
3623
+ const [parsed, setParsed] = (0, react.useState)(null);
3624
+ const [parseError, setParseError] = (0, react.useState)(void 0);
3625
+ const [plan, setPlan] = (0, react.useState)(void 0);
3626
+ const [mode, setMode] = (0, react.useState)("merge");
3627
+ const [busy, setBusy] = (0, react.useState)(false);
3628
+ const [result, setResult] = (0, react.useState)(void 0);
3629
+ const [confirmReplace, setConfirmReplace] = (0, react.useState)(false);
3630
+ const fileRef = (0, react.useRef)(null);
3631
+ const { alert: showAlert, el: alertEl } = useAlert();
3632
+ /** Read + parse the picked file, then dry-run the preview. */
3633
+ const onFile = (file) => {
3634
+ setPlan(void 0);
3635
+ setParseError(void 0);
3636
+ setResult(void 0);
3637
+ setConfirmReplace(false);
3638
+ setFileName("");
3639
+ setParsed(null);
3640
+ if (file === void 0) return;
3641
+ file.text().then((text) => {
3642
+ try {
3643
+ const value = JSON.parse(text);
3644
+ setParsed(value);
3645
+ setFileName(file.name);
3646
+ controller.importPreview(value).then((p) => {
3647
+ if (p !== void 0) setPlan(p);
3648
+ });
3649
+ } catch {
3650
+ setParseError("文件不是合法 JSON");
3651
+ }
3652
+ });
3653
+ };
3654
+ /** Commit the import (replace requires the inline double confirmation). */
3655
+ const commit = () => {
3656
+ if (parsed === null || plan === void 0 || busy) return;
3657
+ if (mode === "replace" && !confirmReplace) {
3658
+ setConfirmReplace(true);
3659
+ return;
3660
+ }
3661
+ setBusy(true);
3662
+ controller.importCommit(mode, parsed).then((r) => {
3663
+ setBusy(false);
3664
+ setConfirmReplace(false);
3665
+ if (r === void 0) return;
3666
+ setResult(r.mode === "replace" ? `整册替换完成:导入 ${r.created + r.overwritten} 张(原 ${r.replacedTotal} 张已整册备份)` : `合并完成:新增 ${r.created} 张、覆盖 ${r.overwritten} 张`);
3667
+ });
3668
+ };
3669
+ const close = () => controller.closeImport();
3670
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3671
+ className: "dsh-atb-modal-backdrop",
3672
+ onClick: (e) => {
3673
+ if (e.target === e.currentTarget) close();
3674
+ },
3675
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3676
+ className: "dsh-atb-modal dsh-atb-imp",
3677
+ role: "dialog",
3678
+ "aria-modal": "true",
3679
+ "aria-label": "导入台账",
3680
+ children: [
3681
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3682
+ className: "dsh-atb-modal-head",
3683
+ children: [
3684
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3685
+ className: "dsh-atb-modal-headicon",
3686
+ children: "⬆"
3687
+ }),
3688
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3689
+ className: "dsh-atb-modal-headtext",
3690
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", { children: "导入台账" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: "选择导出的 JSON 备份文件:先预览、再合并或整册替换" })]
3691
+ }),
3692
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3693
+ type: "button",
3694
+ className: "dsh-atb-modal-close",
3695
+ "aria-label": "关闭",
3696
+ onClick: close,
3697
+ children: "✕"
3698
+ })
3699
+ ]
3700
+ }),
3701
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3702
+ className: "dsh-atb-modal-body",
3703
+ children: [
3704
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3705
+ className: "dsh-atb-imp-picker",
3706
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3707
+ ref: fileRef,
3708
+ type: "file",
3709
+ accept: ".json,application/json",
3710
+ onChange: (e) => onFile(e.target.files?.[0])
3711
+ }), fileName.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3712
+ className: "dsh-atb-imp-filename",
3713
+ children: fileName
3714
+ })]
3715
+ }),
3716
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3717
+ className: "dsh-atb-imp-note",
3718
+ children: "⬇ JSON 导出的文件即为同格式备份,可直接导入恢复;导入文件的 schemaVersion 必须与当前版本一致。"
3719
+ }),
3720
+ parseError !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3721
+ className: "dsh-atb-imp-error",
3722
+ children: parseError
3723
+ }),
3724
+ plan === void 0 && parseError === void 0 && fileName.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3725
+ className: "dsh-atb-empty2",
3726
+ children: "预览中…"
3727
+ }),
3728
+ plan !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
3729
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3730
+ className: "dsh-atb-imp-stats",
3731
+ children: [
3732
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3733
+ className: "dsh-atb-imp-stat",
3734
+ "data-tone": "ok",
3735
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", { children: plan.create.length }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "新增" })]
3736
+ }),
3737
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3738
+ className: "dsh-atb-imp-stat",
3739
+ "data-tone": "warn",
3740
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", { children: plan.overwrite.length }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "覆盖(同 id)" })]
3741
+ }),
3742
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3743
+ className: "dsh-atb-imp-stat",
3744
+ "data-tone": plan.invalid.length > 0 ? "bad" : void 0,
3745
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", { children: plan.invalid.length }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "无效(跳过)" })]
3746
+ })
3747
+ ]
3748
+ }),
3749
+ plan.create.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3750
+ className: "dsh-atb-imp-sec",
3751
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", { children: "新增任务" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3752
+ className: "dsh-atb-imp-list",
3753
+ children: plan.create.map((r) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PlanRow, { row: r }, r.id))
3754
+ })]
3755
+ }),
3756
+ plan.overwrite.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3757
+ className: "dsh-atb-imp-sec",
3758
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", { children: "覆盖任务(整卡替换,含执行历史与评论)" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3759
+ className: "dsh-atb-imp-list",
3760
+ children: plan.overwrite.map((r) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PlanRow, { row: r }, r.id))
3761
+ })]
3762
+ }),
3763
+ plan.invalid.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3764
+ className: "dsh-atb-imp-sec",
3765
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", { children: "无效条目(不会导入)" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3766
+ className: "dsh-atb-imp-list",
3767
+ children: plan.invalid.map((r, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3768
+ className: "dsh-atb-imp-row",
3769
+ "data-tone": "bad",
3770
+ title: r.id ?? "",
3771
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3772
+ className: "dsh-atb-imp-row-title",
3773
+ children: r.id ?? "(无 id)"
3774
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3775
+ className: "dsh-atb-imp-row-status",
3776
+ children: r.reason
3777
+ })]
3778
+ }, r.id ?? `invalid-${i}`))
3779
+ })]
3780
+ }),
3781
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3782
+ className: "dsh-atb-mode-picker",
3783
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3784
+ type: "button",
3785
+ className: "dsh-atb-mode-opt",
3786
+ "data-on": mode === "merge",
3787
+ onClick: () => {
3788
+ setMode("merge");
3789
+ setConfirmReplace(false);
3790
+ },
3791
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3792
+ className: "dsh-atb-mode-name",
3793
+ children: "⊕ 合并"
3794
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3795
+ className: "dsh-atb-mode-hint",
3796
+ children: "新增 + 按 id 覆盖,其余不动"
3797
+ })]
3798
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3799
+ type: "button",
3800
+ className: "dsh-atb-mode-opt",
3801
+ "data-on": mode === "replace",
3802
+ onClick: () => setMode("replace"),
3803
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3804
+ className: "dsh-atb-mode-name",
3805
+ children: "💣 整册替换"
3806
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3807
+ className: "dsh-atb-mode-hint",
3808
+ children: "清空当前台账,以导入文件为准(先自动备份)"
3809
+ })]
3810
+ })]
3811
+ }),
3812
+ result !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3813
+ className: "dsh-atb-imp-result",
3814
+ children: result
3815
+ })
3816
+ ] })
3817
+ ]
3818
+ }),
3819
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3820
+ className: "dsh-atb-modal-foot",
3821
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3822
+ className: "dsh-atb-modal-hint",
3823
+ children: mode === "replace" ? confirmReplace ? "⚠ 再次点击确认执行整册替换(不可撤销,已自动备份)" : "整册替换需要二次确认" : "合并只写入预览中列出的任务"
3824
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3825
+ className: "dsh-atb-modal-footbtns",
3826
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3827
+ type: "button",
3828
+ className: "dsh-atb-btn",
3829
+ onClick: close,
3830
+ children: result !== void 0 ? "关闭" : "取消"
3831
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3832
+ type: "button",
3833
+ className: "dsh-atb-btn",
3834
+ "data-primary": "true",
3835
+ "data-danger": mode === "replace" && confirmReplace ? "true" : void 0,
3836
+ disabled: plan === void 0 || busy || result !== void 0 && false,
3837
+ onClick: commit,
3838
+ children: mode === "replace" && confirmReplace ? "确认整册替换" : "执行导入"
3839
+ })]
3840
+ })]
3841
+ })
3842
+ ]
3843
+ }), alertEl]
3844
+ });
3845
+ }
3846
+
3847
+ //#endregion
3848
+ //#region src/client/board/TemplateManager.tsx
3849
+ /**
3850
+ * The template-manager modal (0.4.0): rename / delete / use the stored task
3851
+ * templates. Templates live host-side (side file next to the ledger) and
3852
+ * prefill the create form from the + 新建任务 ▼ dropdown.
3853
+ *
3854
+ * @module dsh-taskboard/client/board/TemplateManager
3855
+ */
3856
+ /**
3857
+ * The template manager modal.
3858
+ * @param controller - the controller.
3859
+ */
3860
+ function TemplateManager({ controller }) {
3861
+ const state = controller.getSnapshot();
3862
+ const [edits, setEdits] = (0, react.useState)({});
3863
+ const [confirmId, setConfirmId] = (0, react.useState)(void 0);
3864
+ const { alert: showAlert, el: alertEl } = useAlert();
3865
+ const close = () => controller.closeTemplateManager();
3866
+ const nameOf = (id, fallback) => edits[id] ?? fallback;
3867
+ /** Save one template's rename. */
3868
+ const save = (id, name) => {
3869
+ const template = state.templates.find((t) => t.id === id);
3870
+ if (template === void 0 || name === template.name) return;
3871
+ controller.upsertTemplate({
3872
+ id,
3873
+ name,
3874
+ task: template.task
3875
+ }).then((ok) => {
3876
+ if (ok) {
3877
+ setEdits((prev) => {
3878
+ const next = { ...prev };
3879
+ delete next[id];
3880
+ return next;
3881
+ });
3882
+ showAlert("模板已改名");
3883
+ }
3884
+ });
3885
+ };
3886
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3887
+ className: "dsh-atb-modal-backdrop",
3888
+ onClick: (e) => {
3889
+ if (e.target === e.currentTarget) close();
3890
+ },
3891
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3892
+ className: "dsh-atb-modal dsh-atb-tplm",
3893
+ role: "dialog",
3894
+ "aria-modal": "true",
3895
+ "aria-label": "管理模板",
3896
+ children: [
3897
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3898
+ className: "dsh-atb-modal-head",
3899
+ children: [
3900
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3901
+ className: "dsh-atb-modal-headicon",
3902
+ children: "⌗"
3903
+ }),
3904
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3905
+ className: "dsh-atb-modal-headtext",
3906
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", { children: "任务模板" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: "新建任务 ▼ 下拉的模板:改名 / 删除 / 直接使用;任务详情页「存为模板」可新增" })]
3907
+ }),
3908
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3909
+ type: "button",
3910
+ className: "dsh-atb-modal-close",
3911
+ "aria-label": "关闭",
3912
+ onClick: close,
3913
+ children: "✕"
3914
+ })
3915
+ ]
3916
+ }),
3917
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3918
+ className: "dsh-atb-modal-body",
3919
+ children: state.templates.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3920
+ className: "dsh-atb-empty2",
3921
+ children: "暂无模板 — 在任务详情页点「存为模板」把常用配置沉淀下来"
3922
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3923
+ className: "dsh-atb-tplm-list",
3924
+ children: state.templates.map((t) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3925
+ className: "dsh-atb-tplm-row",
3926
+ children: [
3927
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3928
+ className: "dsh-atb-tplm-name",
3929
+ value: nameOf(t.id, t.name),
3930
+ maxLength: 60,
3931
+ spellCheck: false,
3932
+ "aria-label": `模板名 ${t.name}`,
3933
+ onChange: (e) => setEdits((prev) => ({
3934
+ ...prev,
3935
+ [t.id]: e.target.value
3936
+ })),
3937
+ onKeyDown: (e) => {
3938
+ if (e.key === "Enter") save(t.id, nameOf(t.id, t.name));
3939
+ }
3940
+ }),
3941
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3942
+ className: "dsh-atb-tplm-meta",
3943
+ children: [
3944
+ t.builtin === true ? "内置" : "自建",
3945
+ t.task.checklist !== void 0 && t.task.checklist.length > 0 ? ` · 清单 ${t.task.checklist.length} 项` : "",
3946
+ t.task.urgency !== void 0 ? ` · ${t.task.urgency}` : ""
3947
+ ]
3948
+ }),
3949
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3950
+ className: "dsh-atb-tplm-btns",
3951
+ children: [
3952
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3953
+ type: "button",
3954
+ className: "dsh-atb-btn",
3955
+ disabled: nameOf(t.id, t.name) === t.name || nameOf(t.id, t.name).trim().length === 0,
3956
+ title: "保存改名",
3957
+ onClick: () => save(t.id, nameOf(t.id, t.name)),
3958
+ children: "改名"
3959
+ }),
3960
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3961
+ type: "button",
3962
+ className: "dsh-atb-btn",
3963
+ title: "用此模板打开新建表单",
3964
+ onClick: () => {
3965
+ close();
3966
+ controller.newFromTemplate(t.task);
3967
+ },
3968
+ children: "用此新建"
3969
+ }),
3970
+ confirmId === t.id ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3971
+ type: "button",
3972
+ className: "dsh-atb-btn",
3973
+ "data-danger": "true",
3974
+ onClick: () => {
3975
+ controller.deleteTemplate(t.id);
3976
+ setConfirmId(void 0);
3977
+ },
3978
+ children: "确认删除"
3979
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3980
+ type: "button",
3981
+ className: "dsh-atb-btn",
3982
+ onClick: () => setConfirmId(void 0),
3983
+ children: "取消"
3984
+ })] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3985
+ type: "button",
3986
+ className: "dsh-atb-btn",
3987
+ "data-danger": "true",
3988
+ title: "删除该模板",
3989
+ onClick: () => setConfirmId(t.id),
3990
+ children: "🗑"
3991
+ })
3992
+ ]
3993
+ })
3994
+ ]
3995
+ }, t.id))
3996
+ })
3997
+ }),
3998
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3999
+ className: "dsh-atb-modal-foot",
4000
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4001
+ className: "dsh-atb-modal-hint",
4002
+ children: "模板随台账一同保存在 DSH 主目录,升级不丢"
4003
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4004
+ className: "dsh-atb-modal-footbtns",
4005
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4006
+ type: "button",
4007
+ className: "dsh-atb-btn",
4008
+ onClick: close,
4009
+ children: "关闭"
4010
+ })
4011
+ })]
4012
+ })
4013
+ ]
4014
+ }), alertEl]
4015
+ });
4016
+ }
4017
+
2919
4018
  //#endregion
2920
4019
  //#region src/client/board/TaskBoard.tsx
2921
4020
  /**
@@ -2984,6 +4083,8 @@ window.__ModuleLoader__.load({
2984
4083
  const live = filterTasks(state, state.ledger.tasks.filter((t) => t.trashedAt === void 0));
2985
4084
  const selected = state.selectedId === void 0 ? void 0 : state.ledger.tasks.find((t) => t.id === state.selectedId);
2986
4085
  const { alert: showAlert, el: alertEl } = useAlert();
4086
+ const [newMenuOpen, setNewMenuOpen] = (0, react.useState)(false);
4087
+ const closeMenu = () => setNewMenuOpen(false);
2987
4088
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2988
4089
  className: "dsh-atb-board",
2989
4090
  children: [
@@ -3002,12 +4103,55 @@ window.__ModuleLoader__.load({
3002
4103
  state.ledger.revision
3003
4104
  ]
3004
4105
  }),
3005
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3006
- type: "button",
3007
- className: "dsh-atb-btn",
3008
- "data-primary": "true",
3009
- onClick: () => controller.setComposer(true),
3010
- children: "+ 新建任务"
4106
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4107
+ className: "dsh-atb-newmenu",
4108
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4109
+ type: "button",
4110
+ className: "dsh-atb-btn",
4111
+ "data-primary": "true",
4112
+ onClick: () => {
4113
+ const next = !newMenuOpen;
4114
+ setNewMenuOpen(next);
4115
+ if (next) controller.prepareTemplateMenu();
4116
+ },
4117
+ children: "+ 新建任务 ▼"
4118
+ }), newMenuOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4119
+ className: "dsh-atb-newmenu-backdrop",
4120
+ onClick: closeMenu
4121
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4122
+ className: "dsh-atb-newmenu-list",
4123
+ children: [
4124
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4125
+ type: "button",
4126
+ className: "dsh-atb-newmenu-opt",
4127
+ onClick: () => {
4128
+ closeMenu();
4129
+ controller.setComposer(true);
4130
+ },
4131
+ children: "空白任务"
4132
+ }),
4133
+ state.templates.map((t) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
4134
+ type: "button",
4135
+ className: "dsh-atb-newmenu-opt",
4136
+ title: t.task.description !== void 0 && t.task.description.length > 0 ? t.task.description.slice(0, 120) : t.name,
4137
+ onClick: () => {
4138
+ closeMenu();
4139
+ controller.newFromTemplate(t.task);
4140
+ },
4141
+ children: [t.name, t.builtin === true ? "" : ""]
4142
+ }, t.id)),
4143
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "dsh-atb-newmenu-sep" }),
4144
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4145
+ type: "button",
4146
+ className: "dsh-atb-newmenu-opt",
4147
+ onClick: () => {
4148
+ closeMenu();
4149
+ controller.openTemplateManager();
4150
+ },
4151
+ children: "⌗ 管理模板…"
4152
+ })
4153
+ ]
4154
+ })] })]
3011
4155
  }),
3012
4156
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "dsh-atb-spacer" }),
3013
4157
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
@@ -3081,6 +4225,13 @@ window.__ModuleLoader__.load({
3081
4225
  onClick: () => controller.openDiagnostics(),
3082
4226
  children: "⚙ 诊断"
3083
4227
  }),
4228
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4229
+ type: "button",
4230
+ className: "dsh-atb-btn",
4231
+ title: "从 JSON 备份文件导入台账(预览后合并或整册替换)",
4232
+ onClick: () => controller.openImport(),
4233
+ children: "⬆ 导入"
4234
+ }),
3084
4235
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3085
4236
  type: "button",
3086
4237
  className: "dsh-atb-btn",
@@ -3182,6 +4333,8 @@ window.__ModuleLoader__.load({
3182
4333
  task: state.editingId === void 0 ? void 0 : state.ledger.tasks.find((t) => t.id === state.editingId)
3183
4334
  }),
3184
4335
  state.diagOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DiagnosticsPanel, { controller }),
4336
+ state.importOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ImportModal, { controller }),
4337
+ state.tplManagerOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TemplateManager, { controller }),
3185
4338
  alertEl
3186
4339
  ]
3187
4340
  });