Skip to content

Commit 8533ffe

Browse files
committed
feat(tasks): REQ-293/294 行动中心入口与裁决队列 UI
1 parent 7a31ab4 commit 8533ffe

4 files changed

Lines changed: 357 additions & 2 deletions

File tree

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
/**
2+
* ActionCenterOverlay — ✅ 行动中心(v0.20.3 / REQ-293/294/298)。
3+
*
4+
* @ai-context: 裁决漏斗(不留死尸的机制是裁决不是自动清理):待办分区
5+
* 逾期(标红)/计划日/搁置/待提炼,行操作 ✓完成 · 📅改期 · ⤴迁出
6+
* (剪贴板复制 todo.txt 行——保底三件套之复制面;文件/邮件与
7+
* scheme 通道由导出命令面补足)· ✗放弃(留一行原因);完成史页签
8+
* 展示统一证据流(周回顾原料/成长轨迹)。
9+
* @ai-context: 全部操作走 commands_tasks 命令族——正文回写收敛于 tasks_core
10+
* 原子层;操作后重载本面板(列表即真相;笔记域广播驱动阅读侧)。
11+
*/
12+
import { useCallback, useEffect, useRef, useState } from "react";
13+
import { invoke } from "@tauri-apps/api/core";
14+
15+
interface ActionRow {
16+
id: number;
17+
note_id: number;
18+
line_no: number;
19+
text: string;
20+
status: string;
21+
unrefined: boolean;
22+
plan_date: number | null;
23+
disposition: string | null;
24+
note_title: string;
25+
}
26+
27+
interface HistoryRow {
28+
id: number;
29+
ts: number;
30+
event_type: string;
31+
source_type: string;
32+
source_id: number | null;
33+
note_id: number | null;
34+
text: string;
35+
note: string | null;
36+
meta_json: string | null;
37+
}
38+
39+
interface Props {
40+
onClose: () => void;
41+
/** 数据变化回调(NotesPage 列表刷新等) */
42+
onChanged?: () => void;
43+
}
44+
45+
const overlayStyle: React.CSSProperties = {
46+
position: "fixed",
47+
inset: 0,
48+
background: "rgba(0,0,0,.45)",
49+
zIndex: 1000,
50+
display: "flex",
51+
alignItems: "center",
52+
justifyContent: "center",
53+
};
54+
const cardStyle: React.CSSProperties = {
55+
background: "#fff",
56+
borderRadius: 12,
57+
width: 780,
58+
maxWidth: "94vw",
59+
maxHeight: "84vh",
60+
overflow: "auto",
61+
padding: 16,
62+
fontSize: 13,
63+
};
64+
const btn: React.CSSProperties = { padding: "4px 10px", cursor: "pointer", fontSize: 12, borderRadius: 6, border: "1px solid #e5e7eb", background: "#fff", color: "#374151" };
65+
const okBtn: React.CSSProperties = { ...btn, background: "#0d9488", color: "#fff", border: "none" };
66+
const dangerBtn: React.CSSProperties = { ...btn, color: "#dc2626" };
67+
68+
const TYPE_LABEL: Record<string, string> = {
69+
done: "✓ 完成",
70+
abandoned: "✗ 放弃",
71+
exported: "⤴ 迁出",
72+
export_manual_done: "⤴ 回填完成",
73+
practice_tick: "🎯 练习打点",
74+
sop_run: "🧭 SOP 执行",
75+
};
76+
77+
function fmtDate(ts: number): string {
78+
const d = new Date(ts * 1000);
79+
const p = (n: number) => String(n).padStart(2, "0");
80+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
81+
}
82+
83+
export default function ActionCenterOverlay({ onClose, onChanged }: Props) {
84+
const [tab, setTab] = useState<"queue" | "history">("queue");
85+
// 裁决队列(四分区同拉全量行,前端按 tab 分区展示——逾期标红/计划/搁置/提炼)
86+
const [rows, setRows] = useState<ActionRow[]>([]);
87+
const [history, setHistory] = useState<HistoryRow[]>([]);
88+
const [busy, setBusy] = useState(false);
89+
const [err, setErr] = useState("");
90+
const [msg, setMsg] = useState("");
91+
const [reasonFor, setReasonFor] = useState<number | null>(null);
92+
const reasonRef = useRef("");
93+
94+
const reload = useCallback(async () => {
95+
try {
96+
setErr("");
97+
const [q, h] = await Promise.all([
98+
invoke<ActionRow[]>("list_action_queue", { tab: "someday", noteId: null }),
99+
invoke<HistoryRow[]>("completion_history_list", { eventType: null, limit: 150 }),
100+
]);
101+
setRows(q);
102+
setHistory(h);
103+
} catch (e) {
104+
setErr(`加载失败: ${e}`);
105+
}
106+
}, []);
107+
108+
// 队列四分区拉取(逾期/计划/搁置/待提炼)
109+
const fetchTab = useCallback(async (t: "overdue" | "planned" | "someday" | "unrefined") => {
110+
try {
111+
const r = await invoke<ActionRow[]>("list_action_queue", { tab: t, noteId: null });
112+
return r;
113+
} catch (e) {
114+
setErr(`队列加载失败: ${e}`);
115+
return [] as ActionRow[];
116+
}
117+
}, []);
118+
119+
const [overdue, setOverdue] = useState<ActionRow[]>([]);
120+
const [planned, setPlanned] = useState<ActionRow[]>([]);
121+
const [unrefined, setUnrefined] = useState<ActionRow[]>([]);
122+
123+
useEffect(() => {
124+
void (async () => {
125+
const [o, p, s, u] = await Promise.all([
126+
fetchTab("overdue"),
127+
fetchTab("planned"),
128+
fetchTab("someday"),
129+
fetchTab("unrefined"),
130+
]);
131+
setOverdue(o);
132+
setPlanned(p);
133+
setRows(s);
134+
setUnrefined(u);
135+
})();
136+
void reload();
137+
}, [fetchTab, reload]);
138+
139+
const afterMutate = async (info?: string) => {
140+
if (info) setMsg(info);
141+
onChanged?.();
142+
const [o, p, s, u, h] = await Promise.all([
143+
fetchTab("overdue"),
144+
fetchTab("planned"),
145+
fetchTab("someday"),
146+
fetchTab("unrefined"),
147+
invoke<HistoryRow[]>("completion_history_list", { eventType: null, limit: 150 }),
148+
]);
149+
setOverdue(o);
150+
setPlanned(p);
151+
setRows(s);
152+
setUnrefined(u);
153+
setHistory(h);
154+
};
155+
156+
const complete = async (id: number) => {
157+
setBusy(true);
158+
try {
159+
const m = await invoke<string>("task_complete", { rowId: id });
160+
await afterMutate(m);
161+
} catch (e) {
162+
setErr(String(e));
163+
} finally {
164+
setBusy(false);
165+
}
166+
};
167+
168+
const abandon = async (id: number) => {
169+
setBusy(true);
170+
try {
171+
const m = await invoke<string>("task_abandon", { rowId: id, reason: reasonFor === id ? reasonRef.current.trim() || null : null });
172+
setReasonFor(null);
173+
reasonRef.current = "";
174+
await afterMutate(m);
175+
} catch (e) {
176+
setErr(String(e));
177+
} finally {
178+
setBusy(false);
179+
}
180+
};
181+
182+
const refineUnrefined = async (id: number) => {
183+
try {
184+
const m = await invoke<string>("task_refine_unrefined", { rowId: id });
185+
await afterMutate(m);
186+
} catch (e) {
187+
setErr(String(e));
188+
}
189+
};
190+
191+
const planTomorrow = async (id: number) => {
192+
try {
193+
const tomorrow = Math.floor(Date.now() / 1000 / 86400) * 86400 + 86400;
194+
await invoke("task_set_plan_date", { rowId: id, planDate: tomorrow });
195+
await afterMutate("📅 已改期至明天(计划日只入索引,正文零污染)");
196+
} catch (e) {
197+
setErr(String(e));
198+
}
199+
};
200+
201+
/** 迁出=放手:复制 todo.txt 行到剪贴板(保底三件套之复制面) */
202+
const exportCopy = async (r: ActionRow) => {
203+
try {
204+
const line = `[ ] ${r.text} (via:${r.note_title})`;
205+
await navigator.clipboard.writeText(line);
206+
setMsg(`⤴ 已复制 todo.txt 行(外部完成后可到完成史手动回填):${line}`);
207+
} catch (e) {
208+
setErr(`复制失败: ${e}`);
209+
}
210+
};
211+
212+
const renderRow = (r: ActionRow, overdueFlag: boolean) => (
213+
<div key={r.id} style={{ border: overdueFlag ? "1px solid #fca5a5" : "1px solid #e5e7eb", background: overdueFlag ? "#fff7f7" : "#fff", borderRadius: 6, padding: "6px 8px", display: "flex", flexDirection: "column", gap: 4 }}>
214+
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
215+
<span style={{ fontSize: 12.5, color: "#111827", flex: 1 }}>{r.text}</span>
216+
<span style={{ fontSize: 10.5, color: "#9ca3af" }}>@{r.note_title}</span>
217+
{overdueFlag && <span style={{ fontSize: 10.5, color: "#dc2626", fontWeight: 600 }}>逾期</span>}
218+
</div>
219+
{reasonFor === r.id && (
220+
<div style={{ display: "flex", gap: 4 }}>
221+
<input
222+
autoFocus
223+
placeholder="放弃原因(留档不追问)"
224+
defaultValue={reasonRef.current}
225+
onChange={(e) => { reasonRef.current = e.target.value; }}
226+
style={{ flex: 1, fontSize: 12, border: "1px solid #e5e7eb", borderRadius: 4, padding: "2px 6px" }}
227+
/>
228+
<button style={okBtn} onClick={() => void abandon(r.id)}>确认放弃</button>
229+
<button style={btn} onClick={() => setReasonFor(null)}>取消</button>
230+
</div>
231+
)}
232+
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
233+
<button style={{ ...okBtn, fontSize: 11 }} disabled={busy} onClick={() => void complete(r.id)}>✓ 完成</button>
234+
<button style={{ ...btn, fontSize: 11 }} onClick={() => void planTomorrow(r.id)}>📅 明天</button>
235+
<button style={{ ...btn, fontSize: 11 }} title="🎴 转闪卡(规划中——先置灰提示)" disabled>🎴 转卡</button>
236+
<button style={{ ...btn, fontSize: 11 }} onClick={() => void exportCopy(r)}>⤴ 迁出</button>
237+
<button style={{ ...dangerBtn, fontSize: 11 }} onClick={() => { setReasonFor(r.id); }}>✗ 放弃</button>
238+
</div>
239+
</div>
240+
);
241+
242+
const empty = (text: string) => (
243+
<p style={{ fontSize: 12, color: "#9ca3af", padding: "8px 2px" }}>{text}</p>
244+
);
245+
246+
return (
247+
<div style={overlayStyle} onClick={onClose}>
248+
<div style={cardStyle} onClick={(e) => e.stopPropagation()}>
249+
<div style={{ display: "flex", alignItems: "center", marginBottom: 10, gap: 6 }}>
250+
<h3 style={{ margin: 0, fontSize: 15 }}>✅ 行动中心</h3>
251+
<div style={{ marginLeft: 12, display: "flex", gap: 4 }}>
252+
{(
253+
[
254+
["queue", `裁决队列(${overdue.length + planned.length + rows.length + unrefined.length})`],
255+
["history", `完成史(${history.length})`],
256+
] as const
257+
).map(([k, label]) => (
258+
<button
259+
key={k}
260+
onClick={() => setTab(k)}
261+
style={{
262+
...btn,
263+
borderRadius: 6,
264+
border: tab === k ? "1px solid #0d9488" : "1px solid #e5e7eb",
265+
background: tab === k ? "#ccfbf1" : "#fff",
266+
color: tab === k ? "#0f766e" : "#374151",
267+
}}
268+
>
269+
{label}
270+
</button>
271+
))}
272+
</div>
273+
<button style={{ ...btn, marginLeft: "auto" }} onClick={onClose}>关闭</button>
274+
</div>
275+
276+
{msg && <div style={{ fontSize: 12, color: "#047857", background: "#ecfdf5", border: "1px solid #6ee7b7", borderRadius: 6, padding: "6px 10px", marginBottom: 8 }}>{msg}</div>}
277+
{err && <div style={{ fontSize: 12, color: "#dc2626", background: "#fef2f2", border: "1px solid #fca5a5", borderRadius: 6, padding: "6px 10px", marginBottom: 8 }}>{err}</div>}
278+
279+
{tab === "queue" ? (
280+
<>
281+
<h4 style={{ fontSize: 12.5, margin: "6px 0 4px", color: "#dc2626" }}>逾期({overdue.length})</h4>
282+
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
283+
{overdue.length === 0 ? empty("无逾期——裁决是机制不是自动清理") : overdue.map((r) => renderRow(r, true))}
284+
</div>
285+
<h4 style={{ fontSize: 12.5, margin: "10px 0 4px", color: "#374151" }}>计划日({planned.length})</h4>
286+
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
287+
{planned.length === 0 ? empty("无排期任务") : planned.map((r) => renderRow(r, false))}
288+
</div>
289+
<h4 style={{ fontSize: 12.5, margin: "10px 0 4px", color: "#374151" }}>搁置({rows.length})</h4>
290+
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
291+
{rows.length === 0 ? empty("无搁置任务——未排期的任务在此候裁") : rows.map((r) => renderRow(r, false))}
292+
</div>
293+
<h4 style={{ fontSize: 12.5, margin: "10px 0 4px", color: "#7c3aed" }}>待提炼({unrefined.length} · 产物遗留 ☑️ 行)</h4>
294+
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
295+
{unrefined.length === 0 ? empty("无待提炼产物行") : unrefined.map((r) => (
296+
<div key={r.id} style={{ border: "1px solid #e5e7eb", borderRadius: 6, padding: "6px 8px", display: "flex", gap: 6, alignItems: "center" }}>
297+
<span style={{ fontSize: 12.5, flex: 1 }}>☑️ {r.text}</span>
298+
<span style={{ fontSize: 10.5, color: "#9ca3af" }}>@{r.note_title}</span>
299+
<button style={{ ...okBtn, fontSize: 11 }} onClick={() => void refineUnrefined(r.id)}>提炼为任务行</button>
300+
</div>
301+
))}
302+
</div>
303+
</>
304+
) : (
305+
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
306+
{history.length === 0 ? empty("暂无完成记录——完成即证据,周回顾原料在此沉淀") : history.map((h) => (
307+
<div key={h.id} style={{ display: "flex", gap: 8, fontSize: 12, borderBottom: "1px solid #f3f4f6", padding: "4px 2px" }}>
308+
<span style={{ color: "#9ca3af", fontVariantNumeric: "tabular-nums", flexShrink: 0 }}>{fmtDate(h.ts)}</span>
309+
<span style={{ color: h.event_type === "abandoned" ? "#dc2626" : "#0f766e", flexShrink: 0, width: 90 }}>
310+
{TYPE_LABEL[h.event_type] ?? h.event_type}
311+
</span>
312+
<span style={{ flex: 1, color: "#374151" }}>{h.text}</span>
313+
{h.note && <span style={{ fontSize: 11, color: "#9ca3af" }}>因:{h.note}</span>}
314+
</div>
315+
))}
316+
</div>
317+
)}
318+
<p style={{ fontSize: 11, color: "#9ca3af", marginTop: 10 }}>
319+
勾选任务行即被自动收录;产物 ☑️ 行可在上方「待提炼」区一键转标准任务行;🎴 转卡为规划中出口(G7 预留)。
320+
</p>
321+
</div>
322+
</div>
323+
);
324+
}

‎app/src/components/GroupSidebar.test.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ beforeEach(() => {
3535
switch (cmd) {
3636
case "list_note_groups": return [groupA, groupB];
3737
case "count_due_cards": return 0;
38+
// v0.20.3(REQ-293):行动徽标(默认 0)
39+
case "action_badge_count": return 0;
3840
case "list_fragments": return [];
3941
case "get_feature_flags": return { feedCapture: true };
4042
case "week_contract_status":
@@ -72,6 +74,7 @@ function renderSidebar() {
7274
onGroupFilterChange={vi.fn()}
7375
onChanged={vi.fn()}
7476
onOpenReview={vi.fn()}
77+
onOpenAction={vi.fn()}
7578
selectedNoteId={null}
7679
onOpenInbox={vi.fn()}
7780
inboxActive={false}
@@ -90,6 +93,7 @@ describe("GroupSidebar ⓘ 弹层", () => {
9093
onGroupFilterChange={onGroupFilterChange}
9194
onChanged={vi.fn()}
9295
onOpenReview={vi.fn()}
96+
onOpenAction={vi.fn()}
9397
selectedNoteId={null}
9498
onOpenInbox={vi.fn()}
9599
inboxActive={false}
@@ -132,6 +136,7 @@ describe("GroupSidebar ⓘ 弹层", () => {
132136
onGroupFilterChange={onGroupFilterChange}
133137
onChanged={vi.fn()}
134138
onOpenReview={vi.fn()}
139+
onOpenAction={vi.fn()}
135140
selectedNoteId={null}
136141
onOpenInbox={vi.fn()}
137142
inboxActive={false}
@@ -236,6 +241,7 @@ describe("GroupSidebar v0.14.1 新建/重命名", () => {
236241
onGroupFilterChange={vi.fn()}
237242
onChanged={onChanged}
238243
onOpenReview={vi.fn()}
244+
onOpenAction={vi.fn()}
239245
selectedNoteId={null}
240246
onOpenInbox={vi.fn()}
241247
inboxActive={false}
@@ -273,6 +279,7 @@ describe("GroupSidebar v0.14.1 新建/重命名", () => {
273279
onGroupFilterChange={vi.fn()}
274280
onChanged={onChanged}
275281
onOpenReview={vi.fn()}
282+
onOpenAction={vi.fn()}
276283
selectedNoteId={null}
277284
onOpenInbox={vi.fn()}
278285
inboxActive={false}
@@ -299,6 +306,7 @@ describe("GroupSidebar v0.14.1 新建/重命名", () => {
299306
onGroupFilterChange={onGroupFilterChange}
300307
onChanged={onChanged}
301308
onOpenReview={vi.fn()}
309+
onOpenAction={vi.fn()}
302310
selectedNoteId={null}
303311
onOpenInbox={vi.fn()}
304312
inboxActive={false}

0 commit comments

Comments
 (0)