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.
- package/CHANGELOG.md +67 -2
- package/README.en.md +13 -3
- package/README.md +18 -3
- package/lib/client.js +138 -14
- package/lib/client.js.map +2 -2
- package/lib/index.js +50 -0
- package/package.json +15 -25
- package/src/client/index.js +165 -12
- package/src/records.mjs +85 -9
- package/src/schema.mjs +3 -0
- package/src/service.mjs +58 -3
- package/src/tools.mjs +105 -0
package/src/client/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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' },
|
|
1134
|
+
? React.createElement('div', { key: 'groups' }, viewGroups.map((group) => React.createElement(Group, {
|
|
984
1135
|
key: group.key,
|
|
985
|
-
label:
|
|
986
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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: {
|