Skip to content

Commit 589b87f

Browse files
committed
feat(notes): REQ-316 空组清理 toast 留痕与调用方接线
- types/notes.ts 同步五命令结果契约(autoCleanedGroups 组标题数组; 空数组=零变化);NotesPage 单删/批量删消费并自绘 toast(批量逐条 触发、聚合去重单次留痕) - 新 hooks/useTransientToast.tsx(自绘 toast 复用件——批 4 会话页模式) + utils/groupClean.ts autoCleanNotice 纯函数(文案拼接单点,配 4 例 单测);NoteListView/NoteMoveToGroupMenu/NoteRowContextMenu/ GroupSidebar/RouteInfoPopover/FeedFragmentList 经可选 onCleanNotice 上抛统一 toast(无清理零变化) - 全量 vitest 672/672、tsc 0 错误
1 parent 190cb3c commit 589b87f

17 files changed

Lines changed: 299 additions & 27 deletions

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,12 @@ beforeEach(() => {
5050
return [containerGroup];
5151
case "promote_fragment_to_note":
5252
dbFragments = dbFragments.filter((f) => f.id !== args.fragmentId);
53-
return noteStub;
53+
return { note: noteStub, autoCleanedGroups: [] };
5454
case "promote_fragment_to_card":
5555
return 1;
5656
case "delete_fragment":
5757
dbFragments = dbFragments.filter((f) => f.id !== args.fragmentId);
58-
return true;
58+
return { deleted: true, autoCleanedGroups: [] };
5959
case "resolve_fragment_image":
6060
return null;
6161
default:

‎app/src/components/FeedFragmentList.tsx‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
import { useCallback, useEffect, useState } from "react";
1414
import { invoke, convertFileSrc } from "@tauri-apps/api/core";
1515
import type { Fragment, Note, NoteGroup } from "../types";
16+
// REQ-316(批 7):碎片删除/升笔记返回契约(源空组清理留痕数据源)
17+
import type { DeleteFragmentResult, PromoteNoteResult } from "../types/notes";
1618
import { fragmentPreview, promoteTitleFor } from "../utils/inbox";
1719

1820
interface Props {
@@ -22,6 +24,8 @@ interface Props {
2224
onChanged: () => void;
2325
/** 升笔记成功——父层打开新笔记(右侧自动打开,闭环可见) */
2426
onPromoted: (note: Note) => void;
27+
/** REQ-316(批 7):碎片操作触发源空组自动清理 → 上抛组标题(父层 toast 留痕) */
28+
onCleanNotice?: (groupNames: string[]) => void;
2529
/** v0.15:折叠为窄条(父层 useColumnLayout.setManualFolded(true)) */
2630
onCollapse?: () => void;
2731
}
@@ -67,7 +71,7 @@ interface PromoteForm {
6771
groupId: string;
6872
}
6973

70-
export default function FeedFragmentList({ width = 320, onChanged, onPromoted, onCollapse }: Props) {
74+
export default function FeedFragmentList({ width = 320, onChanged, onPromoted, onCleanNotice, onCollapse }: Props) {
7175
const [fragments, setFragments] = useState<Fragment[]>([]);
7276
const [loaded, setLoaded] = useState(false);
7377
const [err, setErr] = useState("");
@@ -116,16 +120,18 @@ export default function FeedFragmentList({ width = 320, onChanged, onPromoted, o
116120
if (!promote) return;
117121
setBusy(true);
118122
try {
119-
const note = await invoke<Note>("promote_fragment_to_note", {
123+
const r = await invoke<PromoteNoteResult>("promote_fragment_to_note", {
120124
fragmentId: promote.fragmentId,
121125
title: promote.title,
122126
groupId: promote.groupId === "" ? null : Number(promote.groupId),
123127
});
124128
setPromote(null);
125129
setErr("");
130+
// REQ-316(批 7):碎片源组因升笔记变空 → 自动清理留痕(结果空=零变化)
131+
if (r.autoCleanedGroups.length > 0) onCleanNotice?.([...new Set(r.autoCleanedGroups)]);
126132
await load();
127133
onChanged();
128-
onPromoted(note);
134+
onPromoted(r.note);
129135
} catch (e) {
130136
setErr(`升笔记失败: ${e}`);
131137
} finally {
@@ -157,7 +163,9 @@ export default function FeedFragmentList({ width = 320, onChanged, onPromoted, o
157163
if (!window.confirm(`删除这条碎片?\n「${f.text.slice(0, 30)}…」\n(绑定的闪卡会保留)`)) return;
158164
setBusy(true);
159165
try {
160-
await invoke<boolean>("delete_fragment", { fragmentId: f.id });
166+
const r = await invoke<DeleteFragmentResult>("delete_fragment", { fragmentId: f.id });
167+
// REQ-316(批 7):碎片源组因删除变空 → 自动清理留痕(结果空=零变化)
168+
if (r.autoCleanedGroups.length > 0) onCleanNotice?.([...new Set(r.autoCleanedGroups)]);
161169
await load();
162170
onChanged();
163171
} catch (e) {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ beforeEach(() => {
4747
? [{ id: 1, systemId: 10, nodeId: null, conceptId: null, modelId: null, targetType: "note_group", targetId: 1, createdAt: 0 }]
4848
: [];
4949
case "override_group_route": return true;
50-
case "move_note_to_group": return true;
50+
case "move_note_to_group": return { moved: true, autoCleanedGroups: [] };
5151
// 审查修复:ⓘ 弹层静默降级依赖的 mock 补齐(简报拉取 list_group_cards——原缺省
5252
// throw 依赖"简报静默降级"容错,测试通过≠mock 完备)
5353
case "list_group_cards": return [];

‎app/src/components/GroupSidebar.tsx‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { useCallback, useEffect, useMemo, useState } from "react";
1717
import { invoke } from "@tauri-apps/api/core";
1818
import type { Fragment, NoteGroup } from "../types";
1919
import type { KnowledgeLink, KnowledgeSystem } from "../types/knowledge";
20+
// REQ-316(批 7):移组返回契约(源空组清理留痕数据源)
21+
import type { MoveNoteResult } from "../types/notes";
2022
import {
2123
GROUP_SECTIONS,
2224
filterGroups,
@@ -59,13 +61,15 @@ interface Props {
5961
refreshToken: number;
6062
/** 跳转体系页并选中体系(v0.13.7 触点①) */
6163
onOpenSystem: (systemId: number) => void;
64+
/** REQ-316(批 7):移组触发源空组自动清理 → 上抛组标题(父层 toast 留痕) */
65+
onCleanNotice?: (groupNames: string[]) => void;
6266
/** v0.15:折叠为窄条(父层 useColumnLayout.setManualFolded(true)) */
6367
onCollapse?: () => void;
6468
}
6569

6670
export default function GroupSidebar({
6771
width = 240, groupFilter, onGroupFilterChange, onChanged, onOpenReview, selectedNoteId,
68-
onOpenInbox, inboxActive, refreshToken, onOpenSystem, onCollapse,
72+
onOpenInbox, inboxActive, refreshToken, onOpenSystem, onCleanNotice, onCollapse,
6973
}: Props) {
7074
const [groups, setGroups] = useState<NoteGroup[]>([]);
7175
const [feedCaptureOn, setFeedCaptureOn] = useState(true);
@@ -234,14 +238,19 @@ export default function GroupSidebar({
234238
if (ids.length === 0) return;
235239
void (async () => {
236240
let failed = 0;
241+
const cleanedNames: string[] = [];
237242
for (const noteId of ids) {
238243
try {
239-
await invoke("move_note_to_group", { noteId, groupId: g.id });
244+
// REQ-316(批 7):移组结果含自动清理列表(源组空 → 后端已删)
245+
const r = await invoke<MoveNoteResult>("move_note_to_group", { noteId, groupId: g.id });
246+
cleanedNames.push(...r.autoCleanedGroups);
240247
} catch {
241248
failed += 1;
242249
}
243250
}
244251
if (failed > 0) setStatus(`归组失败 ${failed}/${ids.length} 条`);
252+
// 清理留痕(无清理零变化;父层 toast)
253+
if (cleanedNames.length > 0) onCleanNotice?.([...new Set(cleanedNames)]);
245254
onChanged();
246255
})();
247256
};
@@ -473,6 +482,7 @@ export default function GroupSidebar({
473482
// 复习深链:关弹层后交 NotesPage→App 转复习页组预选(v0.20.10 批 5)
474483
onOpenReview={(gid, name) => { setPopover(null); onOpenReview(gid, name); }}
475484
selectedNoteId={selectedNoteId}
485+
onCleanNotice={onCleanNotice}
476486
/>
477487
)}
478488

‎app/src/components/NoteHeaderActions.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ interface Props {
2727
onChanged: () => void;
2828
/** 错误上抛(父层 status 区展示) */
2929
onError: (msg: string) => void;
30+
/** REQ-316(批 7):移组触发源空组自动清理 → 上抛组标题(父层 toast 留痕) */
31+
onCleanNotice?: (groupNames: string[]) => void;
3032
/** 跳体系页并打开建体系向导(TD-2026-09-05-A 空体系引导) */
3133
onGotoKnowledgeSystem?: () => void;
3234
/** 打开编辑态 AI 能力对话框(阅读态点击直接进入编辑态) */
@@ -36,7 +38,7 @@ interface Props {
3638
}
3739

3840
export default function NoteHeaderActions({
39-
note, resolvedColor, groups, onChanged, onError, onGotoKnowledgeSystem, onOpenAi, onOpenModelCard,
41+
note, resolvedColor, groups, onChanged, onError, onCleanNotice, onGotoKnowledgeSystem, onOpenAi, onOpenModelCard,
4042
}: Props) {
4143
const [colorPickerOpen, setColorPickerOpen] = useState(false);
4244
// v0.14 B:当前主题(跟随 prefers-color-scheme;jsdom 无 matchMedia 回退 light)
@@ -81,6 +83,7 @@ export default function NoteHeaderActions({
8183
note={note}
8284
groups={groups}
8385
onChanged={onChanged}
86+
onCleanNotice={onCleanNotice}
8487
/>
8588
{/* v0.13.7 触点②:标题栏「挂到体系」入口;key=note.id 切笔记重置内部态 */}
8689
<NoteLinkToSystem

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ beforeEach(() => {
4444
case "note_group_order_list": return []; // REQ-315:组序行(树组头排序)
4545
case "note_order_save": return null;
4646
case "note_order_clear": return true;
47-
case "move_note_to_group": return true;
47+
case "move_note_to_group": return { moved: true, autoCleanedGroups: [] };
4848
default: return null;
4949
}
5050
});

‎app/src/components/NoteListView.tsx‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1717
import { invoke } from "@tauri-apps/api/core";
1818
import type { Note, NoteGroup } from "../types";
19+
// REQ-316(批 7):移组返回契约(空组自动清理留痕数据源)
20+
import type { MoveNoteResult } from "../types/notes";
1921
import { paletteHex } from "../utils/colorPalette";
2022
import type { ThemeMode } from "../utils/colorPalette";
2123
import { emptySelection, rangeSelection, toggleSelection } from "../utils/noteSelection";
@@ -61,6 +63,8 @@ interface Props {
6163
onNoteEdit?: (note: Note) => void;
6264
onNoteDelete?: (note: Note) => void;
6365
onNoteMoved?: () => void;
66+
/** REQ-316(批 7):移组触发空组自动清理 → 上抛组标题(父层 toast 留痕) */
67+
onCleanNotice?: (groupNames: string[]) => void;
6468
onCollapse?: () => void;
6569
}
6670

@@ -85,7 +89,7 @@ export default function NoteListView({
8589
keyword, tagFilter, sortMode, allTags, selectedId, status,
8690
noteColors, tagColors,
8791
onKeywordChange, onTagFilterChange, onSortModeChange, onSelect, onCreate, onRefresh, onOpenSession, onBatchDelete,
88-
onNotePinToggle, onNoteEdit, onNoteDelete, onNoteMoved, onCollapse,
92+
onNotePinToggle, onNoteEdit, onNoteDelete, onNoteMoved, onCleanNotice, onCollapse,
8993
}: Props) {
9094
const theme: ThemeMode = useMemo(
9195
() => (typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"),
@@ -305,9 +309,11 @@ export default function NoteListView({
305309
const groupNotes = new Set(
306310
notes.filter((n) => (groupId == null ? n.group_id == null : n.group_id === groupId)).map((n) => n.id),
307311
);
312+
const cleanedNames: string[] = [];
308313
for (const id of ids) {
309314
if (groupNotes.has(id)) continue;
310-
await invoke("move_note_to_group", { noteId: id, groupId });
315+
const r = await invoke<MoveNoteResult>("move_note_to_group", { noteId: id, groupId });
316+
cleanedNames.push(...r.autoCleanedGroups);
311317
}
312318
// 目标手排:新入组未置顶笔记追加末尾(跨组 drop 的"加入该组"语义;
313319
// 置顶成员由置顶区表达,不写手排行;重写快照顺带清存量置顶残行)
@@ -318,13 +324,15 @@ export default function NoteListView({
318324
await saveOrder(scope, [...cur, ...ids.filter((id) => !groupNotes.has(id) && !pinned.has(id))]);
319325
}
320326
onNoteMoved?.();
327+
// REQ-316(批 7):批量移走后源空组清理留痕(跨条聚合去重)
328+
if (cleanedNames.length > 0) onCleanNotice?.([...new Set(cleanedNames)]);
321329
setBatchMenu(null);
322330
} catch (e) {
323331
console.warn("[notes] 归组失败:", e);
324332
} finally {
325333
setBusyMove(false);
326334
}
327-
}, [notes, manualOrders, saveOrder, onNoteMoved]);
335+
}, [notes, manualOrders, saveOrder, onNoteMoved, onCleanNotice]);
328336

329337
/**
330338
* REQ-315:右键「上移/下移」组内显式移动——补"必须拖一次才触发手排快照"的
@@ -367,9 +375,13 @@ export default function NoteListView({
367375
const n = notes.find((x) => x.id === id);
368376
return n && (n.group_id ?? null) !== targetGroup;
369377
});
378+
const cleanedNames: string[] = [];
370379
for (const id of external) {
371-
await invoke("move_note_to_group", { noteId: id, groupId: target.group_id });
380+
const r = await invoke<MoveNoteResult>("move_note_to_group", { noteId: id, groupId: target.group_id });
381+
cleanedNames.push(...r.autoCleanedGroups);
372382
}
383+
// REQ-316(批 7):跨组拖走使源组变空 → 清理留痕(零清理零变化)
384+
if (cleanedNames.length > 0) onCleanNotice?.([...new Set(cleanedNames)]);
373385
// 落位(REQ-315:只写置顶区外子序列;落点在置顶行上 = 置顶区下沿即手动区首位)
374386
const pinned = new Set(notes.filter((n) => n.pin === 1).map((n) => n.id));
375387
const moved = ids.filter((id) => !pinned.has(id));
@@ -402,7 +414,7 @@ export default function NoteListView({
402414
} finally {
403415
dropBusyRef.current = false;
404416
}
405-
}, [treeMode, notes, manualOrders, manualBaseOf, saveOrder, onNoteMoved]);
417+
}, [treeMode, notes, manualOrders, manualBaseOf, saveOrder, onNoteMoved, onCleanNotice]);
406418

407419
/** 划选(组头空白起 → 组内首行至当前行带;走既有行命中的全局可见序)。
408420
* L9 审查修正:rAF 节流 + pointercancel/blur/松开(buttons=0)即清理——
@@ -597,6 +609,7 @@ export default function NoteListView({
597609
onEdit={onNoteEdit}
598610
onDelete={onNoteDelete}
599611
onMoved={onNoteMoved}
612+
onCleanNotice={onCleanNotice}
600613
// REQ-315:树视图才显 上移/下移(平铺态禁移动——orderActions 为 null)
601614
onMoveWithinScope={orderActions?.move}
602615
canMoveUp={orderActions?.canMoveUp ?? false}

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

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const noteBase: Note = {
2424
id: 42, title: "笔记", content: "x", source: "manual", tags: "[]", pin: 0, created_at: 0, updated_at: 0,
2525
};
2626

27-
beforeEach(() => { invokeMock.mockReset(); invokeMock.mockResolvedValue(true); });
27+
beforeEach(() => { invokeMock.mockReset(); invokeMock.mockResolvedValue({ moved: true, autoCleanedGroups: [] }); });
2828
afterEach(() => cleanup());
2929

3030
describe("NoteMoveToGroupMenu 手动分组", () => {
@@ -54,6 +54,42 @@ describe("NoteMoveToGroupMenu 手动分组", () => {
5454
});
5555
});
5656

57+
it("REQ-316:移出最后笔记 → 结果含 autoCleanedGroups 上抛 onCleanNotice(留痕)", async () => {
58+
// Arrange:移出组命令返回清理列表(源组已自动删除)
59+
const onCleanNotice = vi.fn();
60+
invokeMock.mockResolvedValueOnce({ moved: true, autoCleanedGroups: ["乐理"] });
61+
render(
62+
<NoteMoveToGroupMenu
63+
note={{ ...noteBase, group_id: 2 }}
64+
groups={groups}
65+
onChanged={vi.fn()}
66+
onCleanNotice={onCleanNotice}
67+
/>,
68+
);
69+
// Act:移出分组
70+
fireEvent.click(screen.getByTestId("move-to-group-open"));
71+
fireEvent.click(screen.getByTestId("move-to-group-none"));
72+
// Assert:清理组标题上抛(父层 toast「已自动清理空组:乐理」)
73+
await waitFor(() => expect(onCleanNotice).toHaveBeenCalledWith(["乐理"]));
74+
});
75+
76+
it("REQ-316:结果无清理(空数组)→ 不上抛(零变化)", async () => {
77+
const onCleanNotice = vi.fn();
78+
invokeMock.mockResolvedValueOnce({ moved: true, autoCleanedGroups: [] });
79+
render(
80+
<NoteMoveToGroupMenu
81+
note={{ ...noteBase, group_id: 2 }}
82+
groups={groups}
83+
onChanged={vi.fn()}
84+
onCleanNotice={onCleanNotice}
85+
/>,
86+
);
87+
fireEvent.click(screen.getByTestId("move-to-group-open"));
88+
fireEvent.click(screen.getByTestId("move-to-group-none"));
89+
await waitFor(() => expect(invokeMock).toHaveBeenCalled());
90+
expect(onCleanNotice).not.toHaveBeenCalled();
91+
});
92+
5793
it("无组:提示先新建组(零命令调用)", () => {
5894
render(<NoteMoveToGroupMenu note={noteBase} groups={[]} onChanged={vi.fn()} />);
5995
fireEvent.click(screen.getByTestId("move-to-group-open"));

‎app/src/components/NoteMoveToGroupMenu.tsx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
import { useMemo, useState } from "react";
1212
import { invoke } from "@tauri-apps/api/core";
1313
import type { Note, NoteGroup } from "../types";
14+
// REQ-316(批 7):移组返回契约(源空组清理留痕数据源)
15+
import type { MoveNoteResult } from "../types/notes";
1416
import { paletteHex } from "../utils/colorPalette";
1517
import type { ThemeMode } from "../utils/colorPalette";
1618

@@ -19,6 +21,8 @@ interface Props {
1921
groups: NoteGroup[];
2022
/** 变更后的刷新回调(父层重载列表 + 重取选中笔记) */
2123
onChanged: () => void;
24+
/** REQ-316(批 7):移组触发源空组自动清理 → 上抛组标题(父层 toast 留痕) */
25+
onCleanNotice?: (groupNames: string[]) => void;
2226
}
2327

2428
const DROPDOWN_ITEM: React.CSSProperties = {
@@ -58,7 +62,7 @@ function GroupDot({ colorId }: { colorId: string | null | undefined }) {
5862
);
5963
}
6064

61-
export default function NoteMoveToGroupMenu({ note, groups, onChanged }: Props) {
65+
export default function NoteMoveToGroupMenu({ note, groups, onChanged, onCleanNotice }: Props) {
6266
const [open, setOpen] = useState(false);
6367
const [busy, setBusy] = useState(false);
6468
const [status, setStatus] = useState("");
@@ -68,9 +72,11 @@ export default function NoteMoveToGroupMenu({ note, groups, onChanged }: Props)
6872
if (busy || groupId === currentId) return;
6973
setBusy(true);
7074
try {
71-
await invoke<boolean>("move_note_to_group", { noteId: note.id, groupId });
75+
const r = await invoke<MoveNoteResult>("move_note_to_group", { noteId: note.id, groupId });
7276
setStatus("");
7377
setOpen(false);
78+
// REQ-316(批 7):源组因移走变空 → 自动清理留痕(结果空=零变化)
79+
if (r.autoCleanedGroups.length > 0) onCleanNotice?.([...new Set(r.autoCleanedGroups)]);
7480
onChanged();
7581
} catch (e) {
7682
setStatus(`移动失败: ${e}`);

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ function renderMenu(overrides: Partial<Parameters<typeof NoteRowContextMenu>[0]>
3939

4040
beforeEach(() => {
4141
invokeMock.mockReset();
42-
invokeMock.mockResolvedValue(true);
42+
invokeMock.mockResolvedValue({ moved: true, autoCleanedGroups: [] });
4343
writeTextMock.mockReset();
4444
writeTextMock.mockResolvedValue(undefined);
4545
});

0 commit comments

Comments
 (0)