dsh-worktodo 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,6 @@
1
1
  import React from 'react';
2
+ // 与宿主共用同一份解析器:人机一致,且零依赖(会被 esbuild 内联进 bundle)
3
+ import { parseNaturalDue } from '../parse-due.mjs';
2
4
 
3
5
  /**
4
6
  * worktodo 浏览器半侧:DSH 侧栏入口 + 中栏「工作待办」面板。
@@ -63,6 +65,26 @@ const zh = {
63
65
  'groupWeek': '本周',
64
66
  'groupLater': '以后',
65
67
  'groupCompleted': '已完成',
68
+ 'viewDue': '按截止',
69
+ 'viewProject': '按项目',
70
+ 'noProject': '未归类',
71
+ 'sortLabel': '排序',
72
+ 'sortManual': '手动',
73
+ 'sortPriority': '优先级',
74
+ 'sortCreated': '创建时间',
75
+ 'dragHint': '拖动调整顺序(会自动切到手动排序)',
76
+ 'versionLabel': '版本',
77
+ 'versionHint': '当前安装的 dsh-worktodo 版本',
78
+ 'updateAvailable': '有新版本',
79
+ 'updateHint': '更新命令(重启 DSH 生效)',
80
+ 'addOpen': '新增待办',
81
+ 'addCancel': '取消',
82
+ 'prioNone': '无',
83
+ 'dateUnset': '日期 · 未设',
84
+ 'timeUnset': '时间 · 未设',
85
+ 'todayWord': '今天',
86
+ 'fromParsed': '识别',
87
+ 'dueTimeLabel': '时间',
66
88
  'expand': '展开',
67
89
  'collapse': '收起',
68
90
  'empty': '还没有待办',
@@ -122,6 +144,26 @@ const en = {
122
144
  'groupWeek': 'This week',
123
145
  'groupLater': 'Later',
124
146
  'groupCompleted': 'Completed',
147
+ 'viewDue': 'By due',
148
+ 'viewProject': 'By project',
149
+ 'noProject': 'Uncategorized',
150
+ 'sortLabel': 'Sort',
151
+ 'sortManual': 'Manual',
152
+ 'sortPriority': 'Priority',
153
+ 'sortCreated': 'Created',
154
+ 'dragHint': 'Drag to reorder (switches to manual sort)',
155
+ 'versionLabel': 'Version',
156
+ 'versionHint': 'Installed dsh-worktodo version',
157
+ 'updateAvailable': 'Update available',
158
+ 'updateHint': 'Update command (restart DSH to apply)',
159
+ 'addOpen': 'New todo',
160
+ 'addCancel': 'Cancel',
161
+ 'prioNone': 'None',
162
+ 'dateUnset': 'Date · unset',
163
+ 'timeUnset': 'Time · unset',
164
+ 'todayWord': 'Today',
165
+ 'fromParsed': 'parsed',
166
+ 'dueTimeLabel': 'Time',
125
167
  'expand': 'Expand',
126
168
  'collapse': 'Collapse',
127
169
  'empty': 'No to-dos yet',
@@ -158,6 +200,13 @@ const en = {
158
200
  * 不写任何颜色字面量兜底——兜底值等于写死某一种主题下的颜色,另一套主题就花了。
159
201
  * 需要「淡淡的底色」时用 color-mix 把语义色混进 transparent,仍然是变量驱动。
160
202
  * ------------------------------------------------------------------ */
203
+ /** 本地时区的 YYYY-MM-DD(面板里判断"今天 / 逾期"用;与宿主 records.mjs 的 dayKey 同语义)。 */
204
+ function dayKey(stamp) {
205
+ const d = new Date(stamp);
206
+ const pad = (n) => String(n).padStart(2, '0');
207
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
208
+ }
209
+
161
210
  const C = {
162
211
  label: 'var(--dsw-alias-label-primary)',
163
212
  label2: 'var(--dsw-alias-label-secondary)',
@@ -228,6 +277,32 @@ const STYLE_TEXT = `
228
277
  flex-direction: column; align-items: stretch; gap: 6px;
229
278
  border: 1px solid ${C.border1}; background: ${C.layer1};
230
279
  }
280
+ .wt-seg {
281
+ display: inline-flex; align-items: center; border-radius: 8px; overflow: hidden;
282
+ border: 1px solid ${C.border}; background: ${C.bg2};
283
+ }
284
+ .wt-seg-btn {
285
+ appearance: none; border: 0; background: transparent; color: ${C.label2};
286
+ font: inherit; font-size: 11.5px; line-height: 1; padding: 5px 9px; cursor: pointer;
287
+ }
288
+ .wt-seg-btn.is-on { background: ${C.hover}; color: ${C.label}; font-weight: 600; }
289
+ .wt-seg-btn:hover { color: ${C.label}; }
290
+ .wt-row[draggable="true"] { cursor: grab; }
291
+ .wt-row[draggable="true"]:active { cursor: grabbing; }
292
+ .wt-plus {
293
+ width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px;
294
+ padding: 12px 14px; margin-bottom: 10px; cursor: pointer; font: inherit; font-size: 13.5px; font-weight: 600;
295
+ color: ${C.label2}; background: ${C.layer1};
296
+ border: 1px dashed ${C.border}; border-radius: 12px;
297
+ }
298
+ .wt-plus:hover { color: ${C.label}; border-color: ${C.brand}; background: ${tint(C.brand, 8)}; }
299
+ .wt-plus-mark { font-size: 24px; line-height: 1; font-weight: 400; color: ${C.brand}; }
300
+ .wt-flag-btn {
301
+ appearance: none; display: inline-flex; align-items: center; gap: 5px; cursor: pointer;
302
+ font: inherit; font-size: 11.5px; padding: 5px 9px; border-radius: 9px;
303
+ background: transparent; border: 1px solid ${C.border}; color: ${C.label2};
304
+ }
305
+ .wt-flag-btn.is-on { border-color: currentColor; background: color-mix(in srgb, currentColor 12%, transparent); color: inherit; }
231
306
  .wt-group-head {
232
307
  display: flex; align-items: center; gap: 7px; padding: 3px 8px; border-radius: 8px;
233
308
  cursor: pointer; user-select: none; color: ${C.label2};
@@ -362,8 +437,10 @@ async function postJson(path, body) {
362
437
  * (stale-while-revalidate)。侧栏来回点的时候不该每次都转圈。
363
438
  */
364
439
  let lastList = null;
440
+ // 排序模式跨「切走再切回」保持;manual = 尊重拖动顺序
441
+ let lastSort = 'manual';
365
442
 
366
- function useList() {
443
+ function useList(sort) {
367
444
  const [state, setState] = React.useState(() => (lastList
368
445
  ? { status: 'ready', data: lastList, error: null }
369
446
  : { status: 'loading', data: null, error: null }));
@@ -376,7 +453,7 @@ function useList() {
376
453
  * 用 open 时 groups.completed 恒为空,「已完成」那一组就永远出不来(也就没法折叠/展开)。
377
454
  * 统计 stats 不受 filter 影响(宿主按全量算),所以顶部数字仍然是全局口径。
378
455
  */
379
- fetch(`${ROUTE_LIST}?filter=all`, { cache: 'no-store', credentials: 'same-origin' })
456
+ fetch(`${ROUTE_LIST}?filter=all&sort=${encodeURIComponent(sort || 'manual')}`, { cache: 'no-store', credentials: 'same-origin' })
380
457
  .then(async (response) => {
381
458
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
382
459
  const body = await response.json().catch(() => null);
@@ -395,7 +472,7 @@ function useList() {
395
472
  }
396
473
  });
397
474
  return () => { alive = false; };
398
- }, [tick]);
475
+ }, [tick, sort]);
399
476
 
400
477
  const refresh = React.useCallback(() => setTick((n) => n + 1), []);
401
478
  return { ...state, refresh };
@@ -404,72 +481,220 @@ function useList() {
404
481
  /* ------------------------------------------------------------------ *
405
482
  * 快速添加
406
483
  * ------------------------------------------------------------------ */
484
+ /** 小红旗图标:填充与描边都用 currentColor,颜色由按钮的 color 决定。 */
485
+
486
+ /**
487
+ * 新增待办:平时只是一个**大加号**,点开才展开表单。
488
+ * 日期用原生日历(`type=date`)、时间用原生时钟(`type=time`),
489
+ * 优先级用三个不同颜色的**小红旗**表示(高=红、普通=琥珀、低=灰)。
490
+ */
491
+
492
+ /** 小红旗图标:颜色由父级 color 决定;filled=选中档位。 */
493
+ function IconFlag({ filled, dashed }) {
494
+ return React.createElement('svg', { width: 13, height: 13, viewBox: '0 0 16 16', 'aria-hidden': 'true' }, [
495
+ React.createElement('path', {
496
+ key: 'pole', d: 'M3.6 1.6v13', stroke: 'currentColor', strokeWidth: 1.6,
497
+ strokeLinecap: 'round', fill: 'none', strokeDasharray: dashed ? '1.5 1.5' : undefined,
498
+ }),
499
+ React.createElement('path', {
500
+ key: 'cloth', d: 'M5.2 2.4h7.4L11 5.2l1.6 2.8H5.2z',
501
+ fill: filled ? 'currentColor' : 'none', stroke: 'currentColor', strokeWidth: 1.3,
502
+ strokeLinejoin: 'round', strokeDasharray: dashed ? '1.5 1.5' : undefined,
503
+ }),
504
+ ]);
505
+ }
506
+
507
+ const IconCal = () => React.createElement('svg', { width: 13, height: 13, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, 'aria-hidden': 'true' }, [
508
+ React.createElement('rect', { key: 'r', x: 2.2, y: 3.4, width: 11.6, height: 10.4, rx: 2 }),
509
+ React.createElement('path', { key: 'p', d: 'M2.2 6.6h11.6M5.4 2.2v2.4M10.6 2.2v2.4' }),
510
+ ]);
511
+ const IconClock = () => React.createElement('svg', { width: 13, height: 13, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, 'aria-hidden': 'true' }, [
512
+ React.createElement('circle', { key: 'c', cx: 8, cy: 8, r: 5.8 }),
513
+ React.createElement('path', { key: 'p', d: 'M8 4.9V8l2.2 1.4' }),
514
+ ]);
515
+
516
+ /**
517
+ * 快速添加:两行布局(设计稿 v5)。
518
+ * 第一行:待办内容 + 添加;
519
+ * 第二行(更小):日期 / 时间 / 优先级——显示**当前值**,点开才选。
520
+ * 标题里写了「明天下午3点」这类说法会被实时解析:第二行自动点亮并标"· 识别",
521
+ * 且日期词会从标题里摘掉。**手动改过就不再被自动覆盖**(手动优先)。
522
+ */
407
523
  function QuickAdd({ t, onAdd, busy }) {
408
524
  const [title, setTitle] = React.useState('');
409
525
  const [due, setDue] = React.useState('');
410
- const [priority, setPriority] = React.useState('normal');
526
+ const [dueTime, setDueTime] = React.useState('');
527
+ const [priority, setPriority] = React.useState('none');
528
+ const [parsed, setParsed] = React.useState(null);
529
+ const [manual, setManual] = React.useState(false); // 用户手动改过日期/时间
530
+ const [flagOpen, setFlagOpen] = React.useState(false);
531
+ // 点"整块胶囊"都要弹出选择器:Safari 里带值的 date/time 输入光靠点击只会聚焦,
532
+ // 所以统一主动调 showPicker()(不可用时退回 focus+click)
533
+ const dateRef = React.useRef(null);
534
+ const timeRef = React.useRef(null);
535
+ const openPicker = (ref) => {
536
+ const el = ref.current;
537
+ if (!el) return;
538
+ if (typeof el.showPicker === 'function') {
539
+ try { el.showPicker(); return; } catch { /* 某些环境不允许,走下面的兜底 */ }
540
+ }
541
+ el.focus();
542
+ try { el.click(); } catch { /* 忽略 */ }
543
+ };
544
+
545
+ // 输入防抖 300ms 再解析(每按一键就解析会闪烁)
546
+ React.useEffect(() => {
547
+ if (manual) return undefined;
548
+ const text = title;
549
+ if (!text || !text.trim()) { setParsed(null); return undefined; }
550
+ const timer = setTimeout(() => {
551
+ const result = parseNaturalDue(text, Date.now());
552
+ if (result.matched.length) {
553
+ setParsed(result);
554
+ setDue(result.due ?? '');
555
+ setDueTime(result.dueTime ?? '');
556
+ } else {
557
+ setParsed(null);
558
+ }
559
+ }, 300);
560
+ return () => clearTimeout(timer);
561
+ }, [title, manual]);
411
562
 
412
563
  const submit = () => {
413
564
  if (busy) return;
414
- // 截止日期与优先级是「这一批」的选择,加完保留:同一天连着加几件事时不用反复填
415
- Promise.resolve(onAdd({ title, due, priority })).then((ok) => {
416
- if (ok !== false) setTitle('');
417
- }).catch(() => { /* onAdd 内部已经吞掉错误并显示在错误条上 */ });
565
+ let payloadTitle = title;
566
+ if (!manual) {
567
+ const result = parseNaturalDue(title, Date.now());
568
+ if (result.matched.length) payloadTitle = result.title; // 日期词摘掉
569
+ }
570
+ Promise.resolve(onAdd({
571
+ title: payloadTitle,
572
+ due: due || null,
573
+ dueTime: dueTime || null,
574
+ priority,
575
+ })).then((ok) => {
576
+ if (ok !== false) {
577
+ setTitle('');
578
+ setParsed(null);
579
+ setManual(false);
580
+ setFlagOpen(false);
581
+ // 日期/时间/优先级保留:连着录同一天的几件事不用重设
582
+ }
583
+ }).catch(() => { /* onAdd 内部已显示错误 */ });
418
584
  };
419
585
 
420
- return React.createElement('div', {
421
- style: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', marginBottom: 10 },
422
- }, [
423
- React.createElement('input', {
424
- key: 'ti',
425
- className: 'wt-input',
426
- style: { flex: '1 1 200px', fontSize: 12.5, padding: '5px 9px' },
427
- value: title,
428
- placeholder: t('addPlaceholder'),
429
- 'aria-label': t('addPlaceholder'),
430
- onChange: (event) => setTitle(event.target.value),
431
- onKeyDown: (event) => { if (event.key === 'Enter') submit(); },
432
- }),
433
- React.createElement('label', {
434
- key: 'due',
435
- title: t('dueLabel'),
436
- style: { display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, color: C.label3 },
437
- }, [
438
- t('dueLabel'),
586
+ const today = dayKey(Date.now());
587
+ const dateLabel = due
588
+ ? (due === today ? t('todayWord') : `${due.slice(5).replace('-', '-')}`)
589
+ : t('dateUnset');
590
+ const timeLabel = dueTime || t('timeUnset');
591
+ const prioLabel = priority === 'high' ? t('prioHigh') : priority === 'normal' ? t('prioNormal') : priority === 'low' ? t('prioLow') : t('prioNone');
592
+ const prioColor = priority === 'high' ? C.error : priority === 'normal' ? C.warn : C.label3;
593
+
594
+ const flags = [
595
+ { value: 'none', label: t('prioNone'), color: C.label3, dashed: true },
596
+ { value: 'high', label: t('prioHigh'), color: C.error },
597
+ { value: 'normal', label: t('prioNormal'), color: C.warn },
598
+ { value: 'low', label: t('prioLow'), color: C.label3 },
599
+ ];
600
+
601
+ const setDate = (value) => {
602
+ setManual(true);
603
+ setDue(value);
604
+ if (!value) setDueTime(''); // 没日期就没时间
605
+ setFlagOpen(false);
606
+ };
607
+ const setTime = (value) => {
608
+ setManual(true);
609
+ if (value && !due) setDue(dayKey(Date.now())); // 只选时间=今天
610
+ setDueTime(value);
611
+ };
612
+
613
+ return React.createElement('div', { key: 'qa', style: { marginBottom: 12 } }, [
614
+ // ---- 第一行:内容 + 添加 ----
615
+ React.createElement('div', { key: 'l1', style: { display: 'flex', alignItems: 'center', gap: 8 } }, [
439
616
  React.createElement('input', {
440
- key: 'i',
617
+ key: 'ti',
441
618
  className: 'wt-input',
442
- type: 'date',
443
- value: due,
444
- 'aria-label': t('dueLabel'),
445
- onChange: (event) => setDue(event.target.value),
619
+ style: { flex: '1 1 auto', fontSize: 13, padding: '7px 10px' },
620
+ value: title,
621
+ placeholder: t('addPlaceholder'),
622
+ 'aria-label': t('addPlaceholder'),
623
+ onChange: (event) => {
624
+ const next = event.target.value;
625
+ setTitle(next);
626
+ // 手动优先,但把标题清空=重新开始:交回给自动识别
627
+ if (!next.trim()) { setManual(false); setParsed(null); }
628
+ },
629
+ onKeyDown: (event) => { if (event.key === 'Enter') submit(); },
446
630
  }),
631
+ React.createElement('button', {
632
+ key: 'add', type: 'button', className: 'wt-btn wt-btn-primary',
633
+ disabled: Boolean(busy), onClick: submit,
634
+ }, t('add')),
447
635
  ]),
448
- React.createElement('label', {
449
- key: 'prio',
450
- title: t('priorityLabel'),
451
- style: { display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, color: C.label3 },
452
- }, [
453
- t('priorityLabel'),
454
- React.createElement('select', {
455
- key: 's',
456
- className: 'wt-input',
457
- value: priority,
458
- 'aria-label': t('priorityLabel'),
459
- onChange: (event) => setPriority(event.target.value),
636
+
637
+ // ---- 第二行(更小):日期 / 时间 / 优先级 ----
638
+ React.createElement('div', { key: 'l2', style: { display: 'flex', alignItems: 'center', gap: 10, marginTop: 8, fontSize: 11.5, flexWrap: 'wrap' } }, [
639
+ // 日期:label 里套一个透明的原生 date 输入,点它即弹系统日历
640
+ React.createElement('label', {
641
+ key: 'due',
642
+ className: `wt-chip${parsed && parsed.due ? ' is-parsed' : ''}`,
643
+ title: t('dueLabel'),
644
+ onClick: (event) => { event.preventDefault(); openPicker(dateRef); },
645
+ style: { position: 'relative', cursor: 'pointer', color: due ? C.label : C.label3, borderColor: parsed && parsed.due ? C.brand : undefined },
460
646
  }, [
461
- React.createElement('option', { key: 'high', value: 'high' }, t('prioHigh')),
462
- React.createElement('option', { key: 'normal', value: 'normal' }, t('prioNormal')),
463
- React.createElement('option', { key: 'low', value: 'low' }, t('prioLow')),
647
+ React.createElement(IconCal, { key: 'i' }),
648
+ React.createElement('span', { key: 'v' }, due ? `${today === due ? t('todayWord') : due}${parsed && parsed.due ? ` · ${t('fromParsed')}` : ''}` : t('dateUnset')),
649
+ React.createElement('input', {
650
+ key: 'in', ref: dateRef, type: 'date', value: due, 'aria-label': t('dueLabel'),
651
+ style: { position: 'absolute', inset: 0, opacity: 0, border: 0, padding: 0, width: '100%', height: '100%', cursor: 'pointer' },
652
+ onChange: (event) => setDate(event.target.value),
653
+ }),
654
+ ]),
655
+ // 时间:同样用原生 time 输入;只选时间时自动补今天
656
+ React.createElement('label', {
657
+ key: 'time',
658
+ className: `wt-chip${parsed && parsed.dueTime ? ' is-parsed' : ''}`,
659
+ title: t('dueTimeLabel'),
660
+ onClick: (event) => { event.preventDefault(); openPicker(timeRef); },
661
+ style: { position: 'relative', cursor: 'pointer', color: dueTime ? C.label : C.label3, borderColor: parsed && parsed.dueTime ? C.brand : undefined },
662
+ }, [
663
+ React.createElement(IconClock, { key: 'i' }),
664
+ React.createElement('span', { key: 'v' }, `${timeLabel}${parsed && parsed.dueTime ? ` · ${t('fromParsed')}` : ''}`),
665
+ React.createElement('input', {
666
+ key: 'in', ref: timeRef, type: 'time', value: dueTime, lang: 'zh-CN', 'aria-label': t('dueTimeLabel'),
667
+ style: { position: 'absolute', inset: 0, opacity: 0, border: 0, padding: 0, width: '100%', height: '100%', cursor: 'pointer' },
668
+ onChange: (event) => setTime(event.target.value),
669
+ }),
670
+ ]),
671
+ // 优先级:点开四个小旗
672
+ React.createElement('span', { key: 'prio', style: { position: 'relative' } }, [
673
+ React.createElement('button', {
674
+ key: 'btn', type: 'button', className: 'wt-chip', style: { color: prioColor, cursor: 'pointer', font: 'inherit', fontSize: 11.5 },
675
+ title: t('priorityLabel'), 'aria-expanded': flagOpen,
676
+ onClick: () => setFlagOpen((open) => !open),
677
+ }, [
678
+ React.createElement(IconFlag, { key: 'f', dashed: priority === 'none', filled: priority !== 'none' }),
679
+ React.createElement('span', { key: 'v' }, `${t('priorityLabel')} · ${prioLabel}`),
680
+ ]),
681
+ flagOpen ? React.createElement('div', {
682
+ key: 'pop',
683
+ style: {
684
+ position: 'absolute', top: '100%', left: 0, zIndex: 5, marginTop: 4, padding: 6, display: 'flex', gap: 4,
685
+ background: C.layer1, border: `1px solid ${C.border}`, borderRadius: 10, boxShadow: '0 8px 24px rgba(0,0,0,.12)',
686
+ },
687
+ }, flags.map((flag) => React.createElement('button', {
688
+ key: flag.value, type: 'button', className: 'wt-chip',
689
+ style: { color: flag.color, font: 'inherit', fontSize: 11.5, cursor: 'pointer', borderColor: priority === flag.value ? flag.color : undefined },
690
+ title: flag.label,
691
+ onClick: () => { setPriority(flag.value); setFlagOpen(false); },
692
+ }, [
693
+ React.createElement(IconFlag, { key: 'f', filled: priority === flag.value, dashed: flag.value === 'none' }),
694
+ React.createElement('span', { key: 'v' }, flag.label),
695
+ ]))) : null,
464
696
  ]),
465
697
  ]),
466
- React.createElement('button', {
467
- key: 'add',
468
- type: 'button',
469
- className: 'wt-btn wt-btn-primary',
470
- disabled: Boolean(busy),
471
- onClick: submit,
472
- }, t('add')),
473
698
  ]);
474
699
  }
475
700
 
@@ -477,7 +702,7 @@ function QuickAdd({ t, onAdd, busy }) {
477
702
  * 单行:查看 / 行内编辑 / 二次确认删除
478
703
  * 状态都留在这一行内部,所以在别的行上敲字、或者列表刷新,都不会把正在编辑的内容冲掉。
479
704
  * ------------------------------------------------------------------ */
480
- function TodoRow({ todo, today, t, onToggle, onRemove, onSave }) {
705
+ function TodoRow({ todo, today, t, onToggle, onRemove, onSave, drag }) {
481
706
  const [editing, setEditing] = React.useState(false);
482
707
  const [draft, setDraft] = React.useState(null);
483
708
  const [notesOpen, setNotesOpen] = React.useState(false);
@@ -501,6 +726,7 @@ function TodoRow({ todo, today, t, onToggle, onRemove, onSave }) {
501
726
  due,
502
727
  priority: row.priority === 'high' || row.priority === 'low' ? row.priority : 'normal',
503
728
  project: typeof row.project === 'string' ? row.project : '',
729
+ dueTime: typeof row.dueTime === 'string' ? row.dueTime : '',
504
730
  notes: typeof row.notes === 'string' ? row.notes : '',
505
731
  });
506
732
  setNotesOpen(Boolean(row.notes));
@@ -519,7 +745,12 @@ function TodoRow({ todo, today, t, onToggle, onRemove, onSave }) {
519
745
  const title = String(draft.title ?? '').trim();
520
746
  if (title !== (row.title ?? '')) patch.title = title;
521
747
  const nextDue = draft.due || '';
522
- if (nextDue !== due) patch.due = nextDue; // 空串 = 清空截止日期
748
+ if (nextDue !== due) {
749
+ patch.due = nextDue; // 空串 = 清空截止日期
750
+ if (!nextDue) patch.dueTime = 'none'; // 没有日期就没有时间
751
+ }
752
+ const nextDueTime = String(draft.dueTime ?? '').trim();
753
+ if (nextDue && nextDueTime !== (row.dueTime ?? '')) patch.dueTime = nextDueTime === '' ? 'none' : nextDueTime;
523
754
  const priority = draft.priority || 'normal';
524
755
  if (priority !== (row.priority ?? 'normal')) patch.priority = priority;
525
756
  const project = String(draft.project ?? '').trim();
@@ -571,6 +802,11 @@ function TodoRow({ todo, today, t, onToggle, onRemove, onSave }) {
571
802
  'aria-label': t('dueLabel'),
572
803
  onChange: (event) => setDraft((prev) => ({ ...prev, due: event.target.value })),
573
804
  })),
805
+ field('tm', t('dueTimeLabel'), React.createElement('input', {
806
+ className: 'wt-input', style: inputStyle, type: 'time', value: draft.dueTime, disabled: !draft.due,
807
+ 'aria-label': t('dueTimeLabel'),
808
+ onChange: (event) => setDraft((prev) => ({ ...prev, dueTime: event.target.value })),
809
+ })),
574
810
  field('prio', t('priorityLabel'), React.createElement('select', {
575
811
  className: 'wt-input', style: inputStyle, value: draft.priority,
576
812
  'aria-label': t('priorityLabel'),
@@ -644,7 +880,34 @@ function TodoRow({ todo, today, t, onToggle, onRemove, onSave }) {
644
880
  }, row.source === 'agent' ? t('sourceAgent') : t('sourceSession')));
645
881
  }
646
882
 
647
- return React.createElement('div', { className: 'wt-row' }, [
883
+ // 拖动属性:只有父级给了 drag 才可拖(未启用时行为与以前完全一致)
884
+ const dragAttrs = drag
885
+ ? {
886
+ draggable: true,
887
+ onDragStart: (event) => {
888
+ try {
889
+ event.dataTransfer?.setData('text/plain', row.id ?? '');
890
+ if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
891
+ } catch { /* 少数环境不允许写 dataTransfer,忽略即可 */ }
892
+ drag.onStart?.(row.id);
893
+ },
894
+ onDragOver: (event) => { event.preventDefault(); drag.onOver?.(row.id); },
895
+ onDrop: (event) => { event.preventDefault(); drag.onDrop?.(row.id); },
896
+ onDragEnd: () => drag.onEnd?.(),
897
+ title: drag.hint,
898
+ style: drag.dragging ? { opacity: 0.45 } : undefined,
899
+ }
900
+ : {};
901
+ return React.createElement('div', { className: 'wt-row', ...dragAttrs, style: row.priority === 'high' ? { borderLeft: `2px solid ${C.error}`, paddingLeft: 8 } : undefined }, [
902
+ /* 优先级呈现(设计稿 v5):高=红色左缘 + 实心红旗;中=琥珀;低=灰;无=什么都不显示 */
903
+ row.priority && row.priority !== 'none' ? React.createElement('span', {
904
+ key: 'prio-flag',
905
+ title: row.priority === 'high' ? t('prioHigh') : row.priority === 'normal' ? t('prioNormal') : t('prioLow'),
906
+ style: {
907
+ display: 'inline-flex', flex: '0 0 auto',
908
+ color: row.priority === 'high' ? C.error : row.priority === 'normal' ? C.warn : C.label3,
909
+ },
910
+ }, React.createElement(IconFlag, { filled: row.priority === 'high' })) : null,
648
911
  React.createElement('button', {
649
912
  key: 'check',
650
913
  type: 'button',
@@ -690,7 +953,7 @@ function TodoRow({ todo, today, t, onToggle, onRemove, onSave }) {
690
953
  /* ------------------------------------------------------------------ *
691
954
  * 分组:空组整块不渲染,避免五个空标题堆在面板上
692
955
  * ------------------------------------------------------------------ */
693
- function Group({ label, items, closed, t, today, onToggleOpen, onToggleTodo, onRemove, onSave }) {
956
+ function Group({ label, items, meta, closed, t, today, onToggleOpen, onToggleTodo, onRemove, onSave, drag }) {
694
957
  const list = asArray(items);
695
958
  if (!list.length) return null;
696
959
  return React.createElement('div', { style: { marginTop: 6 } }, [
@@ -704,7 +967,7 @@ function Group({ label, items, closed, t, today, onToggleOpen, onToggleTodo, onR
704
967
  }, [
705
968
  React.createElement(IconChevron, { key: 'c', open: !closed }),
706
969
  React.createElement('span', { key: 'l', style: { fontWeight: 600, fontSize: 12 } }, label),
707
- React.createElement('span', { key: 'n', style: { fontSize: 11, color: C.label3 } }, String(list.length)),
970
+ React.createElement('span', { key: 'n', style: { fontSize: 11, color: C.label3 } }, meta || String(list.length)),
708
971
  React.createElement('span', { key: 'sp', style: { flex: 1 } }),
709
972
  React.createElement('span', { key: 'h', style: { fontSize: 11, color: C.label3 } }, closed ? t('expand') : t('collapse')),
710
973
  ]),
@@ -716,6 +979,14 @@ function Group({ label, items, closed, t, today, onToggleOpen, onToggleTodo, onR
716
979
  onToggle: onToggleTodo,
717
980
  onRemove,
718
981
  onSave,
982
+ drag: drag ? {
983
+ dragging: drag.dragId === todo?.id,
984
+ hint: drag.hint,
985
+ onStart: drag.onStart,
986
+ onOver: drag.onOver,
987
+ onDrop: drag.onDrop,
988
+ onEnd: drag.onEnd,
989
+ } : undefined,
719
990
  }))),
720
991
  ]);
721
992
  }
@@ -779,7 +1050,11 @@ const GROUPS = [
779
1050
  function WorkTodoPanel(props) {
780
1051
  // t 由 locale 服务通过 slot props 注入;拿不到时退回中文,至少不显示裸 key
781
1052
  const t = typeof props?.t === 'function' ? props.t : (key) => zh[key] ?? key;
782
- const { status, data, error, refresh } = useList();
1053
+ // 排序:manual(默认,尊重拖动)/ priority / created;必须在 useList(sort) 之前声明
1054
+ const [sort, setSortState] = React.useState(lastSort);
1055
+ const setSort = React.useCallback((next) => { lastSort = next; setSortState(next); }, []);
1056
+ const [dragId, setDragId] = React.useState(null);
1057
+ const { status, data, error, refresh } = useList(sort);
783
1058
 
784
1059
  // 写操作的错误条;readError 是拉取错误(用户关掉之后,只有换成新错误才再弹)
785
1060
  const [barError, setBarError] = React.useState(null);
@@ -791,6 +1066,8 @@ function WorkTodoPanel(props) {
791
1066
  const [pending, setPending] = React.useState(0);
792
1067
  // 「已完成」默认折叠(做完的事不用占满屏幕),其余默认展开
793
1068
  const [closed, setClosed] = React.useState({ completed: true });
1069
+ // 视图:按截止(默认)或按项目
1070
+ const [view, setView] = React.useState('due');
794
1071
 
795
1072
  /**
796
1073
  * 所有写操作的统一外壳:成功清掉错误条,失败把宿主的 error 原文放上去,
@@ -830,6 +1107,60 @@ function WorkTodoPanel(props) {
830
1107
  const handleRemove = React.useCallback((ref) => runWrite(() => postJson('/remove', { ref })), [runWrite]);
831
1108
  const handleSave = React.useCallback((ref, patch) => runWrite(() => postJson('/update', { ref, ...patch })), [runWrite]);
832
1109
 
1110
+ /** 把 dragId 挪到目标之前,得到新的完整顺序并落盘(manual 模式生效) */
1111
+
1112
+ const handleDropOn = React.useCallback((targetId) => {
1113
+
1114
+ const dragging = dragId;
1115
+
1116
+ setDragId(null);
1117
+
1118
+ if (!dragging || !targetId || dragging === targetId) return Promise.resolve(false);
1119
+
1120
+ const ids = asArray(data?.todos).map((todo) => todo?.id).filter(Boolean);
1121
+
1122
+ const from = ids.indexOf(dragging);
1123
+
1124
+ if (from === -1) return Promise.resolve(false);
1125
+
1126
+ ids.splice(from, 1);
1127
+
1128
+ const to = ids.indexOf(targetId);
1129
+
1130
+ ids.splice(to === -1 ? ids.length : to, 0, dragging);
1131
+
1132
+ return runWrite(() => postJson('/reorder', { ids })).then((result) => result.ok);
1133
+
1134
+ }, [dragId, data, runWrite]);
1135
+
1136
+
1137
+
1138
+ const dragHandlers = React.useMemo(() => ({
1139
+
1140
+ dragId,
1141
+
1142
+ hint: t('dragHint'),
1143
+
1144
+ onStart: (id) => {
1145
+
1146
+ setDragId(id);
1147
+
1148
+ // 拖了就说明想自己定顺序:自动切到手动,否则松手后看起来「没生效」
1149
+
1150
+ if (sort !== 'manual') setSort('manual');
1151
+
1152
+ },
1153
+
1154
+ onOver: () => {},
1155
+
1156
+ onDrop: (id) => handleDropOn(id),
1157
+
1158
+ onEnd: () => setDragId(null),
1159
+
1160
+ }), [dragId, sort, t, setSort, handleDropOn]);
1161
+
1162
+
1163
+
833
1164
  const handleAdopt = React.useCallback((sessionTodos) => runWrite(() => postJson('/adopt', { todos: sessionTodos }))
834
1165
  .then((result) => {
835
1166
  // runWrite 已经刷新过列表,这里补一句「提升了几条、跳过几条」的回执,否则点完像没反应
@@ -877,9 +1208,21 @@ function WorkTodoPanel(props) {
877
1208
  const stats = data?.stats ?? {};
878
1209
  const today = typeof data?.today === 'string' && data.today ? data.today : localToday();
879
1210
  const store = data?.store ?? null;
1211
+ const version = typeof data?.version === 'string' ? data.version : '';
1212
+ const update = data?.update ?? null;
880
1213
  const num = (value) => (Number.isFinite(Number(value)) ? Number(value) : 0);
881
1214
  const overdueCount = num(stats.overdue);
882
- const totalVisible = GROUPS.reduce((sum, group) => sum + asArray(groups[group.key]).length, 0);
1215
+ // 视图分桶:按截止(原有 GROUPS)或按项目(宿主给的 projectGroups,附 未完成/总数/逾期)
1216
+ const projectGroups = asArray(data?.projectGroups);
1217
+ const viewGroups = view === 'project'
1218
+ ? projectGroups.map((bucket) => ({
1219
+ key: `p:${bucket?.key ?? ''}`,
1220
+ label: bucket?.project ? String(bucket.project) : t('noProject'),
1221
+ meta: `${num(bucket?.open)}/${num(bucket?.total)}${num(bucket?.overdue) ? ` · ${t('statOverdue')} ${num(bucket.overdue)}` : ''}`,
1222
+ items: bucket?.todos,
1223
+ }))
1224
+ : GROUPS.map((group) => ({ key: group.key, label: t(group.label), items: groups[group.key] }));
1225
+ const totalVisible = viewGroups.reduce((sum, group) => sum + asArray(group.items).length, 0);
883
1226
 
884
1227
  const chips = [
885
1228
  { key: 'open', label: t('statOpen'), value: num(stats.open) },
@@ -908,6 +1251,31 @@ function WorkTodoPanel(props) {
908
1251
  React.createElement('span', { key: 'l', style: { color: C.label3 } }, chip.label),
909
1252
  React.createElement('b', { key: 'v', style: { fontWeight: 650 } }, String(chip.value)),
910
1253
  ])),
1254
+ React.createElement('span', { key: 'sort', className: 'wt-seg', title: t('sortLabel') }, [
1255
+ ...['manual', 'priority', 'created'].map((mode) => React.createElement('button', {
1256
+ key: mode,
1257
+ type: 'button',
1258
+ className: `wt-seg-btn${sort === mode ? ' is-on' : ''}`,
1259
+ title: mode === 'manual' ? t('dragHint') : `${t('sortLabel')}:${t(`sort${mode === 'manual' ? 'Manual' : mode === 'priority' ? 'Priority' : 'Created'}`)}`,
1260
+ onClick: () => setSort(mode),
1261
+ }, t(`sort${mode === 'manual' ? 'Manual' : mode === 'priority' ? 'Priority' : 'Created'}`))),
1262
+ ]),
1263
+ React.createElement('span', { key: 'view', className: 'wt-seg' }, [
1264
+ React.createElement('button', {
1265
+ key: 'due',
1266
+ type: 'button',
1267
+ className: `wt-seg-btn${view === 'due' ? ' is-on' : ''}`,
1268
+ title: t('viewDue'),
1269
+ onClick: () => setView('due'),
1270
+ }, t('viewDue')),
1271
+ React.createElement('button', {
1272
+ key: 'proj',
1273
+ type: 'button',
1274
+ className: `wt-seg-btn${view === 'project' ? ' is-on' : ''}`,
1275
+ title: t('viewProject'),
1276
+ onClick: () => setView('project'),
1277
+ }, t('viewProject')),
1278
+ ]),
911
1279
  React.createElement('span', { key: 'sp', style: { flex: 1 } }),
912
1280
  React.createElement('button', {
913
1281
  key: 'rf',
@@ -980,10 +1348,11 @@ function WorkTodoPanel(props) {
980
1348
 
981
1349
  /* 分组列表 */
982
1350
  totalVisible
983
- ? React.createElement('div', { key: 'groups' }, GROUPS.map((group) => React.createElement(Group, {
1351
+ ? React.createElement('div', { key: 'groups' }, viewGroups.map((group) => React.createElement(Group, {
984
1352
  key: group.key,
985
- label: t(group.label),
986
- items: groups[group.key],
1353
+ label: group.label,
1354
+ meta: group.meta,
1355
+ items: group.items,
987
1356
  closed: Boolean(closed[group.key]),
988
1357
  t,
989
1358
  today,
@@ -991,6 +1360,7 @@ function WorkTodoPanel(props) {
991
1360
  onToggleTodo: handleToggle,
992
1361
  onRemove: handleRemove,
993
1362
  onSave: handleSave,
1363
+ drag: dragHandlers,
994
1364
  })))
995
1365
  : React.createElement('div', {
996
1366
  key: 'empty',
@@ -1004,13 +1374,34 @@ function WorkTodoPanel(props) {
1004
1374
  React.createElement('div', {
1005
1375
  key: 'foot',
1006
1376
  style: { marginTop: 14, paddingTop: 8, borderTop: `1px solid ${C.border1}`, fontSize: 10.5, color: C.label3, lineHeight: 1.7 },
1007
- }, store && store.location
1008
- ? React.createElement('div', {
1009
- key: 'store',
1010
- title: `${t('storeAt')}:${store.location}`,
1011
- style: { wordBreak: 'break-all' },
1012
- }, `${t('storeAt')} · ${store.kind === 'domain' ? t('storeDomain') : t('storeFile')} · ${store.location}`)
1013
- : null),
1377
+ }, [
1378
+ /* 角落信息:装了哪一版 + 有没有新版(一眼可见,不占主区) */
1379
+ React.createElement('div', {
1380
+ key: 'ver',
1381
+ style: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', marginBottom: 2 },
1382
+ }, [
1383
+ React.createElement('span', {
1384
+ key: 'v',
1385
+ title: t('versionHint'),
1386
+ style: { color: C.label3 },
1387
+ }, `${t('versionLabel')} ${version ? `v${version}` : '—'}`),
1388
+ update?.hasUpdate
1389
+ ? React.createElement('span', {
1390
+ key: 'u',
1391
+ className: 'wt-chip',
1392
+ style: { fontSize: 10, color: C.ok, borderColor: tint(C.ok, 50), background: tint(C.ok, 12) },
1393
+ title: `${t('updateHint')}:dsh plugin --profile desktop add dsh-worktodo@${update.latest}`,
1394
+ }, `${t('updateAvailable')} v${update.latest}`)
1395
+ : null,
1396
+ ]),
1397
+ store && store.location
1398
+ ? React.createElement('div', {
1399
+ key: 'store',
1400
+ title: `${t('storeAt')}:${store.location}`,
1401
+ style: { wordBreak: 'break-all' },
1402
+ }, `${t('storeAt')} · ${store.kind === 'domain' ? t('storeDomain') : t('storeFile')} · ${store.location}`)
1403
+ : null,
1404
+ ]),
1014
1405
  ]);
1015
1406
  }
1016
1407