Skip to content

Commit 33aff9a

Browse files
committed
feat(note): 列表去勾选交互重构——修饰多选/批量模式/拖拽矩阵/手动排序消费(REQ-287)
1 parent d001c2f commit 33aff9a

5 files changed

Lines changed: 605 additions & 313 deletions

File tree

‎app/src/components/NoteListRow.tsx‎

Lines changed: 73 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,119 @@
11
/**
2-
* NoteListRow — 笔记列表行(v0.15 自 NoteListView 拆出——树态/平铺共用)。
2+
* NoteListRow — 笔记列表行(v0.15 自 NoteListView 拆出;REQ-287 v0.19.7 重构)。
33
*
4-
* @ai-context: 行 id=`note-row-{id}` 供 focusNoteId 跨页直达滚动定位;
5-
* 拖拽源(组行为 drop target,move_note_to_group 命令);
6-
* 勾选/固定/标签/来源会话跳转保持 v0.12.x 语义不变。
4+
* @ai-context: 行 id=`note-row-{id}` 供 focusNoteId 跨页直达滚动定位。
5+
* @ai-context: REQ-287 交互矩阵——行内 checkbox 移除:单击=打开(右栏读,
6+
* selectionMode 下由父层改判为勾选);Ctrl/⌘+单击=加/减选(不
7+
* 换右栏);Shift+单击=区间选(父层按列表位置)。多选态视觉=
8+
* 靛蓝底 + ✓ 前缀(替代勾选框)。拖拽源:本行(未选集)或多选
9+
* 整组(选中态行拖任一行=整体带走)——载荷 text/note-ids JSON +
10+
* 单 id 兜底;行间落点经 onDropOnRow 上抛(组内手动排序,父层
11+
* 判定启用手排/禁入)。
712
*/
813
import { useMemo } from "react";
914
import type { Note } from "../types";
1015
import { paletteHex } from "../utils/colorPalette";
1116
import type { ThemeMode } from "../utils/colorPalette";
1217
import { fmtDate, parseTags } from "../utils/noteHelpers";
18+
import { crateDndWriteIds } from "./NoteTreeSection";
1319

1420
interface Props {
1521
note: Note;
1622
/** 行左侧色条(父层 resolveNoteColor 解析结果) */
1723
accent: string;
18-
selectedId: number | null;
19-
checked: boolean;
24+
/** 当前打开(右栏阅读)的笔记 id——teal 高亮优先 */
25+
openId: number | null;
26+
/** 本行是否在多选集内(靛蓝高亮 + ✓ 前缀) */
27+
multiSelected: boolean;
2028
tagColors?: Record<string, string>;
21-
onSelect: (note: Note) => void;
22-
onToggleSelect: (id: number) => void;
29+
/** 单击打开/右栏读(父层在批量选择模式下改判勾选) */
30+
onOpen: (note: Note) => void;
31+
/** Ctrl/⌘+单击=增删选集;Shift+单击=区间选(均不换右栏) */
32+
onModifierClick: (note: Note, ctrl: boolean, shift: boolean) => void;
33+
/** 拖拽行载荷(多选整组=选集 ids;未选中态=单行 id) */
34+
dragIds: number[];
35+
/** 行间落点(组内手动排序;父层判定) */
36+
onDropOnRow?: (ids: number[], targetId: number, before: boolean) => void;
37+
/** 父层行间落点指示(视觉反馈) */
38+
dropIndicator?: "before" | "after" | null;
2339
onOpenSession: (sessionId: number) => void;
24-
/** v0.16.1:右键菜单打开(父层持有坐标/状态;默认浏览器菜单已全局禁用) */
25-
onContextMenu?: (e: React.MouseEvent) => void;
40+
/** v0.16.1:右键菜单打开(父层持有坐标/状态;原生菜单已全局禁用) */
41+
onContextMenu?: (e: React.MouseEvent, note: Note) => void;
2642
}
2743

2844
export default function NoteListRow({
29-
note, accent, selectedId, checked, tagColors, onSelect, onToggleSelect, onOpenSession, onContextMenu,
45+
note, accent, openId, multiSelected, tagColors, onOpen, onModifierClick,
46+
dragIds, onDropOnRow, dropIndicator = null, onOpenSession, onContextMenu,
3047
}: Props) {
3148
const tags = parseTags(note);
3249
// v0.14 B:当前主题(跟随 prefers-color-scheme;jsdom 无 matchMedia 回退 light)
3350
const theme: ThemeMode = useMemo(
3451
() => (typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"),
3552
[],
3653
);
54+
const isOpen = openId === note.id;
3755
return (
3856
<div
3957
id={`note-row-${note.id}`}
58+
data-testid={`note-row-${note.id}`}
4059
draggable
4160
onDragStart={(e) => {
42-
// v0.14 C1:拖拽归组——笔记卡片为 drag source(组行为 drop target)
43-
e.dataTransfer.setData("text/note-id", String(note.id));
44-
e.dataTransfer.effectAllowed = "move";
61+
// REQ-287:多选整组拖走(dragIds=选集);未选中态=单行(左栏归组兼容)
62+
crateDndWriteIds(e.dataTransfer, dragIds.length > 0 ? dragIds : [note.id]);
63+
}}
64+
onDragOver={(e) => {
65+
if (!onDropOnRow) return;
66+
const types = e.dataTransfer.types;
67+
if (!types.includes("text/note-ids") && !types.includes("text/note-id")) return;
68+
e.preventDefault();
69+
e.stopPropagation();
70+
}}
71+
onDrop={(e) => {
72+
if (!onDropOnRow) return;
73+
e.preventDefault();
74+
e.stopPropagation();
75+
const raw = e.dataTransfer.getData("text/note-ids");
76+
let ids: number[] = [];
77+
try {
78+
const arr: unknown = JSON.parse(raw);
79+
if (Array.isArray(arr)) ids = arr.filter((x): x is number => typeof x === "number" && x > 0);
80+
} catch { /* 兜底单 id */ }
81+
if (ids.length === 0) {
82+
const single = Number(e.dataTransfer.getData("text/note-id"));
83+
if (Number.isInteger(single) && single > 0) ids = [single];
84+
}
85+
if (ids.length === 0 || ids.includes(note.id)) return;
86+
const rect = e.currentTarget.getBoundingClientRect();
87+
onDropOnRow(ids, note.id, e.clientY < rect.top + rect.height / 2);
4588
}}
4689
onContextMenu={(e) => {
47-
// v0.16.1:应用内右键菜单——抑制原生菜单(前端兜底)+ 上抛坐标
90+
// v0.16.1:应用内右键菜单——抑制原生菜单 + 上抛坐标(多选语义父层裁决)
4891
e.preventDefault();
4992
e.stopPropagation();
50-
onContextMenu?.(e);
93+
onContextMenu?.(e, note);
94+
}}
95+
onClick={(e) => {
96+
if (e.ctrlKey || e.metaKey) onModifierClick(note, true, false);
97+
else if (e.shiftKey) onModifierClick(note, false, true);
98+
else onOpen(note);
5199
}}
52-
onClick={() => onSelect(note)}
53100
style={{
54101
padding: "10px 14px",
55102
borderBottom: "1px solid #f3f4f6",
56103
borderLeft: `4px solid ${accent}`,
57104
cursor: "pointer",
58-
background: selectedId === note.id ? "#f0fdfa" : "transparent",
105+
background: isOpen ? "#f0fdfa" : multiSelected ? "#eef2ff" : "transparent",
106+
boxShadow: dropIndicator === "before"
107+
? "inset 0 2px 0 #4f46e5"
108+
: dropIndicator === "after" ? "inset 0 -2px 0 #4f46e5" : "none",
59109
}}
60110
>
61111
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
62-
<input
63-
type="checkbox"
64-
checked={checked}
65-
onClick={(e) => e.stopPropagation()}
66-
onChange={() => onToggleSelect(note.id)}
67-
style={{ cursor: "pointer", flexShrink: 0 }}
68-
title="勾选后可批量删除"
69-
/>
112+
{multiSelected ? (
113+
<span style={{ fontSize: 11, color: "#4f46e5", fontWeight: 700, flexShrink: 0 }}>✓</span>
114+
) : (
115+
<span style={{ width: 11, flexShrink: 0 }} />
116+
)}
70117
{note.pin ? <span style={{ fontSize: 11, color: "#b45309" }}>📌</span> : null}
71118
<div style={{ fontSize: 13, fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", flex: 1 }}>
72119
{note.title}
@@ -79,7 +126,6 @@ export default function NoteListRow({
79126
key={t}
80127
style={{
81128
fontSize: 10, color: "#6b7280", borderRadius: 8, padding: "0 4px",
82-
// v0.14 B:标签色徽标底色(tagColors 命中用色板色 13% 透明底;否则默认灰底)
83129
background: tagColors?.[t] ? `${paletteHex(tagColors[t], theme)}22` : "#f3f4f6",
84130
}}
85131
>
Lines changed: 98 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,100 +1,120 @@
11
// @vitest-environment jsdom
22
/**
3-
* NoteListView.test.tsx — 笔记列表勾选批量删除交互测试(v0.12.8 审查补测)。
3+
* NoteListView.test.tsx — 笔记列表交互(REQ-287 v0.19.7)关键路径。
44
*
5-
* @ai-context: 覆盖关键路径——行勾选出现批量栏 / 全选三态 / 删除执行后清空勾选
6-
* (resolve=true)/ 取消确认保留勾选(resolve=false)/ 列表数据变化
7-
* 后裁剪不可见勾选(只删可见子集安全边界)。纯 UI 组件无 invoke。
5+
* @ai-context: 覆盖——去 checkbox 后:单击=打开右栏(onSelect);Ctrl+单击=
6+
* 加/减选(不触发打开)→ 批量栏浮现;Shift+单击=按列表位置区间
7+
* 选;批量选择模式(工具栏「选择」):行单击=勾选且不打开,Esc
8+
* 退出并清空;批量删除(resolve=true 清空选择);批量移动到组
9+
* (选集菜单 → 组 → move_note_to_group 逐条)。invoke/事件全 mock。
810
*/
9-
import { afterEach, describe, expect, it, vi } from "vitest";
10-
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
11-
import type { Note } from "../types";
11+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
13+
import type { Note, NoteGroup } from "../types";
1214
import NoteListView from "./NoteListView";
1315

14-
function makeNote(id: number, title: string): Note {
15-
return {
16-
id,
17-
title,
18-
content: "",
19-
source: "manual",
20-
tags: "[]",
21-
pin: 0,
22-
created_at: 0,
23-
updated_at: 0,
24-
};
25-
}
16+
const invokeMock = vi.fn();
17+
vi.mock("@tauri-apps/api/core", () => ({ invoke: (...a: unknown[]) => invokeMock(...a) }));
2618

27-
const notesA = [makeNote(1, "笔记一"), makeNote(2, "笔记二")];
28-
29-
const baseProps = {
30-
keyword: "",
31-
tagFilter: null,
32-
sortMode: "updated-desc" as const,
33-
allTags: [] as string[],
34-
selectedId: null,
35-
status: "",
36-
onKeywordChange: vi.fn(),
37-
onTagFilterChange: vi.fn(),
38-
onSortModeChange: vi.fn(),
39-
onSelect: vi.fn(),
40-
onCreate: vi.fn(),
41-
onRefresh: vi.fn(),
42-
onOpenSession: vi.fn(),
43-
};
19+
function makeNote(id: number, title: string, groupId: number | null = null): Note {
20+
return { id, title, content: "", source: "manual", tags: "[]", pin: 0, group_id: groupId, created_at: 0, updated_at: 0 };
21+
}
22+
const groups = [
23+
{ id: 1, name: "摄影", color: null, terrain: null, createdAt: 0, updatedAt: 0 },
24+
] as unknown as NoteGroup[];
25+
const notes = [makeNote(1, "笔记一", 1), makeNote(2, "笔记二", 1), makeNote(3, "笔记三", null)];
4426

45-
function renderList(overrides: Partial<Parameters<typeof NoteListView>[0]> = {}) {
46-
return render(<NoteListView notes={notesA} {...baseProps} onBatchDelete={vi.fn().mockResolvedValue(true)} {...overrides} />);
27+
function baseProps(over: Record<string, unknown> = {}) {
28+
return {
29+
width: 320, notes, groups, groupFilter: null, onGroupFilterChange: vi.fn(),
30+
keyword: "", tagFilter: null, sortMode: "updated-desc" as const, allTags: [],
31+
selectedId: null, status: "", noteColors: {}, tagColors: {},
32+
onKeywordChange: vi.fn(), onTagFilterChange: vi.fn(), onSortModeChange: vi.fn(),
33+
onSelect: vi.fn(), onCreate: vi.fn(), onRefresh: vi.fn(), onOpenSession: vi.fn(),
34+
onBatchDelete: vi.fn(async () => true), onNoteMoved: vi.fn(), onCollapse: vi.fn(),
35+
...over,
36+
} as Parameters<typeof NoteListView>[0];
4737
}
4838

49-
afterEach(() => cleanup());
39+
beforeEach(() => {
40+
invokeMock.mockReset();
41+
invokeMock.mockImplementation(async (cmd: string) => {
42+
switch (cmd) {
43+
case "note_order_list": return [];
44+
case "note_order_save": return null;
45+
case "note_order_clear": return true;
46+
case "move_note_to_group": return true;
47+
default: return null;
48+
}
49+
});
50+
});
51+
afterEach(cleanup);
5052

51-
describe("NoteListView 勾选批量删除", () => {
52-
it("行勾选后出现批量栏,计数正确(Arrange/Act/Assert)", () => {
53-
renderList();
54-
expect(screen.queryByText(/已选/)).toBeNull(); // 初始无批量栏
55-
fireEvent.click(screen.getAllByRole("checkbox")[0]);
56-
expect(screen.getByText(/已选 1 个/)).toBeTruthy();
53+
describe("NoteListView REQ-287 选择交互", () => {
54+
it("单击=打开右栏(onSelect);不出现批量栏", async () => {
55+
const onSelect = vi.fn();
56+
render(<NoteListView {...baseProps({ onSelect })} />);
57+
fireEvent.click(await screen.findByTestId("note-row-1"));
58+
expect(onSelect).toHaveBeenCalledWith(notes[0]);
59+
expect(screen.queryByText(/已选/)).toBeNull();
5760
});
5861

59-
it("全选框三态:勾一后全选,再点全选清空", () => {
60-
renderList();
61-
fireEvent.click(screen.getAllByRole("checkbox")[0]); // 勾 1 行 → 批量栏出现
62-
const selectAll = screen.getByTitle("全选当前列表的笔记");
63-
expect((selectAll as HTMLInputElement).checked).toBe(false);
64-
fireEvent.click(selectAll); // 未全选 → 全选
62+
it("Ctrl+单击=加/减选(不打开)→ 批量栏浮现;Ctrl 再点=减选", async () => {
63+
const onSelect = vi.fn();
64+
render(<NoteListView {...baseProps({ onSelect })} />);
65+
fireEvent.click(await screen.findByTestId("note-row-1"), { ctrlKey: true });
66+
fireEvent.click(screen.getByTestId("note-row-2"), { ctrlKey: true });
67+
expect(onSelect).not.toHaveBeenCalled();
6568
expect(screen.getByText(/已选 2 个/)).toBeTruthy();
66-
fireEvent.click(screen.getByTitle("全选当前列表的笔记")); // 已全选 → 清空
67-
expect(screen.queryByText(/已选/)).toBeNull();
69+
fireEvent.click(screen.getByTestId("note-row-1"), { ctrlKey: true });
70+
expect(screen.getByText(/已选 1 个/)).toBeTruthy();
6871
});
6972

70-
it("删除执行成功(resolve=true)后清空勾选", async () => {
71-
const onBatchDelete = vi.fn().mockResolvedValue(true);
72-
renderList({ onBatchDelete });
73-
fireEvent.click(screen.getAllByRole("checkbox")[0]);
74-
fireEvent.click(screen.getByText("批量删除"));
75-
expect(onBatchDelete).toHaveBeenCalledWith([1]);
76-
await vi.waitFor(() => expect(screen.queryByText(/已选/)).toBeNull());
73+
it("Shift+单击=按列表位置区间选(锚=上次 Ctrl 点击行)", async () => {
74+
render(<NoteListView {...baseProps()} />);
75+
// 可见序=[未分组:3, 摄影:1,2]——锚 3(Ctrl),Shift 点 2 → 全段 {1,2,3}
76+
fireEvent.click(await screen.findByTestId("note-row-3"), { ctrlKey: true });
77+
fireEvent.click(screen.getByTestId("note-row-2"), { shiftKey: true });
78+
expect(screen.getByText(/已选 3 个/)).toBeTruthy();
7779
});
7880

79-
it("取消确认(resolve=false)保留勾选", async () => {
80-
const onBatchDelete = vi.fn().mockResolvedValue(false);
81-
renderList({ onBatchDelete });
82-
fireEvent.click(screen.getAllByRole("checkbox")[0]);
83-
fireEvent.click(screen.getByText("批量删除"));
84-
await vi.waitFor(() => expect(onBatchDelete).toHaveBeenCalled());
85-
expect(screen.getByText(/已选 1 个/)).toBeTruthy();
81+
it("批量选择模式:工具栏「选择」→ 行单击=勾选且不打开;Esc 退出并清空", async () => {
82+
const onSelect = vi.fn();
83+
render(<NoteListView {...baseProps({ onSelect })} />);
84+
fireEvent.click(await screen.findByTestId("batch-mode-toggle"));
85+
expect(screen.getByText(/选择模式/)).toBeTruthy();
86+
fireEvent.click(screen.getByTestId("note-row-1"));
87+
expect(onSelect).not.toHaveBeenCalled();
88+
expect(screen.getByText(/选择模式(1)/)).toBeTruthy();
89+
fireEvent.keyDown(window, { key: "Escape" });
90+
await waitFor(() => {
91+
expect(screen.queryByText(/选择模式/)).toBeNull();
92+
expect(screen.queryByText(/已选/)).toBeNull();
93+
});
8694
});
8795

88-
it("列表数据变化后裁剪不可见勾选(只删可见子集)", async () => {
89-
const { rerender } = renderList();
90-
fireEvent.click(screen.getAllByRole("checkbox")[0]);
91-
fireEvent.click(screen.getAllByRole("checkbox")[1]);
92-
expect(screen.getByText(/已选 2 个/)).toBeTruthy();
93-
// 模拟切过滤视图/删除刷新:notes 只剩 1 条 → 勾选裁剪到可见子集
94-
const onBatchDelete = vi.fn().mockResolvedValue(true);
95-
rerender(<NoteListView notes={[makeNote(1, "笔记一")]} {...baseProps} onBatchDelete={onBatchDelete} />);
96-
expect(screen.getByText(/已选 1 个/)).toBeTruthy();
97-
fireEvent.click(screen.getByText("批量删除"));
98-
expect(onBatchDelete).toHaveBeenCalledWith([1]); // 被裁剪的不可见笔记不在删除集
96+
it("批量删除:按钮 → onBatchDelete(选集);resolve=true 后清空选择", async () => {
97+
const onBatchDelete = vi.fn(async () => true);
98+
render(<NoteListView {...baseProps({ onBatchDelete })} />);
99+
fireEvent.click(await screen.findByTestId("note-row-1"), { ctrlKey: true });
100+
fireEvent.click(screen.getByTestId("batch-delete-btn"));
101+
await waitFor(() => expect(onBatchDelete).toHaveBeenCalledWith([1]));
102+
await waitFor(() => expect(screen.queryByText(/已选/)).toBeNull());
103+
});
104+
105+
it("批量移动到组:选集含他组笔记 → 组 → move_note_to_group 落库", async () => {
106+
render(<NoteListView {...baseProps()} />);
107+
// 选 2(已在摄影)+ 3(未分组)→ 移到摄影只发未分组那条(已在组内跳过)
108+
fireEvent.click(await screen.findByTestId("note-row-2"), { ctrlKey: true });
109+
fireEvent.click(screen.getByTestId("note-row-3"), { ctrlKey: true });
110+
fireEvent.click(screen.getByTestId("batch-move-btn"));
111+
const menu = await screen.findByTestId("batch-context-menu");
112+
expect(menu).toBeTruthy();
113+
fireEvent.click(screen.getByText("📁 移动到组…"));
114+
fireEvent.click(await screen.findByText("📁 摄影"));
115+
await waitFor(() => {
116+
expect(invokeMock).toHaveBeenCalledWith("move_note_to_group", { noteId: 3, groupId: 1 });
117+
expect(invokeMock).not.toHaveBeenCalledWith("move_note_to_group", { noteId: 2, groupId: 1 });
118+
});
99119
});
100120
});

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

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66
* 过滤)/组名点击=过滤切换(决策 1 语义)/搜索激活退化平铺/
77
* 空组不渲染/未分组区收纳 group_id=null 笔记。
88
*/
9-
import { afterEach, describe, expect, it, vi } from "vitest";
10-
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
9+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
1111
import type { Note, NoteGroup } from "../types";
1212
import NoteListView from "./NoteListView";
1313

14+
// REQ-287:NoteListView 现读写 localStorage 组折叠——每测清空防串扰
15+
beforeEach(() => { try { window.localStorage.clear(); } catch { /* jsdom 守卫 */ } });
16+
1417
function makeNote(id: number, title: string, groupId?: number | null): Note {
1518
return {
1619
id,
@@ -72,15 +75,15 @@ describe("NoteListView 分组树(v0.15)", () => {
7275
expect(screen.getByText("写了")).toBeTruthy();
7376
});
7477

75-
it("chevron 收起/展开(不触发过滤)", () => {
78+
it("chevron 收起/展开(不触发过滤)", async () => {
7679
const notes = [makeNote(1, "拍了", 1)];
7780
render(<NoteListView notes={notes} groups={groups} groupFilter={null} {...baseProps} />);
78-
const chevron = screen.getByTestId("tree-chevron-摄影");
79-
fireEvent.click(chevron);
80-
expect(screen.queryByTestId("tree-body-摄影")).toBeNull();
81+
// 首次点击=收起;展开时 body 需等重渲染(父层折叠态驱动)
82+
fireEvent.click(screen.getByTestId("tree-chevron-摄影"));
83+
await waitFor(() => expect(screen.queryByTestId("tree-body-摄影")).toBeNull());
8184
expect(baseProps.onGroupFilterChange).not.toHaveBeenCalled();
82-
fireEvent.click(chevron);
83-
expect(screen.getByTestId("tree-body-摄影")).toBeTruthy();
85+
fireEvent.click(screen.getByTestId("tree-chevron-摄影"));
86+
await waitFor(() => expect(screen.getByTestId("tree-body-摄影")).toBeTruthy());
8487
});
8588

8689
it("组名点击=过滤切换(组头无选中+点章 → 组 id;再点 → null)", () => {

0 commit comments

Comments
 (0)