nx-kv 0.1.1 → 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,280 +1,285 @@
1
- // 清单页:三个桶(待办 / 已完成 / 冻结)+ 主题筛选 + 增删改查与状态流转。
2
- // 每个按钮 = 一条 /api 路由 = 一条 CLI 命令(三者同源于模块的 action 声明)。
3
- import { useCallback, useEffect, useMemo, useState } from 'react';
4
- import { api } from '../../web/frontend/api/client.js';
5
- import { useStore } from '../../web/frontend/store.jsx';
6
- import { useToast, useGuard, useDialog, Modal, Copyable } from '../../web/frontend/components/ui.jsx';
7
- import { CliHints } from '../../web/frontend/components/CliHints.jsx';
8
-
9
- const BUCKETS = [
10
- ['open', '待办'],
11
- ['done', '已完成'],
12
- ['freeze', '冻结'],
13
- ];
14
-
15
- const shortTime = (s) => (s ? String(s).slice(0, 16).replace('T', ' ') : '');
16
-
17
- // 单个桶超过这个条数就折叠。已完成常年上百条,全铺出来会把待办挤到看不见。
18
- const COLLAPSE_AT = 15;
19
-
20
- // 一个任务在哪些桶里可能重名 —— id 不是唯一键,UI 必须能显示出来
21
- const taskKey = (bucket, t) => `${bucket}:${t.id}`;
22
-
23
- function TaskRow({ bucket, task, dupCount, onAct }) {
24
- const isDone = bucket === 'done';
25
- const isFrozen = bucket === 'freeze';
26
- const dt = isDone ? task.doneAt : isFrozen ? task.frozenAt : task.createdAt;
27
-
28
- return (
29
- <div className="row">
30
- <span className="tid">#{task.id}</span>
31
- <Copyable className="topic" text={task.topic} title="点击复制主题">{task.topic}</Copyable>
32
- <span className="text" title={task.text}>{task.text}</span>
33
- {dupCount > 1 ? (
34
- <span className="tag" title={`同 id 有 ${dupCount} 条,操作时需要消歧`}>id×{dupCount}</span>
35
- ) : null}
36
- <span className="muted time">{shortTime(dt)}</span>
37
- <span className="acts">
38
- {bucket === 'open' ? (
39
- <>
40
- <button className="btn small" onClick={() => onAct('done', task)}>完成</button>
41
- <button className="btn small ghost" onClick={() => onAct('freeze', task)}>冻结</button>
42
- </>
43
- ) : null}
44
- {isFrozen ? (
45
- <button className="btn small" onClick={() => onAct('unfreeze', task)}>解冻</button>
46
- ) : null}
47
- <button className="btn small ghost" onClick={() => onAct('edit', task, bucket)}>编辑</button>
48
- <button className="btn small ghost danger" onClick={() => onAct('remove', task, bucket)}>删除</button>
49
- </span>
50
- {task.note ? <div className="note">{task.note}</div> : null}
51
- </div>
52
- );
53
- }
54
-
55
- export default function TodoView() {
56
- const { boot, ui, patchUi, refreshBoot } = useStore();
57
- const toast = useToast();
58
- const guard = useGuard();
59
- const { dialog, node: dialogNode } = useDialog();
60
-
61
- const [data, setData] = useState(null);
62
- const [busy, setBusy] = useState(false);
63
- const [form, setForm] = useState({ topic: '', text: '' });
64
- const [modal, setModal] = useState(null);
65
- // 已完成常常上百条,默认只展示前 N 条,避免整页全是历史噪音
66
- const [expanded, setExpanded] = useState({});
67
-
68
- const group = boot?.groupId ?? 0;
69
- const groupName = (boot?.groups || []).find((g) => g.id === group)?.name || (group ? `#${group}` : '默认组');
70
-
71
- const load = useCallback(async () => {
72
- if (!boot?.loggedIn) return;
73
- try {
74
- setData(await api('/api/todo'));
75
- } catch (e) {
76
- toast(e.message);
77
- }
78
- }, [boot?.loggedIn, toast]);
79
-
80
- useEffect(() => {
81
- load();
82
- // groupId 变化后必须重取 —— 否则界面还显示上一个工作空间的数据
83
- }, [load, group]);
84
-
85
- const reload = useCallback(async () => {
86
- setBusy(true);
87
- try {
88
- await load();
89
- } finally {
90
- setBusy(false);
91
- }
92
- }, [load]);
93
-
94
- // 同 id 出现几次 —— 让用户在点「删除」前就看见歧义风险
95
- const dupCounts = useMemo(() => {
96
- const m = new Map();
97
- if (!data) return m;
98
- for (const [key] of BUCKETS) {
99
- for (const t of data[key] || []) m.set(t.id, (m.get(t.id) || 0) + 1);
100
- }
101
- return m;
102
- }, [data]);
103
-
104
- const filtered = useMemo(() => {
105
- if (!data) return null;
106
- const topic = ui.topic;
107
- const pick = (arr) => (arr ? (topic ? arr.filter((t) => t.topic === topic) : arr) : null);
108
- return { open: pick(data.open), done: pick(data.done), freeze: pick(data.freeze) };
109
- }, [data, ui.topic]);
110
-
111
- if (!boot?.loggedIn) {
112
- return (
113
- <div className="card" style={{ padding: 16 }}>
114
- <div className="colhead"><h3>请先登录</h3></div>
115
- <div className="muted">在「登录」页填写邮箱与密码,或执行 <code>nx-kv auth login &lt;email&gt;</code>。</div>
116
- </div>
117
- );
118
- }
119
-
120
- // 消歧:同 id 多条时让用户选一条(与 CLI 的 --pick 对应)。
121
- //
122
- // 返回的 `pick` 是**在候选列表里的下标**,必须原样回传给后端 ——
123
- // 否则后端会再次面对「同 id 多条」而拒绝执行(这是刻意的:
124
- // 不替调用方猜)。候选顺序与服务端 locateAll 一致:
125
- // 按 open → done → freeze、桶内按数组顺序。
126
- const pickOne = async (task, bucket) => {
127
- const candidates = [];
128
- for (const [key] of BUCKETS) {
129
- for (const t of data[key] || []) if (t.id === task.id) candidates.push({ bucket: key, task: t });
130
- }
131
- if (candidates.length <= 1) return { bucket, task, pick: undefined };
132
-
133
- const self = candidates.findIndex((c) => c.bucket === bucket && c.task.text === task.text);
134
- const answer = await dialog({
135
- title: `id #${task.id} 有 ${candidates.length} 条,选哪一条?`,
136
- message: candidates
137
- .map((c, i) => `[${i}] ${c.bucket} ${shortTime(c.task.createdAt)} ${c.task.text.slice(0, 40)}`)
138
- .join('\n'),
139
- input: true,
140
- placeholder: `输入 0..${candidates.length - 1}`,
141
- value: String(self >= 0 ? self : 0),
142
- });
143
- if (answer === null) return null;
144
- const i = Number(answer);
145
- if (!Number.isInteger(i) || !candidates[i]) {
146
- toast(`请输入 0..${candidates.length - 1}`);
147
- return null;
148
- }
149
- return { ...candidates[i], pick: i };
150
- };
151
-
152
- const onAct = (act, task, bucket) => guard(async () => {
153
- // 会改动「某一条」的动作都要先消歧;状态流转只作用于待办/冻结,那里 id 唯一
154
- const needsPick = act === 'remove' || act === 'edit';
155
- const target = needsPick ? await pickOne(task, bucket) : { bucket, task, pick: undefined };
156
- if (!target) return;
157
- const body = { group, pick: target.pick };
158
-
159
- if (act === 'done') {
160
- const result = await dialog({ title: '完成结果', message: `完成「${task.text.slice(0, 40)}」`, input: true, placeholder: '可选:写一句完成结果' });
161
- if (result === null) return;
162
- await api(`/api/todo/${task.id}/done`, { method: 'POST', body: { ...body, result: result || undefined } });
163
- toast('已完成');
164
- } else if (act === 'freeze') {
165
- await api(`/api/todo/${task.id}/freeze`, { method: 'POST', body });
166
- toast('已冻结');
167
- } else if (act === 'unfreeze') {
168
- await api(`/api/todo/${task.id}/unfreeze`, { method: 'POST', body });
169
- toast('已解冻');
170
- } else if (act === 'remove') {
171
- const ok = await dialog({ message: `删除 #${task.id}「${task.text.slice(0, 40)}」?`, danger: true });
172
- if (!ok) return;
173
- await api(`/api/todo/${target.task.id}`, { method: 'DELETE', body });
174
- toast('已删除');
175
- } else if (act === 'edit') {
176
- const text = await dialog({ title: '编辑内容', input: true, value: target.task.text });
177
- if (text === null || !text) return;
178
- await api(`/api/todo/${target.task.id}`, {
179
- method: 'PATCH',
180
- body: { ...body, text, matchTopic: target.task.topic },
181
- });
182
- toast('已更新');
183
- }
184
- await reload();
185
- await refreshBoot();
186
- });
187
-
188
- // 归档:把已完成里较旧的条目移到冷 key(todo:done:cold:<日期>)。
189
- // 后端没有「批量删」接口,所以这是清理已完成列表的唯一手段。
190
- const archive = () => guard(async () => {
191
- const before = await dialog({
192
- title: '归档已完成的旧记录',
193
- message: '把「完成时间早于该日期」的记录移到冷 key(默认 30 天前)。留空用默认值。',
194
- input: true,
195
- placeholder: 'YYYY-MM-DD,留空 = 30 天前',
196
- });
197
- if (before === null) return;
198
- const r = await api('/api/todo/archive', { method: 'POST', body: { before: before.trim() || undefined, group } });
199
- toast(r.moved ? `已归档 ${r.moved} 条 → ${r.coldKey}` : '没有需要归档的记录');
200
- await reload();
201
- });
202
-
203
- const add = () => guard(async () => {
204
- const topic = form.topic.trim();
205
- const text = form.text.trim();
206
- if (!topic) { toast('请填主题'); return; }
207
- if (!text) { toast('请填内容'); return; }
208
- const r = await api('/api/todo', { method: 'POST', body: { topic, text, group } });
209
- setForm({ topic, text: '' });
210
- toast(`已加入待办 #${r.id}${r.topicAdded ? `(新主题 ${topic})` : ''}`);
211
- await reload();
212
- });
213
-
214
- const visible = (key, items) => (expanded[key] ? items : items.slice(0, COLLAPSE_AT));
215
-
216
- const topics = data?.topics || [];
217
- const counts = filtered
218
- ? BUCKETS.map(([k, label]) => `${label} ${(filtered[k] || []).length}`).join(' · ')
219
- : '';
220
-
221
- return (
222
- <>
223
- <div className="toolbar">
224
- <label>工作空间</label>
225
- <span className="pill on">{groupName}</span>
226
- <span className="sep"></span>
227
- <label>主题</label>
228
- <select value={ui.topic} onChange={(e) => patchUi({ topic: e.target.value })}>
229
- <option value="">全部</option>
230
- {topics.map((t) => (
231
- <option key={t} value={t}>{t}</option>
232
- ))}
233
- </select>
234
- <span className="sep"></span>
235
- <input placeholder="主题" size="8" value={form.topic}
236
- onChange={(e) => setForm({ ...form, topic: e.target.value })} />
237
- <input placeholder="要做什么…" size="30" value={form.text}
238
- onChange={(e) => setForm({ ...form, text: e.target.value })}
239
- onKeyDown={(e) => { if (e.key === 'Enter') add(); }} />
240
- <button className="btn" onClick={add}>加入待办</button>
241
- <button className="btn ghost" onClick={reload} disabled={busy}>{busy ? '刷新中…' : '刷新'}</button>
242
- </div>
243
-
244
- <div className="muted" style={{ margin: '0 0 8px 2px', fontSize: 12 }}>{counts}</div>
245
-
246
- <div className="cols">
247
- {BUCKETS.map(([key, label]) => (
248
- <div className="col" key={key}>
249
- <div className="colhead">
250
- <h3>{label}</h3>
251
- <span className="muted">{(filtered?.[key] || []).length}</span>
252
- {key === 'done' && (filtered?.done || []).length ? (
253
- <button className="btn small ghost" style={{ marginLeft: 'auto' }} onClick={archive}
254
- title="把较旧的完成记录移到冷 key">归档旧记录</button>
255
- ) : null}
256
- </div>
257
- <div className="card list">
258
- {(filtered?.[key] || []).length
259
- ? visible(key, filtered[key]).map((t) => (
260
- <TaskRow key={taskKey(key, t)} bucket={key} task={t}
261
- dupCount={dupCounts.get(t.id) || 1} onAct={onAct} />
262
- ))
263
- : <div className="row muted">(空)</div>}
264
- {(filtered?.[key] || []).length > COLLAPSE_AT ? (
265
- <div className="row muted" style={{ cursor: 'pointer', justifyContent: 'center' }}
266
- onClick={() => setExpanded((e) => ({ ...e, [key]: !e[key] }))}>
267
- {expanded[key] ? '收起' : `还有 ${filtered[key].length - COLLAPSE_AT} 条,点击展开`}
268
- </div>
269
- ) : null}
270
- </div>
271
- </div>
272
- ))}
273
- </div>
274
-
275
- <CliHints module="todo" />
276
- {modal ? <Modal title={modal.title} onClose={() => setModal(null)}>{modal.node}</Modal> : null}
277
- {dialogNode}
278
- </>
279
- );
280
- }
1
+ // 清单页:三个桶(待办 / 已完成 / 冻结)+ 主题筛选 + 增删改查与状态流转。
2
+ // 每个按钮 = 一条 /api 路由 = 一条 CLI 命令(三者同源于模块的 action 声明)。
3
+ import { useCallback, useEffect, useMemo, useState } from 'react';
4
+ import { api } from '../../web/frontend/api/client.js';
5
+ import { useStore } from '../../web/frontend/store.jsx';
6
+ import { useToast, useGuard, useDialog, Modal, Copyable } from '../../web/frontend/components/ui.jsx';
7
+ import { CliHints } from '../../web/frontend/components/CliHints.jsx';
8
+
9
+ const BUCKETS = [
10
+ ['open', '待办'],
11
+ ['done', '已完成'],
12
+ ['freeze', '冻结'],
13
+ ];
14
+
15
+ const shortTime = (s) => (s ? String(s).slice(0, 16).replace('T', ' ') : '');
16
+
17
+ // 单个桶超过这个条数就折叠。已完成常年上百条,全铺出来会把待办挤到看不见。
18
+ const COLLAPSE_AT = 15;
19
+
20
+ // 一个任务在哪些桶里可能重名 —— 定位按内容,UI 必须能显示出内容重复
21
+ const taskKey = (bucket, t) => `${bucket}:${t.id}:${t.text}`;
22
+
23
+ function TaskRow({ bucket, task, dupCount, onAct }) {
24
+ const isDone = bucket === 'done';
25
+ const isFrozen = bucket === 'freeze';
26
+ const dt = isDone ? task.doneAt : isFrozen ? task.frozenAt : task.createdAt;
27
+
28
+ return (
29
+ <div className="row">
30
+ <span className="tid">#{task.id}</span>
31
+ <Copyable className="topic" text={task.topic} title="点击复制主题">{task.topic}</Copyable>
32
+ <span className="text" title={task.text}>{task.text}</span>
33
+ {dupCount > 1 ? (
34
+ <span className="tag" title={`同内容有 ${dupCount} 条,操作时需要消歧`}>同内容×{dupCount}</span>
35
+ ) : null}
36
+ <span className="muted time">{shortTime(dt)}</span>
37
+ <span className="acts">
38
+ {bucket === 'open' ? (
39
+ <>
40
+ <button className="btn small" onClick={() => onAct('done', task)}>完成</button>
41
+ <button className="btn small ghost" onClick={() => onAct('freeze', task)}>冻结</button>
42
+ </>
43
+ ) : null}
44
+ {isFrozen ? (
45
+ <button className="btn small" onClick={() => onAct('unfreeze', task)}>解冻</button>
46
+ ) : null}
47
+ <button className="btn small ghost" onClick={() => onAct('edit', task, bucket)}>编辑</button>
48
+ <button className="btn small ghost danger" onClick={() => onAct('remove', task, bucket)}>删除</button>
49
+ </span>
50
+ {task.note ? <div className="note">{task.note}</div> : null}
51
+ </div>
52
+ );
53
+ }
54
+
55
+ export default function TodoView() {
56
+ const { boot, ui, patchUi, refreshBoot } = useStore();
57
+ const toast = useToast();
58
+ const guard = useGuard();
59
+ const { dialog, node: dialogNode } = useDialog();
60
+
61
+ const [data, setData] = useState(null);
62
+ const [busy, setBusy] = useState(false);
63
+ const [form, setForm] = useState({ topic: '', text: '' });
64
+ const [modal, setModal] = useState(null);
65
+ // 已完成常常上百条,默认只展示前 N 条,避免整页全是历史噪音
66
+ const [expanded, setExpanded] = useState({});
67
+
68
+ const group = boot?.groupId ?? 0;
69
+ const groupName = (boot?.groups || []).find((g) => g.id === group)?.name || (group ? `#${group}` : '默认组');
70
+
71
+ const load = useCallback(async () => {
72
+ if (!boot?.loggedIn) return;
73
+ try {
74
+ setData(await api('/api/todo'));
75
+ } catch (e) {
76
+ toast(e.message);
77
+ }
78
+ }, [boot?.loggedIn, toast]);
79
+
80
+ useEffect(() => {
81
+ load();
82
+ // groupId 变化后必须重取 —— 否则界面还显示上一个工作空间的数据
83
+ }, [load, group]);
84
+
85
+ const reload = useCallback(async () => {
86
+ setBusy(true);
87
+ try {
88
+ await load();
89
+ } finally {
90
+ setBusy(false);
91
+ }
92
+ }, [load]);
93
+
94
+ // 同内容出现几次 —— 让用户在点「删除」前就看见歧义风险。
95
+ // 按内容而不是 id 统计:id 会被复用(分配只扫 open + freeze),拿它计数毫无意义。
96
+ const dupCounts = useMemo(() => {
97
+ const m = new Map();
98
+ if (!data) return m;
99
+ for (const [key] of BUCKETS) {
100
+ for (const t of data[key] || []) m.set(t.text, (m.get(t.text) || 0) + 1);
101
+ }
102
+ return m;
103
+ }, [data]);
104
+
105
+ const filtered = useMemo(() => {
106
+ if (!data) return null;
107
+ const topic = ui.topic;
108
+ const pick = (arr) => (arr ? (topic ? arr.filter((t) => t.topic === topic) : arr) : null);
109
+ return { open: pick(data.open), done: pick(data.done), freeze: pick(data.freeze) };
110
+ }, [data, ui.topic]);
111
+
112
+ if (!boot?.loggedIn) {
113
+ return (
114
+ <div className="card" style={{ padding: 16 }}>
115
+ <div className="colhead"><h3>请先登录</h3></div>
116
+ <div className="muted">在「登录」页填写邮箱与密码,或执行 <code>nx-kv auth login &lt;email&gt;</code>。</div>
117
+ </div>
118
+ );
119
+ }
120
+
121
+ // 消歧:同内容多条时让用户选一条(与 CLI 的 --pick 对应)。
122
+ //
123
+ // 定位按**内容**而不是 id —— id 会被复用(分配只扫 open + freeze),
124
+ // 拿它定位迟早改错数据。
125
+ //
126
+ // 返回的 `pick` 是**在候选列表里的下标**,必须原样回传给后端 ——
127
+ // 否则后端会再次面对「同内容多条」而拒绝执行(这是刻意的:
128
+ // 不替调用方猜)。候选顺序与服务端 locateAll 一致:
129
+ // 按 open → done → freeze、桶内按数组顺序。
130
+ const pickOne = async (task, bucket) => {
131
+ const candidates = [];
132
+ for (const [key] of BUCKETS) {
133
+ for (const t of data[key] || []) if (t.text === task.text) candidates.push({ bucket: key, task: t });
134
+ }
135
+ if (candidates.length <= 1) return { bucket, task, pick: undefined };
136
+
137
+ const self = candidates.findIndex((c) => c.bucket === bucket && c.task.createdAt === task.createdAt);
138
+ const answer = await dialog({
139
+ title: `「${task.text.slice(0, 30)}」有 ${candidates.length} 条,选哪一条?`,
140
+ message: candidates
141
+ .map((c, i) => `[${i}] ${c.bucket} ${shortTime(c.task.createdAt)} #${c.task.id}`)
142
+ .join('\n'),
143
+ input: true,
144
+ placeholder: `输入 0..${candidates.length - 1}`,
145
+ value: String(self >= 0 ? self : 0),
146
+ });
147
+ if (answer === null) return null;
148
+ const i = Number(answer);
149
+ if (!Number.isInteger(i) || !candidates[i]) {
150
+ toast(`请输入 0..${candidates.length - 1}`);
151
+ return null;
152
+ }
153
+ return { ...candidates[i], pick: i };
154
+ };
155
+
156
+ const onAct = (act, task, bucket) => guard(async () => {
157
+ // 会改动「某一条」的动作都要先消歧;状态流转只作用于待办/冻结,那里的内容一般唯一
158
+ const needsPick = act === 'remove' || act === 'edit';
159
+ const target = needsPick ? await pickOne(task, bucket) : { bucket, task, pick: undefined };
160
+ if (!target) return;
161
+ // 定位键:当前内容。edit 时即便内容被改成新的,也是用改前的内容定位。
162
+ const body = { group, ref: target.task.text, pick: target.pick };
163
+
164
+ if (act === 'done') {
165
+ const result = await dialog({ title: '完成结果', message: `完成「${task.text.slice(0, 40)}」`, input: true, placeholder: '可选:写一句完成结果' });
166
+ if (result === null) return;
167
+ await api('/api/todo/done', { method: 'POST', body: { ...body, result: result || undefined } });
168
+ toast('已完成');
169
+ } else if (act === 'freeze') {
170
+ await api('/api/todo/freeze', { method: 'POST', body });
171
+ toast('已冻结');
172
+ } else if (act === 'unfreeze') {
173
+ await api('/api/todo/unfreeze', { method: 'POST', body });
174
+ toast('已解冻');
175
+ } else if (act === 'remove') {
176
+ const ok = await dialog({ message: `删除「${task.text.slice(0, 40)}」?`, danger: true });
177
+ if (!ok) return;
178
+ await api('/api/todo/item', { method: 'DELETE', body });
179
+ toast('已删除');
180
+ } else if (act === 'edit') {
181
+ const text = await dialog({ title: '编辑内容', input: true, value: target.task.text });
182
+ if (text === null || !text) return;
183
+ await api('/api/todo/item', {
184
+ method: 'PATCH',
185
+ body: { ...body, text, matchTopic: target.task.topic },
186
+ });
187
+ toast('已更新');
188
+ }
189
+ await reload();
190
+ await refreshBoot();
191
+ });
192
+
193
+ // 归档:把已完成里较旧的条目移到冷 key(todo:done:cold:<日期>)。
194
+ // 后端没有「批量删」接口,所以这是清理已完成列表的唯一手段。
195
+ const archive = () => guard(async () => {
196
+ const before = await dialog({
197
+ title: '归档已完成的旧记录',
198
+ message: '把「完成时间早于该日期」的记录移到冷 key(默认 30 天前)。留空用默认值。',
199
+ input: true,
200
+ placeholder: 'YYYY-MM-DD,留空 = 30 天前',
201
+ });
202
+ if (before === null) return;
203
+ const r = await api('/api/todo/archive', { method: 'POST', body: { before: before.trim() || undefined, group } });
204
+ toast(r.moved ? `已归档 ${r.moved} 条 → ${r.coldKey}` : '没有需要归档的记录');
205
+ await reload();
206
+ });
207
+
208
+ const add = () => guard(async () => {
209
+ const topic = form.topic.trim();
210
+ const text = form.text.trim();
211
+ if (!topic) { toast('请填主题'); return; }
212
+ if (!text) { toast('请填内容'); return; }
213
+ const r = await api('/api/todo', { method: 'POST', body: { topic, text, group } });
214
+ setForm({ topic, text: '' });
215
+ toast(`已加入待办${r.topicAdded ? `(新主题 ${topic})` : ''}`);
216
+ await reload();
217
+ });
218
+
219
+ const visible = (key, items) => (expanded[key] ? items : items.slice(0, COLLAPSE_AT));
220
+
221
+ const topics = data?.topics || [];
222
+ const counts = filtered
223
+ ? BUCKETS.map(([k, label]) => `${label} ${(filtered[k] || []).length}`).join(' · ')
224
+ : '';
225
+
226
+ return (
227
+ <>
228
+ <div className="toolbar">
229
+ <label>工作空间</label>
230
+ <span className="pill on">{groupName}</span>
231
+ <span className="sep"></span>
232
+ <label>主题</label>
233
+ <select value={ui.topic} onChange={(e) => patchUi({ topic: e.target.value })}>
234
+ <option value="">全部</option>
235
+ {topics.map((t) => (
236
+ <option key={t} value={t}>{t}</option>
237
+ ))}
238
+ </select>
239
+ <span className="sep"></span>
240
+ <input placeholder="主题" size="8" value={form.topic}
241
+ onChange={(e) => setForm({ ...form, topic: e.target.value })} />
242
+ <input placeholder="要做什么…" size="30" value={form.text}
243
+ onChange={(e) => setForm({ ...form, text: e.target.value })}
244
+ onKeyDown={(e) => { if (e.key === 'Enter') add(); }} />
245
+ <button className="btn" onClick={add}>加入待办</button>
246
+ <button className="btn ghost" onClick={reload} disabled={busy}>{busy ? '刷新中…' : '刷新'}</button>
247
+ </div>
248
+
249
+ <div className="muted" style={{ margin: '0 0 8px 2px', fontSize: 12 }}>{counts}</div>
250
+
251
+ <div className="cols">
252
+ {BUCKETS.map(([key, label]) => (
253
+ <div className="col" key={key}>
254
+ <div className="colhead">
255
+ <h3>{label}</h3>
256
+ <span className="muted">{(filtered?.[key] || []).length}</span>
257
+ {key === 'done' && (filtered?.done || []).length ? (
258
+ <button className="btn small ghost" style={{ marginLeft: 'auto' }} onClick={archive}
259
+ title="把较旧的完成记录移到冷 key">归档旧记录</button>
260
+ ) : null}
261
+ </div>
262
+ <div className="card list">
263
+ {(filtered?.[key] || []).length
264
+ ? visible(key, filtered[key]).map((t) => (
265
+ <TaskRow key={taskKey(key, t)} bucket={key} task={t}
266
+ dupCount={dupCounts.get(t.text) || 1} onAct={onAct} />
267
+ ))
268
+ : <div className="row muted">(空)</div>}
269
+ {(filtered?.[key] || []).length > COLLAPSE_AT ? (
270
+ <div className="row muted" style={{ cursor: 'pointer', justifyContent: 'center' }}
271
+ onClick={() => setExpanded((e) => ({ ...e, [key]: !e[key] }))}>
272
+ {expanded[key] ? '收起' : `还有 ${filtered[key].length - COLLAPSE_AT} 条,点击展开`}
273
+ </div>
274
+ ) : null}
275
+ </div>
276
+ </div>
277
+ ))}
278
+ </div>
279
+
280
+ <CliHints module="todo" />
281
+ {modal ? <Modal title={modal.title} onClose={() => setModal(null)}>{modal.node}</Modal> : null}
282
+ {dialogNode}
283
+ </>
284
+ );
285
+ }