dsh-todo-time-log 0.6.0 → 0.6.2

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
@@ -260,6 +260,42 @@
260
260
  "notice.serverError": "服务器返回了错误。",
261
261
  "toast.undo": "撤销",
262
262
  "toast.dismiss": "关闭提示",
263
+ "transfer.open": "数据",
264
+ "transfer.title": "导入 / 导出",
265
+ "transfer.close": "关闭",
266
+ "transfer.exportTitle": "导出",
267
+ "transfer.exportHint": "导出全部待办。JSON 无损、可回导;CSV 适合用 Excel 统计耗时。",
268
+ "transfer.exportJson": "导出 JSON",
269
+ "transfer.exportCsv": "导出 CSV",
270
+ "transfer.exported": "已导出 {count} 条待办({format})",
271
+ "transfer.importTitle": "导入",
272
+ "transfer.importHint": "支持本插件 JSON(v1–v5)、待办数组或 CSV;导入前会自动备份当前数据。",
273
+ "transfer.chooseFile": "选择文件",
274
+ "transfer.noFile": "未选择文件",
275
+ "transfer.reading": "读取中…",
276
+ "transfer.readError": "读取文件失败:{message}",
277
+ "transfer.emptyFile": "文件内容为空。",
278
+ "transfer.noPreview": "选择文件后先显示预览,确认后才写入。",
279
+ "transfer.previewTitle": "导入预览",
280
+ "transfer.previewAdded": "新增 {count}",
281
+ "transfer.previewUpdated": "更新 {count}",
282
+ "transfer.previewSkipped": "跳过 {count}",
283
+ "transfer.previewRemoved": "移除 {count}",
284
+ "transfer.previewTotal": "合计 {count}",
285
+ "transfer.previewRows": "共读取 {count} 行",
286
+ "transfer.previewInvalid": "非法 {count} 行",
287
+ "transfer.rowError": "第 {row} 行:{message}",
288
+ "transfer.modeLabel": "冲突处理",
289
+ "transfer.modeMerge": "合并(推荐)",
290
+ "transfer.modeMergeHint": "同 ID 以更新时间较新的为准;新 ID 追加为待办。",
291
+ "transfer.modeAdd": "仅新增",
292
+ "transfer.modeAddHint": "已存在的 ID 直接跳过,不修改现有待办。",
293
+ "transfer.modeReplace": "覆盖全部",
294
+ "transfer.modeReplaceHint": "先自动备份,再用文件内容替换全部待办。",
295
+ "transfer.confirm": "开始导入",
296
+ "transfer.importing": "导入中…",
297
+ "transfer.imported": "导入完成",
298
+ "transfer.undone": "已撤销导入",
263
299
  "error.panel": "待办时间日志面板出现异常",
264
300
  "error.panelHint": "请关闭后重新打开面板;如果问题持续存在,请检查插件日志。",
265
301
  "error.detail": "详情:{message}"
@@ -515,6 +551,42 @@
515
551
  "notice.serverError": "The server returned an error.",
516
552
  "toast.undo": "Undo",
517
553
  "toast.dismiss": "Dismiss message",
554
+ "transfer.open": "Data",
555
+ "transfer.title": "Import / Export",
556
+ "transfer.close": "Close",
557
+ "transfer.exportTitle": "Export",
558
+ "transfer.exportHint": "Exports every todo. JSON is lossless and re-importable; CSV suits Excel time analysis.",
559
+ "transfer.exportJson": "Export JSON",
560
+ "transfer.exportCsv": "Export CSV",
561
+ "transfer.exported": "Exported {count} todo(s) ({format})",
562
+ "transfer.importTitle": "Import",
563
+ "transfer.importHint": "Accepts this plugin JSON (v1–v5), a todo array or CSV; the current data is backed up first.",
564
+ "transfer.chooseFile": "Choose file",
565
+ "transfer.noFile": "No file selected",
566
+ "transfer.reading": "Reading…",
567
+ "transfer.readError": "Could not read the file: {message}",
568
+ "transfer.emptyFile": "The file is empty.",
569
+ "transfer.noPreview": "Pick a file to preview; nothing is written until you confirm.",
570
+ "transfer.previewTitle": "Import preview",
571
+ "transfer.previewAdded": "Add {count}",
572
+ "transfer.previewUpdated": "Update {count}",
573
+ "transfer.previewSkipped": "Skip {count}",
574
+ "transfer.previewRemoved": "Remove {count}",
575
+ "transfer.previewTotal": "Total {count}",
576
+ "transfer.previewRows": "{count} row(s) read",
577
+ "transfer.previewInvalid": "{count} invalid row(s)",
578
+ "transfer.rowError": "Row {row}: {message}",
579
+ "transfer.modeLabel": "Conflict handling",
580
+ "transfer.modeMerge": "Merge (recommended)",
581
+ "transfer.modeMergeHint": "Same id keeps the newer update; new ids are appended.",
582
+ "transfer.modeAdd": "Add only",
583
+ "transfer.modeAddHint": "Existing ids are skipped; current todos stay untouched.",
584
+ "transfer.modeReplace": "Replace all",
585
+ "transfer.modeReplaceHint": "Backs up first, then replaces every todo with the file contents.",
586
+ "transfer.confirm": "Start import",
587
+ "transfer.importing": "Importing…",
588
+ "transfer.imported": "Import finished",
589
+ "transfer.undone": "Import undone",
518
590
  "error.panel": "The todo time log panel crashed",
519
591
  "error.panelHint": "Close and reopen the panel; check the plugin logs if it keeps happening.",
520
592
  "error.detail": "Details: {message}"
@@ -577,6 +649,11 @@
577
649
  { value: "comfortable", labelKey: "board.densityComfortable" },
578
650
  { value: "compact", labelKey: "board.densityCompact" }
579
651
  ];
652
+ var TRANSFER_MODES = [
653
+ { value: "merge", labelKey: "transfer.modeMerge", hintKey: "transfer.modeMergeHint" },
654
+ { value: "add", labelKey: "transfer.modeAdd", hintKey: "transfer.modeAddHint" },
655
+ { value: "replace", labelKey: "transfer.modeReplace", hintKey: "transfer.modeReplaceHint" }
656
+ ];
580
657
  var BOARD_COMPLETED_LIMIT = 10;
581
658
  var CALENDAR_BASES = [
582
659
  { value: "createdAt", labelKey: "calendar.basisCreated" },
@@ -592,6 +669,7 @@
592
669
  "calendar.weekdaySun"
593
670
  ];
594
671
  var TABLE_COLUMNS = [
672
+ { key: "check", labelKey: "row.complete", sortBy: null, srOnly: true },
595
673
  { key: "title", labelKey: "table.title", sortBy: "title" },
596
674
  { key: "project", labelKey: "table.project", sortBy: "project" },
597
675
  { key: "status", labelKey: "table.status", sortBy: null },
@@ -625,8 +703,13 @@
625
703
  overflow: hidden;
626
704
  color: var(--dsw-alias-label-primary);
627
705
  background: var(--dsw-alias-bg-base);
706
+ /* 与宿主 UI 同一套字体栈(真机继承宿主 body 字体):显式声明后,静态预览与真机一致,
707
+ 也不会被祖先样式改掉。 */
708
+ font-family: var(--dsw-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
709
+ "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif);
628
710
  font-size: 14px;
629
711
  line-height: 1.6;
712
+ -webkit-font-smoothing: antialiased;
630
713
  }
631
714
  .ttl-scroll {
632
715
  flex: 1;
@@ -814,8 +897,9 @@
814
897
  /* 数据区:视图/筛选控制与列表同属一张卡片,成为唯一的数据容器。 */
815
898
  .ttl-data {
816
899
  border: 0.5px solid var(--dsw-alias-border-l2);
817
- border-radius: var(--dsw-radius-md);
900
+ border-radius: var(--dsw-radius-lg);
818
901
  background: var(--dsw-alias-bg-base);
902
+ box-shadow: var(--dsw-shadow-lv1);
819
903
  overflow: hidden;
820
904
  }
821
905
  .ttl-data > .ttl-toolbar {
@@ -1072,8 +1156,8 @@
1072
1156
  display: inline-flex;
1073
1157
  align-items: center;
1074
1158
  flex: none;
1075
- height: 20px;
1076
- padding: 0 8px;
1159
+ height: 18px;
1160
+ padding: 0 7px;
1077
1161
  border: 0;
1078
1162
  border-radius: 9999px;
1079
1163
  background: color-mix(in srgb, var(--dsw-alias-label-secondary) 9%, transparent);
@@ -1087,19 +1171,36 @@
1087
1171
  background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 14%, transparent);
1088
1172
  color: var(--dsw-alias-state-success-primary);
1089
1173
  }
1090
- .ttl-badge-row { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px; }
1091
- .ttl-badge.ttl-priority-high {
1092
- background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 14%, transparent);
1093
- color: var(--dsw-alias-state-error-primary);
1174
+ /* 状态:胶囊 + 状态点(颜色沿用胶囊文字色)。 */
1175
+ .ttl-status-badge::before {
1176
+ content: '';
1177
+ flex: none;
1178
+ width: 5px;
1179
+ height: 5px;
1180
+ margin-right: 4px;
1181
+ border-radius: 50%;
1182
+ background: currentColor;
1183
+ opacity: 0.9;
1094
1184
  }
1095
- .ttl-badge.ttl-priority-medium {
1096
- background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent);
1097
- color: var(--dsw-alias-state-warn-primary);
1185
+ .ttl-badge-row { display: inline-flex; flex-wrap: nowrap; align-items: center; gap: 4px; }
1186
+ /* 类型:小号圆角矩形标签(与状态胶囊区分层级)。 */
1187
+ .ttl-badge.ttl-type-badge {
1188
+ border-radius: 5px;
1189
+ padding: 0 6px;
1190
+ font-size: 10.5px;
1191
+ letter-spacing: 0.02em;
1098
1192
  }
1099
- .ttl-badge.ttl-priority-low {
1100
- background: color-mix(in srgb, var(--dsw-alias-label-secondary) 9%, transparent);
1101
- color: var(--dsw-alias-label-secondary);
1193
+ /* 优先级:箭头 + 文字,不再用第二个彩色胶囊,避免「标签汤」。 */
1194
+ .ttl-badge.ttl-priority-badge {
1195
+ gap: 3px;
1196
+ padding: 0;
1197
+ background: none;
1198
+ font-weight: 500;
1102
1199
  }
1200
+ .ttl-priority-arrow { font-size: 11px; line-height: 1; }
1201
+ .ttl-badge.ttl-priority-badge.ttl-priority-high { background: none; color: var(--dsw-alias-state-error-primary); }
1202
+ .ttl-badge.ttl-priority-badge.ttl-priority-medium { background: none; color: var(--dsw-alias-state-warn-label); }
1203
+ .ttl-badge.ttl-priority-badge.ttl-priority-low { background: none; color: var(--dsw-alias-label-tertiary); }
1103
1204
  .ttl-priority-select select { min-width: 64px; }
1104
1205
  .ttl-type-select select { min-width: 76px; }
1105
1206
  .ttl-badge.ttl-type-bug {
@@ -1176,7 +1277,7 @@
1176
1277
  gap: 2px;
1177
1278
  }
1178
1279
  .ttl-link {
1179
- padding: 2px 6px;
1280
+ padding: 2px 3px;
1180
1281
  border: 0;
1181
1282
  border-radius: 6px;
1182
1283
  background: none;
@@ -1246,10 +1347,11 @@
1246
1347
  }
1247
1348
  .ttl-skeleton-row {
1248
1349
  display: grid;
1249
- grid-template-columns: 32fr 14fr 8fr 12fr 12fr 10fr;
1350
+ /* 与 .ttl-table 的列宽同口径(标题吃剩余,状态列固定),骨架屏不跳列。 */
1351
+ grid-template-columns: 20px minmax(0, 1fr) 72px 132px 100px 100px 92px;
1250
1352
  align-items: center;
1251
- gap: 24px;
1252
- height: 44px;
1353
+ gap: 12px;
1354
+ height: 42px;
1253
1355
  padding: 0 12px;
1254
1356
  border-bottom: 0.5px solid var(--dsw-alias-border-l1);
1255
1357
  }
@@ -1300,14 +1402,34 @@
1300
1402
  .ttl-board-edit .ttl-project-picker { flex: 1 1 100%; }
1301
1403
  .ttl-calendar-entry-edit .ttl-project-picker { flex: 1 1 100%; }
1302
1404
  .ttl-gantt-row-edit .ttl-project-picker { flex: 0 1 220px; }.ttl-badge-project {
1303
- max-width: 140px;
1304
- overflow: hidden;
1305
- text-overflow: ellipsis;
1405
+ display: inline-flex;
1406
+ align-items: center;
1407
+ gap: 5px;
1408
+ max-width: 100%;
1306
1409
  padding: 0;
1307
1410
  background: none;
1308
- color: var(--dsw-alias-label-tertiary);
1411
+ color: var(--dsw-alias-label-secondary);
1309
1412
  font-weight: 400;
1310
1413
  }
1414
+ .ttl-project-dot {
1415
+ flex: none;
1416
+ width: 6px;
1417
+ height: 6px;
1418
+ border-radius: 50%;
1419
+ background: var(--dsw-alias-label-tertiary);
1420
+ }
1421
+ .ttl-badge-project[data-tone='business'] .ttl-project-dot { background: var(--dsw-alias-state-business-primary); }
1422
+ .ttl-badge-project[data-tone='done'] .ttl-project-dot { background: var(--dsw-alias-state-done-primary); }
1423
+ .ttl-badge-project[data-tone='success'] .ttl-project-dot { background: var(--dsw-alias-state-success-primary); }
1424
+ .ttl-badge-project[data-tone='warn'] .ttl-project-dot { background: var(--dsw-alias-state-warn-primary); }
1425
+ .ttl-badge-project[data-tone='error'] .ttl-project-dot { background: var(--dsw-alias-state-error-primary); }
1426
+ .ttl-badge-project[data-tone='idle'] .ttl-project-dot { background: var(--dsw-alias-label-caption); }
1427
+ .ttl-project-name {
1428
+ min-width: 0;
1429
+ overflow: hidden;
1430
+ text-overflow: ellipsis;
1431
+ white-space: nowrap;
1432
+ }
1311
1433
  .ttl-badge-project-none { color: var(--dsw-alias-label-caption); }
1312
1434
 
1313
1435
  .ttl-list-group {
@@ -1616,22 +1738,33 @@
1616
1738
  }
1617
1739
  .ttl-table {
1618
1740
  width: 100%;
1619
- min-width: 700px;
1741
+ /* 固定列宽:列宽只由 col 决定,不随标题长短跳动(否则长标题会挤扁状态列,徽标被迫换行)。 */
1742
+ table-layout: fixed;
1743
+ min-width: 860px;
1620
1744
  border-collapse: collapse;
1621
1745
  font-size: 13px;
1622
1746
  }
1747
+ /* 列宽口径:标题吃掉剩余宽度,其余列固定,保证「状态」列永远放得下一行徽标。 */
1748
+ .ttl-col-check { width: 40px; }
1749
+ .ttl-col-project { width: 104px; }
1750
+ .ttl-col-status { width: 160px; }
1751
+ .ttl-col-createdAt { width: 126px; }
1752
+ .ttl-col-completedAt { width: 126px; }
1753
+ .ttl-col-actions { width: 118px; }
1623
1754
  .ttl-th {
1624
- height: 36px;
1755
+ height: 34px;
1625
1756
  padding: 0 12px;
1626
- border-bottom: 0.5px solid var(--dsw-alias-border-l1);
1757
+ border-bottom: 0.5px solid var(--dsw-alias-border-l2);
1627
1758
  background: var(--dsw-alias-bg-module-platform);
1628
- color: var(--dsw-alias-label-secondary);
1629
- font-size: 12px;
1759
+ color: var(--dsw-alias-label-tertiary);
1760
+ font-size: 11px;
1630
1761
  font-weight: 500;
1631
- line-height: 18px;
1762
+ line-height: 16px;
1763
+ letter-spacing: 0.02em;
1632
1764
  text-align: left;
1633
1765
  white-space: nowrap;
1634
1766
  }
1767
+ .ttl-th-check { width: 40px; padding: 0 4px 0 12px; }
1635
1768
  .ttl-th-right { text-align: right; }
1636
1769
  .ttl-th-sortable { padding: 0; }
1637
1770
  .ttl-th-button {
@@ -1639,14 +1772,15 @@
1639
1772
  align-items: center;
1640
1773
  gap: 4px;
1641
1774
  width: 100%;
1642
- height: 36px;
1775
+ height: 34px;
1643
1776
  padding: 0 12px;
1644
1777
  border: 0;
1645
1778
  background: none;
1646
1779
  color: inherit;
1647
1780
  font: inherit;
1648
- font-size: 12px;
1649
- line-height: 18px;
1781
+ font-size: 11px;
1782
+ letter-spacing: 0.02em;
1783
+ line-height: 16px;
1650
1784
  text-align: left;
1651
1785
  white-space: nowrap;
1652
1786
  cursor: pointer;
@@ -1655,7 +1789,10 @@
1655
1789
  .ttl-th-button:hover { color: var(--dsw-alias-label-primary); }
1656
1790
  .ttl-th-button-active { color: var(--dsw-alias-label-primary); }
1657
1791
  .ttl-th-arrow { color: var(--dsw-alias-label-tertiary); font-size: 9px; line-height: 1; }
1658
- .ttl-table-row { border-bottom: 0.5px solid var(--dsw-alias-border-l1); }
1792
+ .ttl-table-row {
1793
+ border-bottom: 0.5px solid var(--dsw-alias-border-l1);
1794
+ transition: background 0.12s ease-out;
1795
+ }
1659
1796
  .ttl-table-row:hover { background: var(--dsw-alias-interactive-bg-hover); }
1660
1797
  .ttl-table tbody tr:last-child { border-bottom: 0; }
1661
1798
  .ttl-table-group-row .ttl-td {
@@ -1671,7 +1808,8 @@
1671
1808
  vertical-align: middle;
1672
1809
  line-height: 20px;
1673
1810
  }
1674
- .ttl-td-title { max-width: 360px; }
1811
+ .ttl-td-check { width: 40px; padding: 11px 4px 11px 12px; }
1812
+ .ttl-td-title { max-width: none; }
1675
1813
  .ttl-td-time {
1676
1814
  color: var(--dsw-alias-label-tertiary);
1677
1815
  font-size: 12px;
@@ -1681,7 +1819,17 @@
1681
1819
  font-variant-numeric: tabular-nums;
1682
1820
  }
1683
1821
  .ttl-td-actions { text-align: right; white-space: nowrap; }
1822
+ /* 操作区默认压低,行 hover / 键盘聚焦时再提亮,避免整张表被按钮占满注意力。 */
1823
+ .ttl-td-actions .ttl-link { color: var(--dsw-alias-label-tertiary); }
1824
+ .ttl-td-actions .ttl-link:hover { color: var(--dsw-alias-label-primary); }
1825
+ .ttl-table-row:hover .ttl-td-actions .ttl-link,
1826
+ .ttl-table-row:focus-within .ttl-td-actions .ttl-link { color: var(--dsw-alias-label-secondary); }
1827
+ .ttl-td-actions .ttl-link.ttl-link-danger { color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 72%, transparent); }
1828
+ .ttl-table-row:hover .ttl-td-actions .ttl-link.ttl-link-danger,
1829
+ .ttl-td-actions .ttl-link.ttl-link-danger:hover { color: var(--dsw-alias-state-error-primary); }
1684
1830
  .ttl-table-title-button {
1831
+ display: -webkit-box;
1832
+ width: 100%;
1685
1833
  max-width: 100%;
1686
1834
  padding: 0;
1687
1835
  border: 0;
@@ -1692,10 +1840,15 @@
1692
1840
  font-weight: 500;
1693
1841
  line-height: 20px;
1694
1842
  text-align: left;
1843
+ overflow: hidden;
1695
1844
  overflow-wrap: anywhere;
1845
+ /* 最多两行:长标题截断为省略号,完整文案在 title 与「详情」里可见,行高保持稳定。 */
1846
+ -webkit-line-clamp: 2;
1847
+ -webkit-box-orient: vertical;
1696
1848
  cursor: pointer;
1697
1849
  }
1698
1850
  .ttl-table-title-button:hover { color: var(--dsw-alias-state-business-primary); }
1851
+ .ttl-table-row[data-status='completed'] .ttl-table-title-button { color: var(--dsw-alias-label-secondary); }
1699
1852
  .ttl-table-detail-row .ttl-td { padding: 0 12px 10px; }
1700
1853
 
1701
1854
  .ttl-board {
@@ -1786,7 +1939,7 @@
1786
1939
  .ttl-board-move-select { height: 24px; max-width: 150px; padding: 0 4px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 6px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-secondary); font: inherit; font-size: 12px; cursor: pointer; }
1787
1940
  .ttl-board-move-select:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: 1px; }
1788
1941
  .ttl-board-compact .ttl-board-card { gap: 2px; padding: 6px 28px 6px 32px; }
1789
- .ttl-board-compact .ttl-board-check { top: 6px; left: 6px; width: 22px; height: 22px; }
1942
+ .ttl-board-compact .ttl-board-check { top: 6px; left: 6px; width: 18px; height: 18px; }
1790
1943
  .ttl-board-compact .ttl-board-more { top: 4px; right: 2px; }
1791
1944
  .ttl-board-compact .ttl-board-card-title { -webkit-line-clamp: 1; }
1792
1945
  .ttl-board-compact .ttl-board-card-files { display: none; }
@@ -1805,8 +1958,13 @@
1805
1958
  gap: 4px;
1806
1959
  padding: 9px 32px 9px 36px;
1807
1960
  border: 0.5px solid var(--dsw-alias-border-l2);
1808
- border-radius: 10px;
1961
+ border-radius: 12px;
1809
1962
  background: var(--dsw-alias-bg-base);
1963
+ transition: border-color 0.15s, box-shadow 0.15s;
1964
+ }
1965
+ .ttl-board-card:hover {
1966
+ border-color: var(--dsw-alias-border-l3);
1967
+ box-shadow: var(--dsw-shadow-lv1);
1810
1968
  }
1811
1969
  .ttl-board-card-editing { padding: 9px 10px; }
1812
1970
  .ttl-board-card-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
@@ -1835,30 +1993,30 @@
1835
1993
  }
1836
1994
  .ttl-board-project { display: inline-flex; align-items: center; gap: 4px; min-width: 0; max-width: 100%; }
1837
1995
  .ttl-board-project-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1838
- .ttl-board-check {
1839
- position: absolute;
1840
- top: 8px;
1841
- left: 8px;
1996
+ /* 勾选圈:表格行首与看板卡片共用(看板只额外负责定位)。 */
1997
+ .ttl-check {
1842
1998
  display: inline-flex;
1843
1999
  align-items: center;
1844
2000
  justify-content: center;
1845
- width: 24px;
1846
- height: 24px;
2001
+ width: 20px;
2002
+ height: 20px;
1847
2003
  padding: 0;
1848
- border: 1.5px solid var(--dsw-alias-border-l3);
2004
+ border: 1.5px solid var(--dsw-alias-border-l4);
1849
2005
  border-radius: 50%;
1850
2006
  background: none;
1851
2007
  color: transparent;
1852
2008
  cursor: pointer;
2009
+ transition: border-color 0.15s, background 0.15s, color 0.15s;
1853
2010
  }
1854
- .ttl-board-check:hover { border-color: var(--dsw-alias-state-success-primary); }
1855
- .ttl-board-check[data-checked='true'] {
2011
+ .ttl-check:hover { border-color: var(--dsw-alias-state-success-primary); }
2012
+ .ttl-check[data-checked='true'] {
1856
2013
  border-color: var(--dsw-alias-state-success-primary);
1857
2014
  background: var(--dsw-alias-state-success-primary);
1858
2015
  color: var(--dsw-alias-label-primary-foreground);
1859
2016
  }
1860
- .ttl-board-check:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: 2px; }
1861
- .ttl-board-check:disabled { opacity: 0.5; cursor: not-allowed; }
2017
+ .ttl-check:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: 2px; }
2018
+ .ttl-check:disabled { opacity: 0.5; cursor: not-allowed; }
2019
+ .ttl-board-check { position: absolute; top: 8px; left: 8px; }
1862
2020
  .ttl-board-more {
1863
2021
  position: absolute;
1864
2022
  top: 6px;
@@ -2119,10 +2277,167 @@
2119
2277
  .ttl-file-chip-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2120
2278
  .ttl-file-chip-icon { font-size: 10px; line-height: 1; }
2121
2279
  .ttl-file-more { color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 20px; }
2122
- .ttl-title-cell { display: flex; flex-direction: column; align-items: flex-start; gap: 3px; min-width: 0; }
2280
+ .ttl-title-cell { display: flex; flex-direction: column; align-items: stretch; gap: 3px; min-width: 0; }
2123
2281
  .ttl-board-card-files { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
2124
2282
  .ttl-calendar-entry-files { display: flex; flex: 1 1 100%; flex-wrap: wrap; gap: 4px; }
2125
2283
  .ttl-detail-files { display: flex; flex-wrap: wrap; gap: 4px; }
2284
+
2285
+ /* 导入 / 导出:轻量浮层对话框,内容超高时只滚动对话框主体。 */
2286
+ .ttl-download-anchor {
2287
+ position: absolute;
2288
+ width: 1px;
2289
+ height: 1px;
2290
+ overflow: hidden;
2291
+ clip: rect(0 0 0 0);
2292
+ white-space: nowrap;
2293
+ }
2294
+ .ttl-overlay {
2295
+ position: absolute;
2296
+ inset: 0;
2297
+ z-index: 30;
2298
+ display: flex;
2299
+ align-items: center;
2300
+ justify-content: center;
2301
+ padding: 24px;
2302
+ background: var(--dsw-alias-bg-mask-1);
2303
+ }
2304
+ .ttl-dialog {
2305
+ display: flex;
2306
+ flex-direction: column;
2307
+ width: min(560px, 100%);
2308
+ max-height: 100%;
2309
+ border: 0.5px solid var(--dsw-alias-border-l2);
2310
+ border-radius: var(--dsw-radius-lg);
2311
+ background: var(--dsw-alias-bg-base);
2312
+ box-shadow: var(--dsw-shadow-lv3);
2313
+ overflow: hidden;
2314
+ }
2315
+ .ttl-dialog-head {
2316
+ display: flex;
2317
+ flex: none;
2318
+ align-items: center;
2319
+ justify-content: space-between;
2320
+ gap: 12px;
2321
+ padding: 14px 16px;
2322
+ border-bottom: 0.5px solid var(--dsw-alias-border-l1);
2323
+ }
2324
+ .ttl-dialog-title {
2325
+ margin: 0;
2326
+ font-size: 15px;
2327
+ font-weight: 600;
2328
+ line-height: 22px;
2329
+ color: var(--dsw-alias-label-primary);
2330
+ }
2331
+ .ttl-dialog-close {
2332
+ display: inline-flex;
2333
+ align-items: center;
2334
+ justify-content: center;
2335
+ width: 26px;
2336
+ height: 26px;
2337
+ padding: 0;
2338
+ border: 0;
2339
+ border-radius: var(--dsw-radius-sm);
2340
+ background: none;
2341
+ color: var(--dsw-alias-label-tertiary);
2342
+ font: inherit;
2343
+ font-size: 18px;
2344
+ line-height: 1;
2345
+ cursor: pointer;
2346
+ }
2347
+ .ttl-dialog-close:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
2348
+ .ttl-dialog-body {
2349
+ display: flex;
2350
+ flex-direction: column;
2351
+ gap: 18px;
2352
+ padding: 16px;
2353
+ overflow-y: auto;
2354
+ }
2355
+ .ttl-dialog-footer {
2356
+ display: flex;
2357
+ flex: none;
2358
+ align-items: center;
2359
+ justify-content: flex-end;
2360
+ gap: 8px;
2361
+ padding: 12px 16px;
2362
+ border-top: 0.5px solid var(--dsw-alias-border-l1);
2363
+ background: var(--dsw-alias-bg-base);
2364
+ }
2365
+ .ttl-transfer-section { display: flex; flex-direction: column; gap: 8px; }
2366
+ .ttl-transfer-heading {
2367
+ margin: 0;
2368
+ font-size: 13px;
2369
+ font-weight: 600;
2370
+ line-height: 20px;
2371
+ color: var(--dsw-alias-label-primary);
2372
+ }
2373
+ .ttl-transfer-hint { margin: 0; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); }
2374
+ .ttl-dialog-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
2375
+ .ttl-dialog-actions-end { justify-content: flex-end; padding-top: 4px; }
2376
+ .ttl-transfer-file {
2377
+ max-width: 100%;
2378
+ overflow: hidden;
2379
+ text-overflow: ellipsis;
2380
+ white-space: nowrap;
2381
+ font-size: 12px;
2382
+ line-height: 18px;
2383
+ color: var(--dsw-alias-label-secondary);
2384
+ }
2385
+ .ttl-file-input { display: none; }
2386
+ .ttl-transfer-error {
2387
+ margin: 0;
2388
+ padding: 8px 10px;
2389
+ border-radius: var(--dsw-radius-sm);
2390
+ background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);
2391
+ color: var(--dsw-alias-state-error-primary);
2392
+ font-size: 12px;
2393
+ line-height: 18px;
2394
+ }
2395
+ .ttl-transfer-preview {
2396
+ display: flex;
2397
+ flex-direction: column;
2398
+ gap: 10px;
2399
+ padding: 12px;
2400
+ border: 0.5px solid var(--dsw-alias-border-l2);
2401
+ border-radius: var(--dsw-radius-md);
2402
+ background: var(--dsw-alias-bg-layer-1);
2403
+ }
2404
+ .ttl-transfer-preview-title { font-size: 12px; font-weight: 600; line-height: 18px; color: var(--dsw-alias-label-primary); }
2405
+ .ttl-transfer-counts { display: flex; flex-wrap: wrap; gap: 6px; }
2406
+ .ttl-transfer-count {
2407
+ padding: 2px 8px;
2408
+ border-radius: 999px;
2409
+ background: var(--dsw-alias-bg-module-platform);
2410
+ color: var(--dsw-alias-label-secondary);
2411
+ font-size: 12px;
2412
+ line-height: 18px;
2413
+ font-variant-numeric: tabular-nums;
2414
+ }
2415
+ .ttl-transfer-rows { margin: 0; font-size: 11px; line-height: 16px; color: var(--dsw-alias-label-tertiary); }
2416
+ .ttl-transfer-invalid {
2417
+ margin: 0;
2418
+ padding-left: 16px;
2419
+ color: var(--dsw-alias-state-error-primary);
2420
+ font-size: 11px;
2421
+ line-height: 17px;
2422
+ }
2423
+ .ttl-transfer-mode { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 12px; color: var(--dsw-alias-label-tertiary); }
2424
+ .ttl-transfer-mode select {
2425
+ height: 28px;
2426
+ padding: 0 6px;
2427
+ border: 0.5px solid var(--dsw-alias-border-l3);
2428
+ border-radius: var(--dsw-radius-sm);
2429
+ background: var(--dsw-alias-bg-layer-1);
2430
+ color: var(--dsw-alias-label-secondary);
2431
+ font: inherit;
2432
+ font-size: 12px;
2433
+ cursor: pointer;
2434
+ }
2435
+ .ttl-transfer-mode-hint { margin: 0; font-size: 11px; line-height: 16px; color: var(--dsw-alias-label-tertiary); }
2436
+ .ttl-transfer-empty { margin: 0; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); }
2437
+ .ttl-overlay .ttl-button { height: 28px; font-size: 12px; }
2438
+ @media (max-width: 560px) {
2439
+ .ttl-overlay { align-items: flex-start; padding: 12px; }
2440
+ }
2126
2441
  @media (prefers-reduced-motion: reduce) {
2127
2442
  .ttl-input,
2128
2443
  .ttl-button,
@@ -2495,6 +2810,16 @@
2495
2810
  const name = normalizeProject(project);
2496
2811
  return name === "" ? t("project.none") : name;
2497
2812
  }
2813
+ var PROJECT_TONES = ["business", "done", "success", "warn", "error"];
2814
+ function projectTone(project) {
2815
+ const name = normalizeProject(project);
2816
+ if (name === "") return "idle";
2817
+ let hash = 0;
2818
+ for (let index = 0; index < name.length; index += 1) {
2819
+ hash = hash * 31 + name.charCodeAt(index) | 0;
2820
+ }
2821
+ return PROJECT_TONES[Math.abs(hash) % PROJECT_TONES.length];
2822
+ }
2498
2823
  function compareProjectNames(left, right) {
2499
2824
  if (left === right) return 0;
2500
2825
  if (left === "") return 1;
@@ -2824,6 +3149,7 @@
2824
3149
  (index) => h(
2825
3150
  "div",
2826
3151
  { key: index, className: "ttl-skeleton-row" },
3152
+ bar(true),
2827
3153
  bar(),
2828
3154
  bar(),
2829
3155
  bar(true),
@@ -2886,7 +3212,54 @@
2886
3212
  function ProjectBadge(props) {
2887
3213
  const project = normalizeProject(props && props.project);
2888
3214
  const className = project === "" ? "ttl-badge ttl-badge-project ttl-badge-project-none" : "ttl-badge ttl-badge-project";
2889
- return h("span", { className, title: projectLabel(project) }, projectLabel(project));
3215
+ return h(
3216
+ "span",
3217
+ { className, title: projectLabel(project), "data-tone": projectTone(project) },
3218
+ h("span", { className: "ttl-project-dot", "aria-hidden": "true" }),
3219
+ h("span", { className: "ttl-project-name" }, projectLabel(project))
3220
+ );
3221
+ }
3222
+ function CheckIcon(props) {
3223
+ const checked = Boolean(props && props.checked);
3224
+ return h(
3225
+ "svg",
3226
+ {
3227
+ width: 12,
3228
+ height: 12,
3229
+ viewBox: "0 0 16 16",
3230
+ fill: "none",
3231
+ stroke: "currentColor",
3232
+ strokeWidth: 2,
3233
+ strokeLinecap: "round",
3234
+ strokeLinejoin: "round",
3235
+ "aria-hidden": "true",
3236
+ focusable: "false"
3237
+ },
3238
+ checked ? h("path", { d: "M3.5 8.5l3 3 6-6.5" }) : null
3239
+ );
3240
+ }
3241
+ function CheckButton(props) {
3242
+ const checked = Boolean(props && props.checked);
3243
+ const disabled = Boolean(props && props.disabled);
3244
+ const label = props && typeof props.label === "string" ? props.label : "";
3245
+ const hover = props && typeof props.title === "string" && props.title !== "" ? props.title : void 0;
3246
+ const className = props && typeof props.className === "string" && props.className !== "" ? props.className : "";
3247
+ const onClick = props && typeof props.onClick === "function" ? props.onClick : null;
3248
+ return h(
3249
+ "button",
3250
+ {
3251
+ type: "button",
3252
+ className: className === "" ? "ttl-check" : `ttl-check ${className}`,
3253
+ role: "checkbox",
3254
+ "aria-checked": checked ? "true" : "false",
3255
+ "aria-label": label,
3256
+ title: hover,
3257
+ "data-checked": checked ? "true" : "false",
3258
+ disabled,
3259
+ onClick
3260
+ },
3261
+ h(CheckIcon, { checked })
3262
+ );
2890
3263
  }
2891
3264
  function GanttView(props) {
2892
3265
  const todos = props && Array.isArray(props.todos) ? props.todos : [];
@@ -3555,22 +3928,6 @@
3555
3928
  )
3556
3929
  );
3557
3930
  };
3558
- const boardCheckIcon = (completed) => h(
3559
- "svg",
3560
- {
3561
- width: 12,
3562
- height: 12,
3563
- viewBox: "0 0 16 16",
3564
- fill: "none",
3565
- stroke: "currentColor",
3566
- strokeWidth: 2,
3567
- strokeLinecap: "round",
3568
- strokeLinejoin: "round",
3569
- "aria-hidden": "true",
3570
- focusable: "false"
3571
- },
3572
- completed ? h("path", { d: "M3.5 8.5l3 3 6-6.5" }) : null
3573
- );
3574
3931
  const renderBoardCard = (todo) => {
3575
3932
  const isEditing = editingId === todo.id;
3576
3933
  const isConfirming = confirmId === todo.id;
@@ -3609,23 +3966,16 @@
3609
3966
  prioritySelectControl(editingPriority, setEditingPriority, busyId === todo.id),
3610
3967
  typeSelectControl(editingType, setEditingType, busyId === todo.id)
3611
3968
  ) : [
3612
- h(
3613
- "button",
3614
- {
3615
- key: "check",
3616
- type: "button",
3617
- className: "ttl-board-check",
3618
- role: "checkbox",
3619
- "aria-checked": completed ? "true" : "false",
3620
- "aria-label": t("board.checkAria", { title }),
3621
- "data-checked": completed ? "true" : "false",
3622
- disabled: isBusy,
3623
- onClick: () => {
3624
- void handleToggle(todo);
3625
- }
3626
- },
3627
- boardCheckIcon(completed)
3628
- ),
3969
+ h(CheckButton, {
3970
+ key: "check",
3971
+ checked: completed,
3972
+ disabled: isBusy,
3973
+ label: t("board.checkAria", { title }),
3974
+ className: "ttl-board-check",
3975
+ onClick: () => {
3976
+ void handleToggle(todo);
3977
+ }
3978
+ }),
3629
3979
  h(
3630
3980
  "div",
3631
3981
  { key: "main", className: "ttl-board-card-main" },
@@ -3639,8 +3989,8 @@
3639
3989
  "span",
3640
3990
  { className: "ttl-board-project", title: projectLabel(project) },
3641
3991
  h("span", {
3642
- className: "ttl-board-dot",
3643
- "data-status": "project",
3992
+ className: "ttl-project-dot",
3993
+ "data-tone": projectTone(project),
3644
3994
  "aria-hidden": "true"
3645
3995
  }),
3646
3996
  h("span", { className: "ttl-board-project-name" }, projectLabel(project))
@@ -4089,7 +4439,7 @@
4089
4439
 
4090
4440
  // src/client/views/table.js
4091
4441
  function createTableView(deps) {
4092
- const { confirmId, detailIdOf, direction, editingId, emptyActionKey, expandedIds, filter, handleEmptyAction, handleHeaderSort, listStatus, priorityBadge, projectInput, renderActionButtons, renderDetailContent, renderFileChips, setConfirmId, sortBy, statusBadge, titleInput, toggleExpanded, typeBadge, visibleTodos } = deps;
4442
+ const { confirmId, detailIdOf, direction, editingId, emptyActionKey, expandedIds, filter, handleEmptyAction, handleHeaderSort, listStatus, priorityBadge, projectInput, renderActionButtons, renderDetailContent, renderFileChips, renderRowCheck, setConfirmId, sortBy, statusBadge, titleInput, toggleExpanded, typeBadge, visibleTodos } = deps;
4093
4443
  const pushTableRow = (rows, todo) => {
4094
4444
  const completed = todo.status === "completed";
4095
4445
  const isEditing = editingId === todo.id;
@@ -4131,6 +4481,7 @@
4131
4481
  }
4132
4482
  }
4133
4483
  },
4484
+ h("td", { className: "ttl-td ttl-td-check" }, renderRowCheck(todo)),
4134
4485
  titleCell,
4135
4486
  h(
4136
4487
  "td",
@@ -4144,7 +4495,7 @@
4144
4495
  ),
4145
4496
  h("td", { className: "ttl-td ttl-td-time" }, formatTime(todo.createdAt)),
4146
4497
  h("td", { className: "ttl-td ttl-td-time" }, completed ? formatTime(todo.completedAt) : EMPTY_TIME),
4147
- h("td", { className: "ttl-td ttl-td-actions" }, renderActionButtons(todo, { showDetail: true }))
4498
+ h("td", { className: "ttl-td ttl-td-actions" }, renderActionButtons(todo, { showDetail: true, showToggle: false }))
4148
4499
  )
4149
4500
  );
4150
4501
  if (expanded) {
@@ -4207,6 +4558,11 @@
4207
4558
  "aria-label": t("table.aria"),
4208
4559
  "aria-busy": listStatus === "loading" ? "true" : "false"
4209
4560
  },
4561
+ h(
4562
+ "colgroup",
4563
+ null,
4564
+ TABLE_COLUMNS.map((column) => h("col", { key: column.key, className: `ttl-col ttl-col-${column.key}` }))
4565
+ ),
4210
4566
  h(
4211
4567
  "thead",
4212
4568
  null,
@@ -4221,10 +4577,10 @@
4221
4577
  {
4222
4578
  key: column.key,
4223
4579
  scope: "col",
4224
- className: (column.sortBy === null ? "ttl-th" : "ttl-th ttl-th-sortable") + (column.align === "right" ? " ttl-th-right" : ""),
4580
+ className: (column.sortBy === null ? "ttl-th" : "ttl-th ttl-th-sortable") + (column.align === "right" ? " ttl-th-right" : "") + ` ttl-th-${column.key}`,
4225
4581
  "aria-sort": column.sortBy === null ? void 0 : ariaSort
4226
4582
  },
4227
- column.sortBy === null ? h("span", null, t(column.labelKey)) : h(
4583
+ column.srOnly === true ? h("span", { className: "ttl-sr-only" }, t(column.labelKey)) : column.sortBy === null ? h("span", null, t(column.labelKey)) : h(
4228
4584
  "button",
4229
4585
  {
4230
4586
  type: "button",
@@ -4247,6 +4603,245 @@
4247
4603
  return renderTableView;
4248
4604
  }
4249
4605
 
4606
+ // src/client/transfer.js
4607
+ function makeDownload(content, filename, mime) {
4608
+ const text = typeof content === "string" ? content : "";
4609
+ const name = typeof filename === "string" && filename !== "" ? filename : "todos.json";
4610
+ const type = typeof mime === "string" && mime !== "" ? mime : "application/octet-stream";
4611
+ try {
4612
+ if (typeof Blob !== "undefined" && typeof URL !== "undefined" && typeof URL.createObjectURL === "function") {
4613
+ return { url: URL.createObjectURL(new Blob([text], { type })), name };
4614
+ }
4615
+ } catch {
4616
+ }
4617
+ return { url: `data:${type},${encodeURIComponent(text)}`, name };
4618
+ }
4619
+ function readFileText(file) {
4620
+ if (file && typeof file.text === "function") {
4621
+ return file.text().then((text) => String(text ?? ""));
4622
+ }
4623
+ return new Promise((resolve, reject) => {
4624
+ const reader = new FileReader();
4625
+ reader.onload = () => resolve(String(reader.result ?? ""));
4626
+ reader.onerror = () => reject(new Error("读取文件失败"));
4627
+ reader.readAsText(file, "utf-8");
4628
+ });
4629
+ }
4630
+ function countChip(labelKey, count) {
4631
+ const value = Number(count);
4632
+ return h(
4633
+ "span",
4634
+ { className: "ttl-transfer-count", key: labelKey },
4635
+ t(labelKey, { count: Number.isFinite(value) && value > 0 ? Math.floor(value) : 0 })
4636
+ );
4637
+ }
4638
+ function TransferDialog(props) {
4639
+ const {
4640
+ open,
4641
+ busy,
4642
+ applying,
4643
+ fileName,
4644
+ mode,
4645
+ preview,
4646
+ error,
4647
+ fileInputRef,
4648
+ onClose,
4649
+ onExport,
4650
+ onPickFile,
4651
+ onFileChange,
4652
+ onModeChange,
4653
+ onConfirm
4654
+ } = props;
4655
+ if (open !== true) return null;
4656
+ const record = isRecord(preview) ? preview : null;
4657
+ const summary = record !== null && isRecord(record.summary) ? record.summary : null;
4658
+ const invalid = record !== null && Array.isArray(record.invalid) ? record.invalid.slice(0, 5) : [];
4659
+ const invalidCount = record !== null && typeof record.invalidCount === "number" ? record.invalidCount : 0;
4660
+ const rowCount = record !== null && typeof record.rowCount === "number" ? record.rowCount : 0;
4661
+ const activeMode = TRANSFER_MODES.find((option) => option.value === mode) ?? TRANSFER_MODES[0];
4662
+ const canConfirm = summary !== null && busy !== true && applying !== true;
4663
+ const backdrop = (event) => {
4664
+ if (event && event.target === event.currentTarget) onClose();
4665
+ };
4666
+ return h(
4667
+ "div",
4668
+ { className: "ttl-overlay", role: "presentation", onMouseDown: backdrop },
4669
+ h(
4670
+ "div",
4671
+ {
4672
+ className: "ttl-dialog",
4673
+ role: "dialog",
4674
+ "aria-modal": "true",
4675
+ "aria-labelledby": "ttl-transfer-title",
4676
+ onKeyDown: (event) => {
4677
+ if (event && event.key === "Escape") {
4678
+ event.stopPropagation();
4679
+ onClose();
4680
+ }
4681
+ }
4682
+ },
4683
+ h(
4684
+ "div",
4685
+ { className: "ttl-dialog-head" },
4686
+ h("h2", { id: "ttl-transfer-title", className: "ttl-dialog-title" }, t("transfer.title")),
4687
+ h(
4688
+ "button",
4689
+ {
4690
+ type: "button",
4691
+ className: "ttl-dialog-close",
4692
+ onClick: onClose,
4693
+ disabled: busy === true,
4694
+ "aria-label": t("transfer.close"),
4695
+ autoFocus: true
4696
+ },
4697
+ "×"
4698
+ )
4699
+ ),
4700
+ h(
4701
+ "div",
4702
+ { className: "ttl-dialog-body" },
4703
+ // 导出
4704
+ h(
4705
+ "section",
4706
+ { className: "ttl-transfer-section" },
4707
+ h("h3", { className: "ttl-transfer-heading" }, t("transfer.exportTitle")),
4708
+ h("p", { className: "ttl-transfer-hint" }, t("transfer.exportHint")),
4709
+ h(
4710
+ "div",
4711
+ { className: "ttl-dialog-actions" },
4712
+ h(
4713
+ "button",
4714
+ {
4715
+ type: "button",
4716
+ className: "ttl-button ttl-button-outline",
4717
+ disabled: busy === true,
4718
+ onClick: () => onExport("json")
4719
+ },
4720
+ t("transfer.exportJson")
4721
+ ),
4722
+ h(
4723
+ "button",
4724
+ {
4725
+ type: "button",
4726
+ className: "ttl-button ttl-button-outline",
4727
+ disabled: busy === true,
4728
+ onClick: () => onExport("csv")
4729
+ },
4730
+ t("transfer.exportCsv")
4731
+ )
4732
+ )
4733
+ ),
4734
+ // 导入
4735
+ h(
4736
+ "section",
4737
+ { className: "ttl-transfer-section" },
4738
+ h("h3", { className: "ttl-transfer-heading" }, t("transfer.importTitle")),
4739
+ h("p", { className: "ttl-transfer-hint" }, t("transfer.importHint")),
4740
+ h(
4741
+ "div",
4742
+ { className: "ttl-dialog-actions" },
4743
+ h(
4744
+ "button",
4745
+ {
4746
+ type: "button",
4747
+ className: "ttl-button ttl-button-outline",
4748
+ disabled: busy === true,
4749
+ onClick: onPickFile
4750
+ },
4751
+ t("transfer.chooseFile")
4752
+ ),
4753
+ h(
4754
+ "span",
4755
+ { className: "ttl-transfer-file", title: typeof fileName === "string" ? fileName : "" },
4756
+ typeof fileName === "string" && fileName !== "" ? fileName : t("transfer.noFile")
4757
+ )
4758
+ ),
4759
+ h("input", {
4760
+ ref: fileInputRef,
4761
+ className: "ttl-file-input",
4762
+ type: "file",
4763
+ accept: ".json,.csv,application/json,text/csv",
4764
+ tabIndex: -1,
4765
+ "aria-label": t("transfer.chooseFile"),
4766
+ onChange: onFileChange
4767
+ }),
4768
+ typeof error === "string" && error !== "" ? h("p", { className: "ttl-transfer-error", role: "alert" }, error) : null,
4769
+ summary !== null ? h(
4770
+ "div",
4771
+ { className: "ttl-transfer-preview" },
4772
+ h("div", { className: "ttl-transfer-preview-title" }, t("transfer.previewTitle")),
4773
+ h(
4774
+ "div",
4775
+ { className: "ttl-transfer-counts" },
4776
+ countChip("transfer.previewAdded", summary.added),
4777
+ countChip("transfer.previewUpdated", summary.updated),
4778
+ countChip("transfer.previewSkipped", summary.skipped),
4779
+ Number(summary.removed) > 0 ? countChip("transfer.previewRemoved", summary.removed) : null,
4780
+ countChip("transfer.previewTotal", summary.total)
4781
+ ),
4782
+ rowCount > 0 || invalidCount > 0 ? h(
4783
+ "p",
4784
+ { className: "ttl-transfer-rows" },
4785
+ t("transfer.previewRows", { count: rowCount }),
4786
+ invalidCount > 0 ? ` · ${t("transfer.previewInvalid", { count: invalidCount })}` : ""
4787
+ ) : null,
4788
+ invalid.length > 0 ? h(
4789
+ "ul",
4790
+ { className: "ttl-transfer-invalid" },
4791
+ invalid.map((item, index) => h(
4792
+ "li",
4793
+ { key: `invalid-${index}` },
4794
+ t("transfer.rowError", {
4795
+ row: isRecord(item) ? Number(item.row) || 0 : 0,
4796
+ message: isRecord(item) && typeof item.message === "string" ? item.message : ""
4797
+ })
4798
+ ))
4799
+ ) : null,
4800
+ h(
4801
+ "div",
4802
+ { className: "ttl-transfer-mode" },
4803
+ h("span", { className: "ttl-transfer-mode-label" }, t("transfer.modeLabel")),
4804
+ h(
4805
+ "select",
4806
+ {
4807
+ value: typeof mode === "string" ? mode : "merge",
4808
+ disabled: busy === true,
4809
+ "aria-label": t("transfer.modeLabel"),
4810
+ onChange: (event) => onModeChange(event.target.value)
4811
+ },
4812
+ TRANSFER_MODES.map((option) => h(
4813
+ "option",
4814
+ { key: option.value, value: option.value },
4815
+ t(option.labelKey)
4816
+ ))
4817
+ )
4818
+ ),
4819
+ h("p", { className: "ttl-transfer-mode-hint" }, t(activeMode.hintKey))
4820
+ ) : h(
4821
+ "p",
4822
+ { className: "ttl-transfer-empty" },
4823
+ busy === true ? t("transfer.reading") : t("transfer.noPreview")
4824
+ )
4825
+ )
4826
+ ),
4827
+ h(
4828
+ "div",
4829
+ { className: "ttl-dialog-footer" },
4830
+ h(
4831
+ "button",
4832
+ {
4833
+ type: "button",
4834
+ className: "ttl-button ttl-button-primary",
4835
+ disabled: canConfirm !== true,
4836
+ onClick: onConfirm
4837
+ },
4838
+ applying === true ? t("transfer.importing") : t("transfer.confirm")
4839
+ )
4840
+ )
4841
+ )
4842
+ );
4843
+ }
4844
+
4250
4845
  // src/client/panel.js
4251
4846
  function TodoPanel() {
4252
4847
  const [todos, setTodos] = React.useState([]);
@@ -4265,6 +4860,15 @@
4265
4860
  const [editTitle, setEditTitle] = React.useState("");
4266
4861
  const [confirmId, setConfirmId] = React.useState(null);
4267
4862
  const [notice, setNotice] = React.useState(null);
4863
+ const [transferOpen, setTransferOpen] = React.useState(false);
4864
+ const [transferBusy, setTransferBusy] = React.useState(false);
4865
+ const [transferApplying, setTransferApplying] = React.useState(false);
4866
+ const [transferError, setTransferError] = React.useState("");
4867
+ const [transferFileName, setTransferFileName] = React.useState("");
4868
+ const [transferContent, setTransferContent] = React.useState("");
4869
+ const [transferMode, setTransferMode] = React.useState("merge");
4870
+ const [transferPreview, setTransferPreview] = React.useState(null);
4871
+ const [download, setDownload] = React.useState({ url: "", name: "" });
4268
4872
  const [expandedIds, setExpandedIds] = React.useState({});
4269
4873
  const [view, setView] = React.useState("table");
4270
4874
  const [range, setRange] = React.useState("all");
@@ -4325,6 +4929,8 @@
4325
4929
  const projectSeqRef = React.useRef(0);
4326
4930
  const suggestKindRef = React.useRef("file");
4327
4931
  const createBlurTimerRef = React.useRef(null);
4932
+ const transferInputRef = React.useRef(null);
4933
+ const downloadRef = React.useRef(null);
4328
4934
  React.useEffect(() => {
4329
4935
  mountedRef.current = true;
4330
4936
  return () => {
@@ -4350,6 +4956,21 @@
4350
4956
  }, delay);
4351
4957
  return () => clearTimeout(timer);
4352
4958
  }, [notice]);
4959
+ React.useEffect(() => {
4960
+ if (download.url === "" || downloadRef.current === null) return void 0;
4961
+ downloadRef.current.click();
4962
+ const url = download.url;
4963
+ const timer = setTimeout(() => {
4964
+ try {
4965
+ if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
4966
+ URL.revokeObjectURL(url);
4967
+ }
4968
+ } catch {
4969
+ }
4970
+ if (mountedRef.current) setDownload({ url: "", name: "" });
4971
+ }, 2e3);
4972
+ return () => clearTimeout(timer);
4973
+ }, [download]);
4353
4974
  const loadList = React.useCallback(
4354
4975
  async (nextFilter, nextSortBy, nextDirection, nextProject, options) => {
4355
4976
  const quiet = Boolean(options && options.quiet);
@@ -5037,7 +5658,9 @@
5037
5658
  const detailIdOf = (todo) => `ttl-detail-${String(todo.id).replace(/[^A-Za-z0-9_-]/g, "-")}`;
5038
5659
  const statusBadge = (todo) => h(
5039
5660
  "span",
5040
- { className: todo.status === "completed" ? "ttl-badge ttl-badge-completed" : "ttl-badge" },
5661
+ {
5662
+ className: todo.status === "completed" ? "ttl-badge ttl-status-badge ttl-badge-completed" : "ttl-badge ttl-status-badge"
5663
+ },
5041
5664
  t(todo.status === "completed" ? "row.completed" : "row.pending")
5042
5665
  );
5043
5666
  const priorityBadge = (todo) => {
@@ -5049,6 +5672,11 @@
5049
5672
  className: `ttl-badge ttl-priority-badge ttl-priority-${priority}`,
5050
5673
  "data-priority": priority
5051
5674
  },
5675
+ h(
5676
+ "span",
5677
+ { className: "ttl-priority-arrow", "aria-hidden": "true" },
5678
+ priority === "high" ? "↑" : priority === "medium" ? "↗" : "↓"
5679
+ ),
5052
5680
  t(`priority.${priority}`)
5053
5681
  );
5054
5682
  };
@@ -5171,22 +5799,24 @@
5171
5799
  return buttons;
5172
5800
  }
5173
5801
  if (opts.showDetail === true) buttons.push(detailToggleButton(todo));
5174
- buttons.push(
5175
- h(
5176
- "button",
5177
- {
5178
- key: "toggle",
5179
- type: "button",
5180
- className: "ttl-link",
5181
- disabled: isBusy,
5182
- "aria-label": t("row.toggleAria", { action: toggleLabel, title: todo.title }),
5183
- onClick: () => {
5184
- void handleToggle(todo);
5185
- }
5186
- },
5187
- toggleLabel
5188
- )
5189
- );
5802
+ if (opts.showToggle !== false) {
5803
+ buttons.push(
5804
+ h(
5805
+ "button",
5806
+ {
5807
+ key: "toggle",
5808
+ type: "button",
5809
+ className: "ttl-link",
5810
+ disabled: isBusy,
5811
+ "aria-label": t("row.toggleAria", { action: toggleLabel, title: todo.title }),
5812
+ onClick: () => {
5813
+ void handleToggle(todo);
5814
+ }
5815
+ },
5816
+ toggleLabel
5817
+ )
5818
+ );
5819
+ }
5190
5820
  buttons.push(
5191
5821
  h(
5192
5822
  "button",
@@ -5245,6 +5875,19 @@
5245
5875
  }
5246
5876
  return buttons;
5247
5877
  };
5878
+ const renderRowCheck = (todo) => {
5879
+ const completed = todo.status === "completed";
5880
+ return h(CheckButton, {
5881
+ checked: completed,
5882
+ disabled: isBusy,
5883
+ label: t("board.checkAria", { title: todo.title === "" ? t("list.empty") : todo.title }),
5884
+ title: t(completed ? "row.uncomplete" : "row.complete"),
5885
+ className: "ttl-row-check",
5886
+ onClick: () => {
5887
+ void handleToggle(todo);
5888
+ }
5889
+ });
5890
+ };
5248
5891
  const copyFilePath = (path) => {
5249
5892
  try {
5250
5893
  if (typeof navigator === "undefined" || navigator === null) return;
@@ -5405,6 +6048,128 @@
5405
6048
  if (mountedRef.current) setBusyId(null);
5406
6049
  }
5407
6050
  };
6051
+ const openTransfer = () => {
6052
+ if (transferBusy) return;
6053
+ setTransferError("");
6054
+ setTransferPreview(null);
6055
+ setTransferFileName("");
6056
+ setTransferContent("");
6057
+ setTransferMode("merge");
6058
+ setTransferOpen(true);
6059
+ };
6060
+ const closeTransfer = () => {
6061
+ if (transferBusy) return;
6062
+ setTransferOpen(false);
6063
+ };
6064
+ const runImportPreview = async (content, mode) => {
6065
+ setTransferBusy(true);
6066
+ setTransferError("");
6067
+ try {
6068
+ const value = await request("/import", { content, mode, dryRun: true });
6069
+ if (!mountedRef.current) return;
6070
+ setTransferPreview(value);
6071
+ } catch (error) {
6072
+ if (!mountedRef.current) return;
6073
+ setTransferPreview(null);
6074
+ setTransferError(messageOf(error));
6075
+ } finally {
6076
+ if (mountedRef.current) setTransferBusy(false);
6077
+ }
6078
+ };
6079
+ const handleTransferModeChange = (nextMode) => {
6080
+ if (transferBusy || transferContent === "") return;
6081
+ setTransferMode(nextMode);
6082
+ void runImportPreview(transferContent, nextMode);
6083
+ };
6084
+ const handleExport = async (format) => {
6085
+ if (transferBusy) return;
6086
+ setTransferBusy(true);
6087
+ setTransferError("");
6088
+ try {
6089
+ const value = await request("/export", { format });
6090
+ if (!mountedRef.current) return;
6091
+ setDownload(makeDownload(value.content, value.filename, value.mime));
6092
+ showNotice("success", t("transfer.exported", {
6093
+ count: Number(value.count) || 0,
6094
+ format: String(value.format || format).toUpperCase()
6095
+ }));
6096
+ setTransferOpen(false);
6097
+ } catch (error) {
6098
+ if (!mountedRef.current) return;
6099
+ setTransferError(messageOf(error));
6100
+ } finally {
6101
+ if (mountedRef.current) setTransferBusy(false);
6102
+ }
6103
+ };
6104
+ const handlePickFile = () => {
6105
+ const input = transferInputRef.current;
6106
+ if (input && typeof input.click === "function") input.click();
6107
+ };
6108
+ const handleImportFileChange = async (event) => {
6109
+ const target = event && event.target ? event.target : null;
6110
+ const files = target && target.files ? target.files : null;
6111
+ const file = files && files.length > 0 ? files[0] : null;
6112
+ if (target) target.value = "";
6113
+ if (file === null) return;
6114
+ let content = "";
6115
+ try {
6116
+ content = await readFileText(file);
6117
+ } catch (error) {
6118
+ if (mountedRef.current) setTransferError(t("transfer.readError", { message: messageOf(error) }));
6119
+ return;
6120
+ }
6121
+ if (!mountedRef.current) return;
6122
+ if (content.trim() === "") {
6123
+ setTransferPreview(null);
6124
+ setTransferError(t("transfer.emptyFile"));
6125
+ return;
6126
+ }
6127
+ setTransferFileName(typeof file.name === "string" ? file.name : "");
6128
+ setTransferContent(content);
6129
+ void runImportPreview(content, transferMode);
6130
+ };
6131
+ const handleRestoreBackup = async (backupFile) => {
6132
+ if (typeof backupFile !== "string" || backupFile === "") return;
6133
+ try {
6134
+ const value = await request("/restore", { file: backupFile });
6135
+ if (!mountedRef.current) return;
6136
+ showNotice("success", notifyText(value, "transfer.undone"));
6137
+ void fetchProjectSuggestions(workspaceRef.current, true);
6138
+ await reload(true);
6139
+ } catch (error) {
6140
+ showNotice("error", messageOf(error));
6141
+ }
6142
+ };
6143
+ const handleImportConfirm = async () => {
6144
+ if (transferBusy || transferContent === "") return;
6145
+ setTransferBusy(true);
6146
+ setTransferApplying(true);
6147
+ setTransferError("");
6148
+ try {
6149
+ const value = await request("/import", { content: transferContent, mode: transferMode });
6150
+ if (!mountedRef.current) return;
6151
+ setTransferOpen(false);
6152
+ setTransferPreview(null);
6153
+ setTransferContent("");
6154
+ setTransferFileName("");
6155
+ const backup = value && typeof value.backup === "string" ? value.backup : "";
6156
+ showNotice("success", notifyText(value, "transfer.imported"), backup === "" ? void 0 : {
6157
+ onClick: () => {
6158
+ void handleRestoreBackup(backup);
6159
+ }
6160
+ });
6161
+ void fetchProjectSuggestions(workspaceRef.current, true);
6162
+ await reload(true);
6163
+ } catch (error) {
6164
+ if (!mountedRef.current) return;
6165
+ setTransferError(messageOf(error));
6166
+ } finally {
6167
+ if (mountedRef.current) {
6168
+ setTransferBusy(false);
6169
+ setTransferApplying(false);
6170
+ }
6171
+ }
6172
+ };
5408
6173
  const shiftCalendarMonth = (delta) => {
5409
6174
  const total = calendarYear * 12 + calendarMonth + delta;
5410
6175
  setCalendarYear(Math.floor(total / 12));
@@ -5464,6 +6229,7 @@
5464
6229
  renderActionButtons,
5465
6230
  renderDetailContent,
5466
6231
  renderFileChips,
6232
+ renderRowCheck,
5467
6233
  setBoardActionsId,
5468
6234
  setBoardLaneCollapsed,
5469
6235
  setBoardShowAllCompleted,
@@ -5753,6 +6519,15 @@
5753
6519
  "div",
5754
6520
  { className: "ttl-root", "data-dsh-todo-time-log": "panel", onKeyDown: handlePanelKeyDown },
5755
6521
  h(StyleSheet),
6522
+ // 下载锚点:makeDownload() 生成 blob URL 后由 effect 通过 ref 点击。
6523
+ h("a", {
6524
+ ref: downloadRef,
6525
+ className: "ttl-download-anchor",
6526
+ href: download.url === "" ? void 0 : download.url,
6527
+ download: download.name === "" ? void 0 : download.name,
6528
+ "aria-hidden": "true",
6529
+ tabIndex: -1
6530
+ }),
5756
6531
  h(
5757
6532
  "div",
5758
6533
  { className: "ttl-scroll" },
@@ -5772,6 +6547,35 @@
5772
6547
  h(
5773
6548
  "div",
5774
6549
  { className: "ttl-heading-actions" },
6550
+ h(
6551
+ "button",
6552
+ {
6553
+ type: "button",
6554
+ className: "ttl-ghost-button",
6555
+ "aria-haspopup": "dialog",
6556
+ "aria-expanded": transferOpen ? "true" : "false",
6557
+ title: t("transfer.open"),
6558
+ onClick: openTransfer
6559
+ },
6560
+ h(
6561
+ "svg",
6562
+ {
6563
+ className: "ttl-ghost-button-icon",
6564
+ width: 12,
6565
+ height: 12,
6566
+ viewBox: "0 0 16 16",
6567
+ fill: "none",
6568
+ stroke: "currentColor",
6569
+ strokeWidth: 1.4,
6570
+ strokeLinecap: "round",
6571
+ strokeLinejoin: "round",
6572
+ "aria-hidden": "true",
6573
+ focusable: "false"
6574
+ },
6575
+ h("path", { d: "M8 2v7m0 0l3-3m-3 3L5 6M3 12v1.5a.5.5 0 0 0 .5.5h9a.5.5 0 0 0 .5-.5V12" })
6576
+ ),
6577
+ t("transfer.open")
6578
+ ),
5775
6579
  h(
5776
6580
  "button",
5777
6581
  {
@@ -5954,7 +6758,23 @@
5954
6758
  "×"
5955
6759
  )
5956
6760
  )
5957
- )
6761
+ ),
6762
+ h(TransferDialog, {
6763
+ open: transferOpen,
6764
+ busy: transferBusy,
6765
+ applying: transferApplying,
6766
+ fileName: transferFileName,
6767
+ mode: transferMode,
6768
+ preview: transferPreview,
6769
+ error: transferError,
6770
+ fileInputRef: transferInputRef,
6771
+ onClose: closeTransfer,
6772
+ onExport: handleExport,
6773
+ onPickFile: handlePickFile,
6774
+ onFileChange: handleImportFileChange,
6775
+ onModeChange: handleTransferModeChange,
6776
+ onConfirm: handleImportConfirm
6777
+ })
5958
6778
  );
5959
6779
  }
5960
6780
  function createPanel() {