dsh-worktodo 0.1.0 → 0.1.3

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.
@@ -63,6 +63,14 @@ const zh = {
63
63
  'groupWeek': '本周',
64
64
  'groupLater': '以后',
65
65
  'groupCompleted': '已完成',
66
+ 'viewDue': '按截止',
67
+ 'viewProject': '按项目',
68
+ 'noProject': '未归类',
69
+ 'sortLabel': '排序',
70
+ 'sortManual': '手动',
71
+ 'sortPriority': '优先级',
72
+ 'sortCreated': '创建时间',
73
+ 'dragHint': '拖动调整顺序(会自动切到手动排序)',
66
74
  'expand': '展开',
67
75
  'collapse': '收起',
68
76
  'empty': '还没有待办',
@@ -122,6 +130,14 @@ const en = {
122
130
  'groupWeek': 'This week',
123
131
  'groupLater': 'Later',
124
132
  'groupCompleted': 'Completed',
133
+ 'viewDue': 'By due',
134
+ 'viewProject': 'By project',
135
+ 'noProject': 'Uncategorized',
136
+ 'sortLabel': 'Sort',
137
+ 'sortManual': 'Manual',
138
+ 'sortPriority': 'Priority',
139
+ 'sortCreated': 'Created',
140
+ 'dragHint': 'Drag to reorder (switches to manual sort)',
125
141
  'expand': 'Expand',
126
142
  'collapse': 'Collapse',
127
143
  'empty': 'No to-dos yet',
@@ -228,6 +244,18 @@ const STYLE_TEXT = `
228
244
  flex-direction: column; align-items: stretch; gap: 6px;
229
245
  border: 1px solid ${C.border1}; background: ${C.layer1};
230
246
  }
247
+ .wt-seg {
248
+ display: inline-flex; align-items: center; border-radius: 8px; overflow: hidden;
249
+ border: 1px solid ${C.border}; background: ${C.bg2};
250
+ }
251
+ .wt-seg-btn {
252
+ appearance: none; border: 0; background: transparent; color: ${C.label2};
253
+ font: inherit; font-size: 11.5px; line-height: 1; padding: 5px 9px; cursor: pointer;
254
+ }
255
+ .wt-seg-btn.is-on { background: ${C.hover}; color: ${C.label}; font-weight: 600; }
256
+ .wt-seg-btn:hover { color: ${C.label}; }
257
+ .wt-row[draggable="true"] { cursor: grab; }
258
+ .wt-row[draggable="true"]:active { cursor: grabbing; }
231
259
  .wt-group-head {
232
260
  display: flex; align-items: center; gap: 7px; padding: 3px 8px; border-radius: 8px;
233
261
  cursor: pointer; user-select: none; color: ${C.label2};
@@ -362,8 +390,10 @@ async function postJson(path, body) {
362
390
  * (stale-while-revalidate)。侧栏来回点的时候不该每次都转圈。
363
391
  */
364
392
  let lastList = null;
393
+ // 排序模式跨「切走再切回」保持;manual = 尊重拖动顺序
394
+ let lastSort = 'manual';
365
395
 
366
- function useList() {
396
+ function useList(sort) {
367
397
  const [state, setState] = React.useState(() => (lastList
368
398
  ? { status: 'ready', data: lastList, error: null }
369
399
  : { status: 'loading', data: null, error: null }));
@@ -376,7 +406,7 @@ function useList() {
376
406
  * 用 open 时 groups.completed 恒为空,「已完成」那一组就永远出不来(也就没法折叠/展开)。
377
407
  * 统计 stats 不受 filter 影响(宿主按全量算),所以顶部数字仍然是全局口径。
378
408
  */
379
- fetch(`${ROUTE_LIST}?filter=all`, { cache: 'no-store', credentials: 'same-origin' })
409
+ fetch(`${ROUTE_LIST}?filter=all&sort=${encodeURIComponent(sort || 'manual')}`, { cache: 'no-store', credentials: 'same-origin' })
380
410
  .then(async (response) => {
381
411
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
382
412
  const body = await response.json().catch(() => null);
@@ -395,7 +425,7 @@ function useList() {
395
425
  }
396
426
  });
397
427
  return () => { alive = false; };
398
- }, [tick]);
428
+ }, [tick, sort]);
399
429
 
400
430
  const refresh = React.useCallback(() => setTick((n) => n + 1), []);
401
431
  return { ...state, refresh };
@@ -477,7 +507,7 @@ function QuickAdd({ t, onAdd, busy }) {
477
507
  * 单行:查看 / 行内编辑 / 二次确认删除
478
508
  * 状态都留在这一行内部,所以在别的行上敲字、或者列表刷新,都不会把正在编辑的内容冲掉。
479
509
  * ------------------------------------------------------------------ */
480
- function TodoRow({ todo, today, t, onToggle, onRemove, onSave }) {
510
+ function TodoRow({ todo, today, t, onToggle, onRemove, onSave, drag }) {
481
511
  const [editing, setEditing] = React.useState(false);
482
512
  const [draft, setDraft] = React.useState(null);
483
513
  const [notesOpen, setNotesOpen] = React.useState(false);
@@ -644,7 +674,25 @@ function TodoRow({ todo, today, t, onToggle, onRemove, onSave }) {
644
674
  }, row.source === 'agent' ? t('sourceAgent') : t('sourceSession')));
645
675
  }
646
676
 
647
- return React.createElement('div', { className: 'wt-row' }, [
677
+ // 拖动属性:只有父级给了 drag 才可拖(未启用时行为与以前完全一致)
678
+ const dragAttrs = drag
679
+ ? {
680
+ draggable: true,
681
+ onDragStart: (event) => {
682
+ try {
683
+ event.dataTransfer?.setData('text/plain', row.id ?? '');
684
+ if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
685
+ } catch { /* 少数环境不允许写 dataTransfer,忽略即可 */ }
686
+ drag.onStart?.(row.id);
687
+ },
688
+ onDragOver: (event) => { event.preventDefault(); drag.onOver?.(row.id); },
689
+ onDrop: (event) => { event.preventDefault(); drag.onDrop?.(row.id); },
690
+ onDragEnd: () => drag.onEnd?.(),
691
+ title: drag.hint,
692
+ style: drag.dragging ? { opacity: 0.45 } : undefined,
693
+ }
694
+ : {};
695
+ return React.createElement('div', { className: 'wt-row', ...dragAttrs }, [
648
696
  React.createElement('button', {
649
697
  key: 'check',
650
698
  type: 'button',
@@ -690,7 +738,7 @@ function TodoRow({ todo, today, t, onToggle, onRemove, onSave }) {
690
738
  /* ------------------------------------------------------------------ *
691
739
  * 分组:空组整块不渲染,避免五个空标题堆在面板上
692
740
  * ------------------------------------------------------------------ */
693
- function Group({ label, items, closed, t, today, onToggleOpen, onToggleTodo, onRemove, onSave }) {
741
+ function Group({ label, items, meta, closed, t, today, onToggleOpen, onToggleTodo, onRemove, onSave, drag }) {
694
742
  const list = asArray(items);
695
743
  if (!list.length) return null;
696
744
  return React.createElement('div', { style: { marginTop: 6 } }, [
@@ -704,7 +752,7 @@ function Group({ label, items, closed, t, today, onToggleOpen, onToggleTodo, onR
704
752
  }, [
705
753
  React.createElement(IconChevron, { key: 'c', open: !closed }),
706
754
  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)),
755
+ React.createElement('span', { key: 'n', style: { fontSize: 11, color: C.label3 } }, meta || String(list.length)),
708
756
  React.createElement('span', { key: 'sp', style: { flex: 1 } }),
709
757
  React.createElement('span', { key: 'h', style: { fontSize: 11, color: C.label3 } }, closed ? t('expand') : t('collapse')),
710
758
  ]),
@@ -716,6 +764,14 @@ function Group({ label, items, closed, t, today, onToggleOpen, onToggleTodo, onR
716
764
  onToggle: onToggleTodo,
717
765
  onRemove,
718
766
  onSave,
767
+ drag: drag ? {
768
+ dragging: drag.dragId === todo?.id,
769
+ hint: drag.hint,
770
+ onStart: drag.onStart,
771
+ onOver: drag.onOver,
772
+ onDrop: drag.onDrop,
773
+ onEnd: drag.onEnd,
774
+ } : undefined,
719
775
  }))),
720
776
  ]);
721
777
  }
@@ -779,7 +835,11 @@ const GROUPS = [
779
835
  function WorkTodoPanel(props) {
780
836
  // t 由 locale 服务通过 slot props 注入;拿不到时退回中文,至少不显示裸 key
781
837
  const t = typeof props?.t === 'function' ? props.t : (key) => zh[key] ?? key;
782
- const { status, data, error, refresh } = useList();
838
+ // 排序:manual(默认,尊重拖动)/ priority / created;必须在 useList(sort) 之前声明
839
+ const [sort, setSortState] = React.useState(lastSort);
840
+ const setSort = React.useCallback((next) => { lastSort = next; setSortState(next); }, []);
841
+ const [dragId, setDragId] = React.useState(null);
842
+ const { status, data, error, refresh } = useList(sort);
783
843
 
784
844
  // 写操作的错误条;readError 是拉取错误(用户关掉之后,只有换成新错误才再弹)
785
845
  const [barError, setBarError] = React.useState(null);
@@ -791,6 +851,8 @@ function WorkTodoPanel(props) {
791
851
  const [pending, setPending] = React.useState(0);
792
852
  // 「已完成」默认折叠(做完的事不用占满屏幕),其余默认展开
793
853
  const [closed, setClosed] = React.useState({ completed: true });
854
+ // 视图:按截止(默认)或按项目
855
+ const [view, setView] = React.useState('due');
794
856
 
795
857
  /**
796
858
  * 所有写操作的统一外壳:成功清掉错误条,失败把宿主的 error 原文放上去,
@@ -830,6 +892,60 @@ function WorkTodoPanel(props) {
830
892
  const handleRemove = React.useCallback((ref) => runWrite(() => postJson('/remove', { ref })), [runWrite]);
831
893
  const handleSave = React.useCallback((ref, patch) => runWrite(() => postJson('/update', { ref, ...patch })), [runWrite]);
832
894
 
895
+ /** 把 dragId 挪到目标之前,得到新的完整顺序并落盘(manual 模式生效) */
896
+
897
+ const handleDropOn = React.useCallback((targetId) => {
898
+
899
+ const dragging = dragId;
900
+
901
+ setDragId(null);
902
+
903
+ if (!dragging || !targetId || dragging === targetId) return Promise.resolve(false);
904
+
905
+ const ids = asArray(data?.todos).map((todo) => todo?.id).filter(Boolean);
906
+
907
+ const from = ids.indexOf(dragging);
908
+
909
+ if (from === -1) return Promise.resolve(false);
910
+
911
+ ids.splice(from, 1);
912
+
913
+ const to = ids.indexOf(targetId);
914
+
915
+ ids.splice(to === -1 ? ids.length : to, 0, dragging);
916
+
917
+ return runWrite(() => postJson('/reorder', { ids })).then((result) => result.ok);
918
+
919
+ }, [dragId, data, runWrite]);
920
+
921
+
922
+
923
+ const dragHandlers = React.useMemo(() => ({
924
+
925
+ dragId,
926
+
927
+ hint: t('dragHint'),
928
+
929
+ onStart: (id) => {
930
+
931
+ setDragId(id);
932
+
933
+ // 拖了就说明想自己定顺序:自动切到手动,否则松手后看起来「没生效」
934
+
935
+ if (sort !== 'manual') setSort('manual');
936
+
937
+ },
938
+
939
+ onOver: () => {},
940
+
941
+ onDrop: (id) => handleDropOn(id),
942
+
943
+ onEnd: () => setDragId(null),
944
+
945
+ }), [dragId, sort, t, setSort, handleDropOn]);
946
+
947
+
948
+
833
949
  const handleAdopt = React.useCallback((sessionTodos) => runWrite(() => postJson('/adopt', { todos: sessionTodos }))
834
950
  .then((result) => {
835
951
  // runWrite 已经刷新过列表,这里补一句「提升了几条、跳过几条」的回执,否则点完像没反应
@@ -879,7 +995,17 @@ function WorkTodoPanel(props) {
879
995
  const store = data?.store ?? null;
880
996
  const num = (value) => (Number.isFinite(Number(value)) ? Number(value) : 0);
881
997
  const overdueCount = num(stats.overdue);
882
- const totalVisible = GROUPS.reduce((sum, group) => sum + asArray(groups[group.key]).length, 0);
998
+ // 视图分桶:按截止(原有 GROUPS)或按项目(宿主给的 projectGroups,附 未完成/总数/逾期)
999
+ const projectGroups = asArray(data?.projectGroups);
1000
+ const viewGroups = view === 'project'
1001
+ ? projectGroups.map((bucket) => ({
1002
+ key: `p:${bucket?.key ?? ''}`,
1003
+ label: bucket?.project ? String(bucket.project) : t('noProject'),
1004
+ meta: `${num(bucket?.open)}/${num(bucket?.total)}${num(bucket?.overdue) ? ` · ${t('statOverdue')} ${num(bucket.overdue)}` : ''}`,
1005
+ items: bucket?.todos,
1006
+ }))
1007
+ : GROUPS.map((group) => ({ key: group.key, label: t(group.label), items: groups[group.key] }));
1008
+ const totalVisible = viewGroups.reduce((sum, group) => sum + asArray(group.items).length, 0);
883
1009
 
884
1010
  const chips = [
885
1011
  { key: 'open', label: t('statOpen'), value: num(stats.open) },
@@ -908,6 +1034,31 @@ function WorkTodoPanel(props) {
908
1034
  React.createElement('span', { key: 'l', style: { color: C.label3 } }, chip.label),
909
1035
  React.createElement('b', { key: 'v', style: { fontWeight: 650 } }, String(chip.value)),
910
1036
  ])),
1037
+ React.createElement('span', { key: 'sort', className: 'wt-seg', title: t('sortLabel') }, [
1038
+ ...['manual', 'priority', 'created'].map((mode) => React.createElement('button', {
1039
+ key: mode,
1040
+ type: 'button',
1041
+ className: `wt-seg-btn${sort === mode ? ' is-on' : ''}`,
1042
+ title: mode === 'manual' ? t('dragHint') : `${t('sortLabel')}:${t(`sort${mode === 'manual' ? 'Manual' : mode === 'priority' ? 'Priority' : 'Created'}`)}`,
1043
+ onClick: () => setSort(mode),
1044
+ }, t(`sort${mode === 'manual' ? 'Manual' : mode === 'priority' ? 'Priority' : 'Created'}`))),
1045
+ ]),
1046
+ React.createElement('span', { key: 'view', className: 'wt-seg' }, [
1047
+ React.createElement('button', {
1048
+ key: 'due',
1049
+ type: 'button',
1050
+ className: `wt-seg-btn${view === 'due' ? ' is-on' : ''}`,
1051
+ title: t('viewDue'),
1052
+ onClick: () => setView('due'),
1053
+ }, t('viewDue')),
1054
+ React.createElement('button', {
1055
+ key: 'proj',
1056
+ type: 'button',
1057
+ className: `wt-seg-btn${view === 'project' ? ' is-on' : ''}`,
1058
+ title: t('viewProject'),
1059
+ onClick: () => setView('project'),
1060
+ }, t('viewProject')),
1061
+ ]),
911
1062
  React.createElement('span', { key: 'sp', style: { flex: 1 } }),
912
1063
  React.createElement('button', {
913
1064
  key: 'rf',
@@ -980,10 +1131,11 @@ function WorkTodoPanel(props) {
980
1131
 
981
1132
  /* 分组列表 */
982
1133
  totalVisible
983
- ? React.createElement('div', { key: 'groups' }, GROUPS.map((group) => React.createElement(Group, {
1134
+ ? React.createElement('div', { key: 'groups' }, viewGroups.map((group) => React.createElement(Group, {
984
1135
  key: group.key,
985
- label: t(group.label),
986
- items: groups[group.key],
1136
+ label: group.label,
1137
+ meta: group.meta,
1138
+ items: group.items,
987
1139
  closed: Boolean(closed[group.key]),
988
1140
  t,
989
1141
  today,
@@ -991,6 +1143,7 @@ function WorkTodoPanel(props) {
991
1143
  onToggleTodo: handleToggle,
992
1144
  onRemove: handleRemove,
993
1145
  onSave: handleSave,
1146
+ drag: dragHandlers,
994
1147
  })))
995
1148
  : React.createElement('div', {
996
1149
  key: 'empty',
package/src/records.mjs CHANGED
@@ -228,6 +228,7 @@ export function createRecord(input = {}, { now = Date.now(), source = 'human', i
228
228
  priority: normalizePriority(input.priority) ?? 'normal',
229
229
  due: normalizeDue(input.due, now),
230
230
  project: normalizeProject(input.project),
231
+ order: null,
231
232
  source,
232
233
  createdAt: now,
233
234
  updatedAt: now,
@@ -312,14 +313,19 @@ const PRIORITY_WEIGHT = { high: 0, normal: 1, low: 2 };
312
313
  * @param {object[]} records
313
314
  * @returns {object[]} 新数组
314
315
  */
315
- export function sortForView(records) {
316
+ export const SORT_MODES = ['manual', 'priority', 'created'];
317
+
318
+ /**
319
+ * 视图排序。三种模式共用同一套「已完成永远沉底」的规则:
320
+ * - `priority`(默认):优先级 → 截止日期 → 创建时间(早的在前)
321
+ * - `created`:创建时间**倒序**(刚加的在前)
322
+ * - `manual`:拖过顺序的(`order` 是有限数)按 order 升序在前;没拖过的按 priority 规则排在后面
323
+ * @param {object[]} records
324
+ * @param {'manual'|'priority'|'created'} [mode]
325
+ */
326
+ export function sortForView(records, mode = 'priority') {
316
327
  const rank = (r) => (r.status === 'completed' ? 1 : 0);
317
- return [...records].sort((a, b) => {
318
- if (rank(a) !== rank(b)) return rank(a) - rank(b);
319
- if (a.status === 'completed' && b.status === 'completed') {
320
- // 已完成按完成时间倒序:最近做完的先看到
321
- return (b.completedAt ?? b.updatedAt) - (a.completedAt ?? a.updatedAt);
322
- }
328
+ const byFallback = (a, b) => {
323
329
  const pa = PRIORITY_WEIGHT[a.priority] ?? 1;
324
330
  const pb = PRIORITY_WEIGHT[b.priority] ?? 1;
325
331
  if (pa !== pb) return pa - pb;
@@ -327,9 +333,36 @@ export function sortForView(records) {
327
333
  if (a.due && !b.due) return -1;
328
334
  if (!a.due && b.due) return 1;
329
335
  return a.createdAt - b.createdAt;
336
+ };
337
+ const manualRank = (r) => (Number.isFinite(r.order) ? 0 : 1);
338
+ return [...records].sort((a, b) => {
339
+ if (rank(a) !== rank(b)) return rank(a) - rank(b);
340
+ if (a.status === 'completed' && b.status === 'completed') {
341
+ // 已完成按完成时间倒序:最近做完的先看到
342
+ return (b.completedAt ?? b.updatedAt) - (a.completedAt ?? a.updatedAt);
343
+ }
344
+ if (mode === 'created') return b.createdAt - a.createdAt;
345
+ if (mode === 'manual') {
346
+ if (manualRank(a) !== manualRank(b)) return manualRank(a) - manualRank(b);
347
+ if (manualRank(a) === 0 && a.order !== b.order) return a.order - b.order;
348
+ return byFallback(a, b);
349
+ }
350
+ return byFallback(a, b);
330
351
  });
331
352
  }
332
353
 
354
+ /**
355
+ * 把「新的完整顺序」落到记录上:ids[i] 的记录得到 order=i。
356
+ * 不在 ids 里的记录保持原样(order 为 null 的继续按自动规则排)。
357
+ * 纯函数:返回新数组,不改入参。
358
+ * @param {object[]} records
359
+ * @param {string[]} ids
360
+ */
361
+ export function applyManualOrder(records, ids) {
362
+ const position = new Map(ids.map((id, index) => [id, index]));
363
+ return records.map((record) => (position.has(record.id) ? { ...record, order: position.get(record.id) } : record));
364
+ }
365
+
333
366
  /**
334
367
  * 分组成面板的五个区。
335
368
  * 「本周」= 今天之后到本周日;无截止日期的进「以后」。
@@ -337,7 +370,7 @@ export function sortForView(records) {
337
370
  * @param {number} [now]
338
371
  * @returns {{overdue: object[], today: object[], week: object[], later: object[], completed: object[]}}
339
372
  */
340
- export function groupRecords(records, now = Date.now()) {
373
+ export function groupRecords(records, now = Date.now(), mode = 'priority') {
341
374
  const today = dayKey(now);
342
375
  const weekEnd = dayKey(dayStart(weekStartKey(now)) + 6 * DAY_MS);
343
376
  const groups = { overdue: [], today: [], week: [], later: [], completed: [] };
@@ -350,7 +383,7 @@ export function groupRecords(records, now = Date.now()) {
350
383
  if (due <= weekEnd) { groups.week.push(record); continue; }
351
384
  groups.later.push(record);
352
385
  }
353
- for (const key of Object.keys(groups)) groups[key] = sortForView(groups[key]);
386
+ for (const key of Object.keys(groups)) groups[key] = sortForView(groups[key], mode);
354
387
  return groups;
355
388
  }
356
389
 
@@ -360,6 +393,49 @@ export function groupRecords(records, now = Date.now()) {
360
393
  * @param {number} [now]
361
394
  * @returns {{total: number, open: number, overdue: number, today: number, doing: number, completed: number, completedToday: number}}
362
395
  */
396
+ /**
397
+ * 按项目分组:面板的「按项目」视图。
398
+ *
399
+ * 规则:
400
+ * - 没写 project 的记录归入「未归类」桶(project 为 null,永远排在最后);
401
+ * - 桶的排序:未完成条数多的在前 → 未完成里最早的截止日期更早的在前 → 名字字典序;
402
+ * - 桶内沿用 sortForView(未完成在前、高优先级在前、临近截止在前);
403
+ * - 每个桶附 total / open / overdue,面板可直接显示「项目名 · 未完成/总数」。
404
+ *
405
+ * @param {object[]} records
406
+ * @param {number} [now]
407
+ * @returns {{project: string|null, key: string, todos: object[], total: number, open: number, overdue: number}[]}
408
+ */
409
+ export function groupByProject(records, now = Date.now(), mode = 'priority') {
410
+ const today = dayKey(now);
411
+ const buckets = new Map();
412
+ for (const record of records) {
413
+ const project = record.project ?? null;
414
+ const key = project ?? '';
415
+ if (!buckets.has(key)) buckets.set(key, { project, key, todos: [], total: 0, open: 0, overdue: 0 });
416
+ const bucket = buckets.get(key);
417
+ bucket.todos.push(record);
418
+ bucket.total += 1;
419
+ if (record.status !== 'completed') {
420
+ bucket.open += 1;
421
+ if (record.due && record.due < today) bucket.overdue += 1;
422
+ }
423
+ }
424
+ const list = [...buckets.values()];
425
+ for (const bucket of list) bucket.todos = sortForView(bucket.todos, mode);
426
+ const earliestOpenDue = (bucket) =>
427
+ bucket.todos.filter((t) => t.status !== 'completed' && t.due).map((t) => t.due).sort()[0] ?? '9999-99-99';
428
+ return list.sort((a, b) => {
429
+ // 未归类永远最后:它不是一个真正的项目
430
+ if ((a.project === null) !== (b.project === null)) return a.project === null ? 1 : -1;
431
+ if (a.open !== b.open) return b.open - a.open;
432
+ const da = earliestOpenDue(a);
433
+ const db = earliestOpenDue(b);
434
+ if (da !== db) return da < db ? -1 : 1;
435
+ return (a.project ?? '').localeCompare(b.project ?? '');
436
+ });
437
+ }
438
+
363
439
  export function statsOf(records, now = Date.now()) {
364
440
  const today = dayKey(now);
365
441
  const stats = { total: records.length, open: 0, overdue: 0, today: 0, doing: 0, completed: 0, completedToday: 0 };
package/src/schema.mjs CHANGED
@@ -67,6 +67,8 @@ export function repairRecord(raw) {
67
67
  const source = SOURCES.includes(raw.source) ? raw.source : 'human';
68
68
  const createdAt = Number.isFinite(raw.createdAt) ? Number(raw.createdAt) : Date.now();
69
69
  const updatedAt = Number.isFinite(raw.updatedAt) ? Number(raw.updatedAt) : createdAt;
70
+ // 手动排序位置:只有拖过的记录才有;null 表示「按优先级/截止/创建时间自动排」
71
+ const order = Number.isFinite(raw.order) ? Number(raw.order) : null;
70
72
  const completedAt = status === 'completed'
71
73
  ? (Number.isFinite(raw.completedAt) ? Number(raw.completedAt) : updatedAt)
72
74
  : null;
@@ -80,6 +82,7 @@ export function repairRecord(raw) {
80
82
  // 相对日期(today/+3d)按「读到的这一刻」折算:手改文件的人想的当然是今天
81
83
  due: typeof raw.due === 'string' ? coerce(() => normalizeDue(raw.due, Date.now()), null) : null,
82
84
  project: typeof raw.project === 'string' && raw.project.trim() ? raw.project.trim() : null,
85
+ order,
83
86
  source,
84
87
  createdAt,
85
88
  updatedAt,
package/src/service.mjs CHANGED
@@ -16,7 +16,10 @@ import {
16
16
  adoptSessionTodos,
17
17
  createRecord,
18
18
  dayKey,
19
+ SORT_MODES,
20
+ applyManualOrder,
19
21
  filterRecords,
22
+ groupByProject,
20
23
  groupRecords,
21
24
  patchRecord,
22
25
  resolveRef,
@@ -79,7 +82,9 @@ export function createWorkTodoService({ store, clock = () => Date.now(), logger
79
82
  * @param {string} [options.query] 标题/备注里的关键词
80
83
  * @returns {Promise<object>} 显式状态
81
84
  */
82
- async function list({ filter = 'open', project, query } = {}) {
85
+ async function list({ filter = 'open', project, query, sort } = {}) {
86
+ // 默认 manual:拖过的顺序优先,没拖过的自动退回优先级/截止/创建时间(所以初看依然合理)
87
+ const mode = SORT_MODES.includes(sort) ? sort : 'manual';
83
88
  const now = clock();
84
89
  let records;
85
90
  try {
@@ -101,12 +106,28 @@ export function createWorkTodoService({ store, clock = () => Date.now(), logger
101
106
  });
102
107
  if (!picked.ok) return picked;
103
108
 
109
+ // 项目视图用的分组:**不套 project 收窄**,否则面板上只剩当前项目、没法切到别处
110
+ const forProjects = attempt(() => {
111
+ let subset = records;
112
+ if (query) {
113
+ const needle = String(query).trim().toLowerCase();
114
+ subset = subset.filter((r) => r.title.toLowerCase().includes(needle) || (r.notes ?? '').toLowerCase().includes(needle));
115
+ }
116
+ return filterRecords(subset, filter, now);
117
+ });
118
+ if (!forProjects.ok) return forProjects;
119
+
104
120
  return {
105
121
  ok: true,
106
- todos: picked.value,
107
- groups: groupRecords(picked.value, now),
122
+ // filterRecords 内部按优先级排过序;这里按当前模式再排一次,
123
+ // 否则「平铺列表」会和「分组」用两套顺序(sort=created/manual 时最明显)
124
+ todos: sortForView(picked.value, mode),
125
+ groups: groupRecords(picked.value, now, mode),
126
+ // 「按项目」视图:每个项目一个桶,附 total/open/overdue
127
+ projectGroups: groupByProject(forProjects.value, now, mode),
108
128
  // 统计基于**全部**记录,而不是当前筛选结果:面板顶部的「逾期 3 条」不该因为
109
129
  // 用户切到「已完成」标签就变成 0
130
+ sort: mode,
110
131
  stats: statsOf(records, now),
111
132
  today: dayKey(now),
112
133
  store: { kind: store.kind, location: store.location },
@@ -134,6 +155,39 @@ export function createWorkTodoService({ store, clock = () => Date.now(), logger
134
155
  return { ok: true, todo: built.value };
135
156
  }
136
157
 
158
+ /**
159
+ * 手动排序:ids 是「新的完整顺序」,ids[i] 的记录得到 order=i。
160
+ * 只写真正变了的记录;不在 ids 里的记录保持原样(order 仍为 null,继续按自动规则排)。
161
+ * @param {{ids?: string[]}} [input]
162
+ */
163
+ async function reorder({ ids } = {}) {
164
+ const order = Array.isArray(ids) ? ids.filter((id) => typeof id === 'string' && id.trim()) : [];
165
+ if (!order.length) return { ok: false, reason: 'empty-order', message: '需要给出待办 id 的顺序数组' };
166
+ let records;
167
+ try {
168
+ records = await all();
169
+ } catch (error) {
170
+ return { ok: false, reason: 'store-unreadable', message: `读取待办失败:${error?.message ?? error}` };
171
+ }
172
+ const known = new Set(records.map((record) => record.id));
173
+ const unknownIds = order.filter((id) => !known.has(id));
174
+ if (unknownIds.length) return { ok: false, reason: 'not-found', message: `这些 id 不存在:${unknownIds.join('、')}` };
175
+
176
+ const next = applyManualOrder(records, order);
177
+ let updated = 0;
178
+ for (let index = 0; index < next.length; index += 1) {
179
+ if (next[index] === records[index]) continue; // 顺序没变的记录不写盘
180
+ try {
181
+ await store.put(next[index]);
182
+ updated += 1;
183
+ } catch (error) {
184
+ return { ok: false, reason: 'store-write-failed', message: `保存顺序失败:${error?.message ?? error}` };
185
+ }
186
+ }
187
+ emit({ operation: 'reorder', updated });
188
+ return { ok: true, updated, order };
189
+ }
190
+
137
191
  /**
138
192
  * 改一条:按引用定位,只覆盖显式给出的字段。
139
193
  * @param {string} ref id 或标题
@@ -243,6 +297,7 @@ export function createWorkTodoService({ store, clock = () => Date.now(), logger
243
297
 
244
298
  return {
245
299
  list,
300
+ reorder,
246
301
  create,
247
302
  update,
248
303
  setStatus,
package/src/tools.mjs CHANGED
@@ -22,6 +22,7 @@
22
22
  */
23
23
  export const TOOL_NAMES = Object.freeze([
24
24
  'worktodo_add',
25
+ 'worktodo_add_many',
25
26
  'worktodo_list',
26
27
  'worktodo_update',
27
28
  'worktodo_done',
@@ -127,6 +128,109 @@ export async function registerWorkTodoTools({ ctx, service, logger }) {
127
128
  presentCall: (args) => ({ card: 'generic', title: '登记工作待办', kind: 'other', rawInput: args.title }),
128
129
  })));
129
130
 
131
+ // ---- 批量新增(AI 友好:一口气登记多条)----
132
+ disposers.push(ctx.tools.register(defineTool({
133
+ name: 'worktodo_add_many',
134
+ description:
135
+ '一次登记多条工作待办(用户一口气说了好几件事、或你整理出一串要跟进的活时用它;分开一条条调 worktodo_add 也行,但这样更省调用)。'
136
+ + '某条不合法只跳过那一条并把原因回传,不影响其余条目。',
137
+ parameters: {
138
+ items: {
139
+ type: 'array',
140
+ required: true,
141
+ description: '要登记的事;每项至少给 title。',
142
+ items: {
143
+ type: 'object',
144
+ additionalProperties: false,
145
+ properties: {
146
+ title: { type: 'string', required: true, description: '待办内容(一句简短的祈使句)。' },
147
+ due: { type: 'string', description: '截止日期:YYYY-MM-DD / today / tomorrow / +3d。' },
148
+ priority: { type: 'string', enum: ['high', 'normal', 'low'], description: '默认 normal。' },
149
+ project: { type: 'string', description: '项目或领域标签。' },
150
+ notes: { type: 'string', description: '背景、链接、验收标准。' },
151
+ status: { type: 'string', enum: ['pending', 'in_progress', 'completed'], description: '默认 pending。' },
152
+ },
153
+ },
154
+ },
155
+ },
156
+ output: {
157
+ schema: {
158
+ type: 'object',
159
+ additionalProperties: false,
160
+ properties: {
161
+ count: { type: 'number', required: true },
162
+ created: {
163
+ type: 'array',
164
+ required: true,
165
+ items: {
166
+ type: 'object',
167
+ additionalProperties: false,
168
+ properties: {
169
+ id: { type: 'string', required: true },
170
+ title: { type: 'string', required: true },
171
+ due: { type: 'string' },
172
+ priority: { type: 'string', required: true },
173
+ project: { type: 'string' },
174
+ status: { type: 'string', required: true },
175
+ },
176
+ },
177
+ },
178
+ skipped: {
179
+ type: 'array',
180
+ required: true,
181
+ items: {
182
+ type: 'object',
183
+ additionalProperties: false,
184
+ properties: {
185
+ index: { type: 'number', required: true },
186
+ title: { type: 'string', required: true },
187
+ reason: { type: 'string', required: true },
188
+ },
189
+ },
190
+ },
191
+ stats: { type: 'object', additionalProperties: true },
192
+ },
193
+ },
194
+ render: (_args, value) => [{
195
+ type: 'text',
196
+ text: `已记入工作待办 ${value.count} 条`
197
+ + (value.skipped.length ? `,跳过 ${value.skipped.length} 条(${value.skipped.map((entry) => `#${entry.index} ${entry.reason}`).join(';')})` : '')
198
+ + (value.stats ? `|当前未完成 ${value.stats.open} 条,逾期 ${value.stats.overdue} 条` : ''),
199
+ }],
200
+ },
201
+ async execute(args) {
202
+ const created = [];
203
+ const skipped = [];
204
+ const items = Array.isArray(args.items) ? args.items : [];
205
+ for (const [index, item] of items.entries()) {
206
+ try {
207
+ const result = unwrap(await service.create(item ?? {}, { source: 'agent' }));
208
+ created.push(compact({
209
+ id: result.todo.id,
210
+ title: result.todo.title,
211
+ due: result.todo.due ?? undefined,
212
+ priority: result.todo.priority,
213
+ project: result.todo.project ?? undefined,
214
+ status: result.todo.status,
215
+ }));
216
+ } catch (error) {
217
+ skipped.push({
218
+ index,
219
+ title: typeof item?.title === 'string' ? item.title : '',
220
+ reason: String(error?.message ?? error),
221
+ });
222
+ }
223
+ }
224
+ return withStats(compact({ count: created.length, created, skipped }));
225
+ },
226
+ presentCall: (args) => ({
227
+ card: 'generic',
228
+ title: `登记 ${Array.isArray(args.items) ? args.items.length : 0} 条工作待办`,
229
+ kind: 'other',
230
+ rawInput: '',
231
+ }),
232
+ })));
233
+
130
234
  // ---- 查询 ----
131
235
  disposers.push(ctx.tools.register(defineTool({
132
236
  name: 'worktodo_list',
@@ -138,6 +242,7 @@ export async function registerWorkTodoTools({ ctx, service, logger }) {
138
242
  filter: { type: 'string', enum: ['open', 'today', 'overdue', 'week', 'doing', 'completed', 'all'], description: '默认 open(未完成)。' },
139
243
  project: { type: 'string', description: '只看某个项目标签。' },
140
244
  query: { type: 'string', description: '按标题或备注里的关键词过滤。' },
245
+ sort: { type: 'string', enum: ['manual', 'priority', 'created'], description: '排序:manual=尊重用户拖动过的顺序(默认,用户看到的顺序);priority=高优先级在前;created=最近添加的在前。' },
141
246
  },
142
247
  output: {
143
248
  schema: {