Skip to content

Commit ae9e89e

Browse files
committed
feat(notes): 手动分组显式入口与笔记行右键菜单——移动到组/固定/复制/编辑/删除 + 组行右键=ⓘ 组管理 + 会话行右键=打开详情(REQ-243)
1 parent 4e30af1 commit ae9e89e

10 files changed

Lines changed: 594 additions & 2 deletions

‎app/src/components/GroupSidebar.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,8 @@ export default function GroupSidebar({
255255
}}
256256
onDragLeave={() => setDragOverId((cur) => (cur === g.id ? null : cur))}
257257
onDrop={(e) => handleGroupDrop(g, e)}
258+
// v0.16.1:右键组行 = 打开 ⓘ 组管理弹层(同语义;原生菜单已全局禁用)
259+
onContextMenu={(e) => openPopover(g, e)}
258260
/>
259261
);
260262

‎app/src/components/GroupSidebarRow.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,14 @@ interface Props {
3535
onDragOver: (e: React.DragEvent) => void;
3636
onDragLeave: () => void;
3737
onDrop: (e: React.DragEvent) => void;
38+
/** v0.16.1:右键组行 → 打开 ⓘ 组管理弹层(右键菜单完整性——与 ⓘ 同语义) */
39+
onContextMenu?: (e: React.MouseEvent<HTMLElement>) => void;
3840
}
3941

4042
export default function GroupSidebarRow({
4143
group, active, systems, systemLinks, colorPickerOpen, dragOver,
4244
onSelect, onInfo, onToggleColorPicker, onColorChange, onRename, onOpenSystem,
43-
onDragOver, onDragLeave, onDrop,
45+
onDragOver, onDragLeave, onDrop, onContextMenu,
4446
}: Props) {
4547
const theme: ThemeMode = useMemo(
4648
() => (typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"),
@@ -75,6 +77,7 @@ export default function GroupSidebarRow({
7577
// 审查修复:编辑态行点击短路——原仅 input/✎ 等 stopPropagation,
7678
// 行空白区点击会先 blur 取消编辑再触发组过滤切换(内容丢失+浏览位置漂移)
7779
onClick={editing ? (e) => e.stopPropagation() : onSelect}
80+
onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); onContextMenu?.(e); }}
7881
onDragOver={onDragOver}
7982
onDragLeave={onDragLeave}
8083
onDrop={onDrop}

‎app/src/components/NoteListRow.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,12 @@ interface Props {
2121
onSelect: (note: Note) => void;
2222
onToggleSelect: (id: number) => void;
2323
onOpenSession: (sessionId: number) => void;
24+
/** v0.16.1:右键菜单打开(父层持有坐标/状态;默认浏览器菜单已全局禁用) */
25+
onContextMenu?: (e: React.MouseEvent) => void;
2426
}
2527

2628
export default function NoteListRow({
27-
note, accent, selectedId, checked, tagColors, onSelect, onToggleSelect, onOpenSession,
29+
note, accent, selectedId, checked, tagColors, onSelect, onToggleSelect, onOpenSession, onContextMenu,
2830
}: Props) {
2931
const tags = parseTags(note);
3032
// v0.14 B:当前主题(跟随 prefers-color-scheme;jsdom 无 matchMedia 回退 light)
@@ -41,6 +43,12 @@ export default function NoteListRow({
4143
e.dataTransfer.setData("text/note-id", String(note.id));
4244
e.dataTransfer.effectAllowed = "move";
4345
}}
46+
onContextMenu={(e) => {
47+
// v0.16.1:应用内右键菜单——抑制原生菜单(前端兜底)+ 上抛坐标
48+
e.preventDefault();
49+
e.stopPropagation();
50+
onContextMenu?.(e);
51+
}}
4452
onClick={() => onSelect(note)}
4553
style={{
4654
padding: "10px 14px",

‎app/src/components/NoteListView.tsx‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { paletteHex } from "../utils/colorPalette";
1717
import type { ThemeMode } from "../utils/colorPalette";
1818
import NoteListRow from "./NoteListRow";
1919
import NoteTreeSection from "./NoteTreeSection";
20+
// v0.16.1:笔记行右键菜单(原生菜单已全局禁用——应用内替代)
21+
import NoteRowContextMenu from "./NoteRowContextMenu";
2022

2123
// 兼容既有导入面(NoteReadingView/NotesPage/parseTags.test 从此解析——v0.15 移厝 utils)
2224
export { parseTags, fmtDate } from "../utils/noteHelpers";
@@ -54,6 +56,12 @@ interface Props {
5456
/** 批量删除(父层负责确认/invoke/刷新;resolve=true 表示已执行删除——
5557
* 勾选集合在删除执行后才清空,取消确认时保留勾选——与会话管理台一致) */
5658
onBatchDelete: (ids: number[]) => Promise<boolean>;
59+
// v0.16.1:右键菜单动作(父层既有处理;缺省时对应菜单项隐藏)
60+
onNotePinToggle?: (note: Note) => void;
61+
onNoteEdit?: (note: Note) => void;
62+
onNoteDelete?: (note: Note) => void;
63+
/** 归组成功后刷新(父层重载列表 + 右栏选中重取) */
64+
onNoteMoved?: () => void;
5765
/** v0.15:折叠为窄条(父层 useColumnLayout.setManualFolded(true)) */
5866
onCollapse?: () => void;
5967
}
@@ -72,6 +80,7 @@ export default function NoteListView({
7280
keyword, tagFilter, sortMode, allTags, selectedId, status,
7381
noteColors, tagColors,
7482
onKeywordChange, onTagFilterChange, onSortModeChange, onSelect, onCreate, onRefresh, onOpenSession, onBatchDelete,
83+
onNotePinToggle, onNoteEdit, onNoteDelete, onNoteMoved,
7584
onCollapse,
7685
}: Props) {
7786
// v0.14 B:当前主题(跟随 prefers-color-scheme;jsdom 无 matchMedia 回退 light)
@@ -140,6 +149,9 @@ export default function NoteListView({
140149

141150
const toggleGroupFold = (key: string) => setGroupFolds((cur) => ({ ...cur, [key]: !cur[key] }));
142151

152+
// v0.16.1:右键菜单态(note + 视口坐标;null=关闭)
153+
const [contextMenu, setContextMenu] = useState<{ note: Note; x: number; y: number } | null>(null);
154+
143155
// v0.15:树退化规则——搜索/标签/排序激活时平铺(树上下文无意义)
144156
const treeMode = keyword.trim() === "" && tagFilter === null && sortMode === "updated-desc";
145157
const grouped = useMemo(() => {
@@ -168,6 +180,7 @@ export default function NoteListView({
168180
onSelect={onSelect}
169181
onToggleSelect={toggleSelect}
170182
onOpenSession={onOpenSession}
183+
onContextMenu={(e) => setContextMenu({ note: n, x: e.clientX, y: e.clientY })}
171184
/>
172185
);
173186

@@ -313,6 +326,21 @@ export default function NoteListView({
313326
</div>
314327
)}
315328
{status && <p style={{ padding: 8, fontSize: 12, color: "#dc2626" }}>{status}</p>}
329+
330+
{/* v0.16.1:笔记行右键菜单(原生菜单已全局禁用——应用内替代) */}
331+
{contextMenu && onNotePinToggle && onNoteEdit && onNoteDelete && onNoteMoved && (
332+
<NoteRowContextMenu
333+
note={contextMenu.note}
334+
groups={groups}
335+
x={contextMenu.x}
336+
y={contextMenu.y}
337+
onClose={() => setContextMenu(null)}
338+
onPinToggle={onNotePinToggle}
339+
onEdit={onNoteEdit}
340+
onDelete={onNoteDelete}
341+
onMoved={onNoteMoved}
342+
/>
343+
)}
316344
</div>
317345
);
318346
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* NoteMoveToGroupMenu.test.tsx — 阅读头「移动到组」菜单(v0.16.1)。AAA 模式。
4+
*
5+
* @ai-context: 覆盖三条路径——移入组(invoke move_note_to_group 带 groupId)、
6+
* 移出组(command groupId=null,仅当前已归组时出现)、当前组 ✓
7+
* 禁点(幂等防重复调用)。
8+
*/
9+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
11+
12+
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
13+
vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }));
14+
15+
import NoteMoveToGroupMenu from "./NoteMoveToGroupMenu";
16+
import type { Note, NoteGroup } from "../types";
17+
18+
const groups: NoteGroup[] = [
19+
{ id: 1, name: "化妆课程", terrain: "container", kind: "topic", domainTag: "beauty-makeup", source: "manual", seriesKey: null, routeReason: null, routeOverridden: 0, color: "pink", noteCount: 3, createdAt: 0, updatedAt: 0 },
20+
{ id: 2, name: "乐理", terrain: "container", kind: "course", domainTag: null, source: "route", seriesKey: null, routeReason: null, routeOverridden: 0, color: null, noteCount: 5, createdAt: 0, updatedAt: 0 },
21+
];
22+
23+
const noteBase: Note = {
24+
id: 42, title: "笔记", content: "x", source: "manual", tags: "[]", pin: 0, created_at: 0, updated_at: 0,
25+
};
26+
27+
beforeEach(() => { invokeMock.mockReset(); invokeMock.mockResolvedValue(true); });
28+
afterEach(() => cleanup());
29+
30+
describe("NoteMoveToGroupMenu 手动分组", () => {
31+
it("未归组:点开列出全部组;点组 → move_note_to_group(noteId, groupId) + 刷新", async () => {
32+
const onChanged = vi.fn();
33+
render(<NoteMoveToGroupMenu note={noteBase} groups={groups} onChanged={onChanged} />);
34+
fireEvent.click(screen.getByTestId("move-to-group-open"));
35+
expect(screen.getByTestId("move-to-group-1")).toBeTruthy();
36+
fireEvent.click(screen.getByTestId("move-to-group-1"));
37+
await waitFor(() => {
38+
expect(invokeMock).toHaveBeenCalledWith("move_note_to_group", { noteId: 42, groupId: 1 });
39+
});
40+
expect(onChanged).toHaveBeenCalled();
41+
expect(screen.queryByTestId("move-to-group-pop")).toBeNull(); // 成功后收起
42+
});
43+
44+
it("已归组:当前组 ✓ 且点击不重复调用;「移出组」以 groupId=null 调用", async () => {
45+
render(<NoteMoveToGroupMenu note={{ ...noteBase, group_id: 2 }} groups={groups} onChanged={vi.fn()} />);
46+
fireEvent.click(screen.getByTestId("move-to-group-open"));
47+
const current = screen.getByTestId("move-to-group-2");
48+
expect(current.textContent).toContain("✓");
49+
fireEvent.click(current); // 幂等:当前组点击不调用
50+
expect(invokeMock).not.toHaveBeenCalled();
51+
fireEvent.click(screen.getByTestId("move-to-group-none"));
52+
await waitFor(() => {
53+
expect(invokeMock).toHaveBeenCalledWith("move_note_to_group", { noteId: 42, groupId: null });
54+
});
55+
});
56+
57+
it("无组:提示先新建组(零命令调用)", () => {
58+
render(<NoteMoveToGroupMenu note={noteBase} groups={[]} onChanged={vi.fn()} />);
59+
fireEvent.click(screen.getByTestId("move-to-group-open"));
60+
expect(screen.getByText(/暂无组/)).toBeTruthy();
61+
expect(invokeMock).not.toHaveBeenCalled();
62+
});
63+
});
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* NoteMoveToGroupMenu — 阅读头「移动到组」下拉(v0.16.1 手动分组入口)。
3+
*
4+
* @ai-context: 手动分组此前仅两条路径——拖拽笔记行到组行(HTML5 DnD,WebView2
5+
* 下不稳健且难发现)与组行 ⓘ 弹层「移入选中笔记」(需先选中笔记,
6+
* 入口隐蔽)。本组件在笔记阅读头提供显式「移动到组」:当前组 ✓ +
7+
* 组色点 + 全部组列表 + 移出组(回「全部」);复用 move_note_to_group
8+
* 命令;变更经 onChanged(NotesPage.handleNoteChanged)刷新列表与
9+
* 右栏(选中笔记 get_note 回填 group_id)。
10+
*/
11+
import { useMemo, useState } from "react";
12+
import { invoke } from "@tauri-apps/api/core";
13+
import type { Note, NoteGroup } from "../types";
14+
import { paletteHex } from "../utils/colorPalette";
15+
import type { ThemeMode } from "../utils/colorPalette";
16+
17+
interface Props {
18+
note: Note;
19+
groups: NoteGroup[];
20+
/** 变更后的刷新回调(父层重载列表 + 重取选中笔记) */
21+
onChanged: () => void;
22+
}
23+
24+
const DROPDOWN_ITEM: React.CSSProperties = {
25+
display: "flex",
26+
alignItems: "center",
27+
gap: 6,
28+
width: "100%",
29+
padding: "5px 8px",
30+
border: "none",
31+
background: "none",
32+
borderRadius: 4,
33+
fontSize: 12,
34+
color: "#374151",
35+
cursor: "pointer",
36+
textAlign: "left",
37+
whiteSpace: "nowrap",
38+
overflow: "hidden",
39+
textOverflow: "ellipsis",
40+
};
41+
42+
function GroupDot({ colorId }: { colorId: string | null | undefined }) {
43+
const theme: ThemeMode = useMemo(
44+
() => (typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"),
45+
[],
46+
);
47+
return (
48+
<span
49+
style={{
50+
width: 9,
51+
height: 9,
52+
borderRadius: 3,
53+
flexShrink: 0,
54+
display: "inline-block",
55+
background: paletteHex(colorId, theme),
56+
}}
57+
/>
58+
);
59+
}
60+
61+
export default function NoteMoveToGroupMenu({ note, groups, onChanged }: Props) {
62+
const [open, setOpen] = useState(false);
63+
const [busy, setBusy] = useState(false);
64+
const [status, setStatus] = useState("");
65+
const currentId = note.group_id ?? null;
66+
67+
const move = async (groupId: number | null) => {
68+
if (busy || groupId === currentId) return;
69+
setBusy(true);
70+
try {
71+
await invoke<boolean>("move_note_to_group", { noteId: note.id, groupId });
72+
setStatus("");
73+
setOpen(false);
74+
onChanged();
75+
} catch (e) {
76+
setStatus(`移动失败: ${e}`);
77+
} finally {
78+
setBusy(false);
79+
}
80+
};
81+
82+
return (
83+
<div style={{ position: "relative" }}>
84+
<button
85+
data-testid="move-to-group-open"
86+
onClick={() => setOpen((v) => !v)}
87+
title="手动归组:把这条笔记移入/移出笔记组"
88+
style={{
89+
fontSize: 12,
90+
cursor: "pointer",
91+
padding: "4px 8px",
92+
borderRadius: 4,
93+
border: "1px solid #d1d5db",
94+
background: "#fff",
95+
color: "#374151",
96+
}}
97+
>
98+
📁 分组
99+
</button>
100+
{open && (
101+
<>
102+
{/* 透明背板:点击外部收起 */}
103+
<div
104+
onClick={() => setOpen(false)}
105+
style={{ position: "fixed", inset: 0, zIndex: 30, background: "transparent" }}
106+
/>
107+
<div
108+
data-testid="move-to-group-pop"
109+
style={{
110+
position: "absolute",
111+
top: "100%",
112+
right: 0,
113+
zIndex: 31,
114+
minWidth: 220,
115+
maxHeight: 320,
116+
overflowY: "auto",
117+
background: "#fff",
118+
border: "1px solid #e5e7eb",
119+
borderRadius: 6,
120+
padding: 6,
121+
boxShadow: "0 4px 12px rgba(0,0,0,0.12)",
122+
}}
123+
>
124+
<div style={{ fontSize: 11, fontWeight: 600, color: "#6b7280", padding: "2px 8px 4px" }}>
125+
移动到组(当前:{groups.find((g) => g.id === currentId)?.name ?? "未分组"})
126+
</div>
127+
{groups.length === 0 && (
128+
<div style={{ fontSize: 12, color: "#9ca3af", padding: "2px 8px 6px" }}>
129+
暂无组——左侧「+ 新建组」创建后即可归组
130+
</div>
131+
)}
132+
{groups.map((g) => {
133+
const active = g.id === currentId;
134+
return (
135+
<button
136+
key={g.id}
137+
data-testid={`move-to-group-${g.id}`}
138+
onClick={() => void move(g.id)}
139+
disabled={busy || active}
140+
style={{
141+
...DROPDOWN_ITEM,
142+
background: active ? "#f0fdfa" : undefined,
143+
color: active ? "#0f766e" : "#374151",
144+
cursor: active ? "default" : "pointer",
145+
}}
146+
title={g.name}
147+
>
148+
<GroupDot colorId={g.color} />
149+
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis" }}>{g.name}</span>
150+
{active && <span>✓</span>}
151+
</button>
152+
);
153+
})}
154+
{currentId != null && (
155+
<button
156+
data-testid="move-to-group-none"
157+
onClick={() => void move(null)}
158+
disabled={busy}
159+
style={{ ...DROPDOWN_ITEM, color: "#b91c1c", borderTop: "1px solid #f3f4f6", marginTop: 4 }}
160+
>
161+
移出分组(回「全部笔记」)
162+
</button>
163+
)}
164+
{status && (
165+
<div style={{ fontSize: 11, color: "#dc2626", padding: "4px 8px" }}>{status}</div>
166+
)}
167+
</div>
168+
</>
169+
)}
170+
</div>
171+
);
172+
}

0 commit comments

Comments
 (0)