Skip to content

Commit a790458

Browse files
committed
feat(notes): REQ-315 组行右键菜单与分区手排/置顶 UI(侧栏排序消费)
1 parent 5b61400 commit a790458

5 files changed

Lines changed: 483 additions & 4 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* GroupRowContextMenu — 组行右键菜单(REQ-315,v0.20.11 批 6)。
3+
*
4+
* @ai-context: 原生右键菜单已全局禁用——组行右键此前直开 ⓘ 弹层(与 ⓘ 按钮
5+
* 同语义),本组件承接右键入口并扩展为操作菜单:📌 置顶/取消置顶
6+
* (update_note_group_pin)→ ↑↓ 上移/下移(分区手动序快照——
7+
* note_group_order_save,父层重排后整表覆写)→ ↺ 回自动排序
8+
* (note_group_order_clear——仅组已有手动位时显示;整分区复位在
9+
* 分区头「手排 ↺」徽标)→ ⓘ 打开信息(保留既有 RouteInfoPopover
10+
* 入口)。行内 ⓘ 按钮仍直开弹层(不动旧动线)。
11+
* @ai-context: 置顶组移动禁用——置顶区按更新时间定序(内部排序非手动位),
12+
* 与笔记置顶语义一致;边界/置顶可用性由父层按分区可见序计算。
13+
*/
14+
import { useEffect } from "react";
15+
16+
interface Props {
17+
/** 组名(菜单头) */
18+
name: string;
19+
pinned: boolean;
20+
/** 组当前是否有手动位(有则显示「回自动排序」) */
21+
hasOrderRow: boolean;
22+
canMoveUp: boolean;
23+
canMoveDown: boolean;
24+
x: number;
25+
y: number;
26+
onClose: () => void;
27+
onPinToggle: () => void;
28+
onMoveUp: () => void;
29+
onMoveDown: () => void;
30+
/** 清除该组手动位(=该组回自动区;行已删=后端幂等) */
31+
onClearOrder: () => void;
32+
/** 打开 ⓘ 组管理弹层(父层以菜单坐标合成锚点) */
33+
onOpenInfo: () => void;
34+
}
35+
36+
const ITEM: React.CSSProperties = {
37+
display: "flex",
38+
alignItems: "center",
39+
gap: 8,
40+
width: "100%",
41+
padding: "5px 10px",
42+
border: "none",
43+
background: "none",
44+
borderRadius: 6,
45+
fontSize: 12.5,
46+
color: "#374151",
47+
cursor: "pointer",
48+
textAlign: "left",
49+
};
50+
51+
const ITEM_ICON: React.CSSProperties = { width: 20, fontSize: 12, textAlign: "center" };
52+
53+
export default function GroupRowContextMenu({
54+
name, pinned, hasOrderRow, canMoveUp, canMoveDown, x, y,
55+
onClose, onPinToggle, onMoveUp, onMoveDown, onClearOrder, onOpenInfo,
56+
}: Props) {
57+
useEffect(() => {
58+
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
59+
window.addEventListener("keydown", h);
60+
return () => window.removeEventListener("keydown", h);
61+
}, [onClose]);
62+
const px = Math.max(4, Math.min(x, window.innerWidth - 232));
63+
const py = Math.max(4, Math.min(y, window.innerHeight - 320));
64+
// 置顶区语义提示(与笔记置顶菜单同口径——置顶项位置由更新时间表达)
65+
const pinMoveHint = pinned ? "置顶组固定于置顶区(按更新时间排序),取消置顶后可移动" : undefined;
66+
const moveBtn = (disabled: boolean) => ({
67+
...ITEM,
68+
opacity: disabled ? 0.45 : 1,
69+
cursor: disabled ? "default" : "pointer",
70+
});
71+
72+
return (
73+
<>
74+
{/* 透明背板:点击/右键收起 */}
75+
<div
76+
onClick={onClose}
77+
onContextMenu={(e) => { e.preventDefault(); onClose(); }}
78+
style={{ position: "fixed", inset: 0, zIndex: 60, background: "transparent" }}
79+
/>
80+
<div
81+
role="menu"
82+
data-testid="group-row-menu"
83+
data-app-menu=""
84+
onContextMenu={(e) => e.preventDefault()}
85+
onMouseDown={(e) => e.stopPropagation()}
86+
style={{
87+
position: "fixed",
88+
left: px,
89+
top: py,
90+
zIndex: 61,
91+
width: 216,
92+
background: "#fff",
93+
border: "1px solid #e5e7eb",
94+
borderRadius: 8,
95+
boxShadow: "0 8px 24px rgba(0,0,0,0.14)",
96+
padding: 4,
97+
}}
98+
>
99+
<div style={{ fontSize: 11, color: "#6b7280", padding: "2px 10px 4px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
100+
📁 {name}
101+
</div>
102+
103+
<button data-testid="ctx-group-pin" style={ITEM} onClick={() => { onClose(); onPinToggle(); }}>
104+
<span style={ITEM_ICON}>📌</span> {pinned ? "取消置顶" : "置顶"}
105+
</button>
106+
<button
107+
data-testid="ctx-group-up"
108+
disabled={!canMoveUp}
109+
title={pinMoveHint ?? "已在首位"}
110+
style={moveBtn(!canMoveUp)}
111+
onClick={() => { if (canMoveUp) { onClose(); onMoveUp(); } }}
112+
>
113+
<span style={ITEM_ICON}>↑</span> 上移
114+
</button>
115+
<button
116+
data-testid="ctx-group-down"
117+
disabled={!canMoveDown}
118+
title={pinMoveHint ?? "已在末位"}
119+
style={moveBtn(!canMoveDown)}
120+
onClick={() => { if (canMoveDown) { onClose(); onMoveDown(); } }}
121+
>
122+
<span style={ITEM_ICON}>↓</span> 下移
123+
</button>
124+
{hasOrderRow && (
125+
<button data-testid="ctx-group-reset" style={ITEM} onClick={() => { onClose(); onClearOrder(); }}>
126+
<span style={ITEM_ICON}>↺</span> 回自动排序(移除手动位)
127+
</button>
128+
)}
129+
<div style={{ height: 1, background: "#f3f4f6", margin: "3px 6px" }} />
130+
<button data-testid="ctx-group-info" style={ITEM} onClick={() => { onClose(); onOpenInfo(); }}>
131+
<span style={ITEM_ICON}>ⓘ</span> 打开信息(路由/改判/周契约…)
132+
</button>
133+
</div>
134+
</>
135+
);
136+
}

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

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ beforeEach(() => {
5454
case "create_topic_group": return { id: 99, name: args?.name ?? "新组", terrain: "container", kind: "topic", domainTag: args?.domainTag ?? "beauty", source: "manual", seriesKey: null, routeReason: null, routeOverridden: 0, noteCount: 0, createdAt: 0, updatedAt: 0 };
5555
case "update_group_color": return true;
5656
case "rename_note_group": return true;
57+
// REQ-315(批 6):组排序/置顶命令(默认空序行/成功)
58+
case "note_group_order_list": return [];
59+
case "update_note_group_pin": return true;
60+
case "note_group_order_save": return null;
61+
case "note_group_order_clear": return true;
5762
case "get_group_delete_impact":
5863
return { notes: 1, fragments: 0, cards: 0, settlements: 0, contracts: 0, systemRefs: 0 };
5964
case "delete_note_group": return true;
@@ -356,3 +361,137 @@ describe("GroupSidebar v0.14.1 新建/重命名", () => { beforeEach(() => loca
356361
expect(onGroupFilterChange).not.toHaveBeenCalled();
357362
});
358363
});
364+
365+
describe("GroupSidebar REQ-315 排序/置顶", () => {
366+
beforeEach(() => localStorage.clear());
367+
const courseA: NoteGroup = {
368+
id: 1, name: "课程 A", terrain: "container", kind: "course", domainTag: null,
369+
source: "route", seriesKey: null, routeReason: null, routeOverridden: 0,
370+
noteCount: 1, createdAt: 0, updatedAt: 10,
371+
};
372+
const courseC: NoteGroup = {
373+
id: 3, name: "课程 C", terrain: "container", kind: "course", domainTag: null,
374+
source: "route", seriesKey: null, routeReason: null, routeOverridden: 0,
375+
noteCount: 1, createdAt: 0, updatedAt: 20,
376+
};
377+
const renderWith = (list: NoteGroup[], rows: [number, number][] = []) => {
378+
invokeMock.mockImplementation(async (cmd: string) => {
379+
switch (cmd) {
380+
case "list_note_groups": return list;
381+
case "list_fragments": return [];
382+
case "get_feature_flags": return { feedCapture: true };
383+
case "note_group_order_list": return rows;
384+
case "list_knowledge_systems": return [];
385+
case "list_knowledge_links": return [];
386+
case "week_contract_status": return { contract: null, weekStart: 0, actualDays: 0, actualCards: 0, minimalDayMet: false };
387+
case "update_note_group_pin": return true;
388+
case "note_group_order_save": return null;
389+
case "note_group_order_clear": return true;
390+
case "list_group_cards": return [];
391+
default: return null;
392+
}
393+
});
394+
const onChanged = vi.fn();
395+
render(
396+
<GroupSidebar
397+
groupFilter={null}
398+
onGroupFilterChange={vi.fn()}
399+
onChanged={onChanged}
400+
onOpenReview={vi.fn()}
401+
selectedNoteId={null}
402+
onOpenInbox={vi.fn()}
403+
inboxActive={false}
404+
refreshToken={0}
405+
onOpenSystem={vi.fn()}
406+
/>,
407+
);
408+
return onChanged;
409+
};
410+
411+
it("分区行按 置顶(updatedAt 降序)→手排 seq→自动 渲染", async () => {
412+
const pinned = { ...courseC, pin: 1, id: 3 };
413+
const old = { ...courseA, id: 1 };
414+
const fresh = { ...courseA, id: 2, name: "课程 B", updatedAt: 5 };
415+
renderWith([old, fresh, pinned], [[1, 0]]); // 手排区只有课程 A(seq0)
416+
await screen.findByTestId("group-row-3");
417+
const rows = Array.from(screen.getByTestId("group-section-course").querySelectorAll('[data-testid^="group-row-"]'));
418+
// 置顶课程C → 手排课程A → 自动区课程B(其余按 updatedAt 降序)
419+
expect(rows.map((r) => r.getAttribute("data-testid"))).toEqual(["group-row-3", "group-row-1", "group-row-2"]);
420+
// 置顶标记
421+
expect(screen.getByTestId("group-pin-3")).toBeTruthy();
422+
});
423+
424+
it("右键组行 = 操作菜单(替代直开弹层);「打开信息」保留 ⓘ 入口", async () => {
425+
renderWith([courseA, courseC]);
426+
await screen.findByTestId("group-row-1");
427+
fireEvent.contextMenu(screen.getByTestId("group-row-3"), { clientX: 120, clientY: 130 });
428+
const menu = await screen.findByTestId("group-row-menu");
429+
expect(menu).toBeTruthy();
430+
expect(screen.getByTestId("ctx-group-pin").textContent).toContain("置顶");
431+
expect(screen.queryByTestId("route-popover")).toBeNull(); // 右键不再直开弹层
432+
fireEvent.click(screen.getByTestId("ctx-group-info"));
433+
expect(await screen.findByTestId("route-popover")).toBeTruthy();
434+
});
435+
436+
it("菜单置顶 → update_note_group_pin;置顶行菜单切「取消置顶」且移动禁用", async () => {
437+
const onChanged = renderWith([{ ...courseA, pin: 1 }]);
438+
await screen.findByTestId("group-row-1");
439+
fireEvent.contextMenu(screen.getByTestId("group-row-1"), { clientX: 10, clientY: 10 });
440+
const menu = await screen.findByTestId("group-row-menu");
441+
expect(menu).toBeTruthy();
442+
expect(screen.getByTestId("ctx-group-pin").textContent).toContain("取消置顶");
443+
// 置顶区按更新时间定序——上移/下移禁用
444+
expect((screen.getByTestId("ctx-group-up") as HTMLButtonElement).disabled).toBe(true);
445+
expect((screen.getByTestId("ctx-group-down") as HTMLButtonElement).disabled).toBe(true);
446+
fireEvent.click(screen.getByTestId("ctx-group-pin"));
447+
await waitFor(() => expect(invokeMock).toHaveBeenCalledWith("update_note_group_pin", { id: 1, pin: 0 }));
448+
await waitFor(() => expect(onChanged).toHaveBeenCalled());
449+
});
450+
451+
it("分区内上移 = 可见序换位整表快照覆写(自动分区首移即转手排)", async () => {
452+
const onChanged = renderWith([courseA, courseC]); // 自动区:C(20) 前 A(10) 后
453+
await screen.findByTestId("group-row-1");
454+
const rows0 = Array.from(screen.getByTestId("group-section-course").querySelectorAll('[data-testid^="group-row-"]'));
455+
expect(rows0.map((r) => r.getAttribute("data-testid"))).toEqual(["group-row-3", "group-row-1"]);
456+
// 右键课程 A(可上移)→ 上移
457+
fireEvent.contextMenu(screen.getByTestId("group-row-1"), { clientX: 10, clientY: 10 });
458+
expect((screen.getByTestId("ctx-group-up") as HTMLButtonElement).disabled).toBe(false);
459+
fireEvent.click(screen.getByTestId("ctx-group-up"));
460+
await waitFor(() => expect(invokeMock).toHaveBeenCalledWith("note_group_order_save", { kind: "course", groupIds: [1, 3] }));
461+
await waitFor(() => expect(onChanged).toHaveBeenCalled());
462+
});
463+
464+
it("分区「手排 ↺」徽标:有手动位才显示,点击=空快照整体回自动", async () => {
465+
const onChanged = renderWith([courseA, courseC], [[1, 0], [3, 1]]);
466+
await screen.findByTestId("group-row-1");
467+
const resetBtn = await screen.findByTestId("group-reset-course");
468+
expect(resetBtn.textContent).toContain("手排");
469+
fireEvent.click(resetBtn);
470+
await waitFor(() => expect(invokeMock).toHaveBeenCalledWith("note_group_order_save", { kind: "course", groupIds: [] }));
471+
await waitFor(() => expect(onChanged).toHaveBeenCalled());
472+
});
473+
474+
it("菜单「回自动排序」仅组有手动位时显示;点击清单组手动位", async () => {
475+
const onChanged = renderWith([courseA, courseC], [[3, 0]]); // 仅课程 C 有手动位
476+
await screen.findByTestId("group-row-1");
477+
// 课程 A 无手动位 → 无该项
478+
fireEvent.contextMenu(screen.getByTestId("group-row-1"), { clientX: 10, clientY: 10 });
479+
expect(screen.queryByTestId("ctx-group-reset")).toBeNull();
480+
fireEvent.keyDown(window, { key: "Escape" });
481+
// 课程 C 有手动位 → 显示并可清
482+
fireEvent.contextMenu(screen.getByTestId("group-row-3"), { clientX: 10, clientY: 10 });
483+
fireEvent.click(await screen.findByTestId("ctx-group-reset"));
484+
await waitFor(() => expect(invokeMock).toHaveBeenCalledWith("note_group_order_clear", { groupId: 3 }));
485+
await waitFor(() => expect(onChanged).toHaveBeenCalled());
486+
});
487+
488+
it("序行空(全自动分区)→ 无「手排」徽标;ESC 关闭右键菜单", async () => {
489+
renderWith([courseA, courseC]);
490+
await screen.findByTestId("group-row-1");
491+
expect(screen.queryByTestId("group-reset-course")).toBeNull();
492+
fireEvent.contextMenu(screen.getByTestId("group-row-1"), { clientX: 10, clientY: 10 });
493+
expect(await screen.findByTestId("group-row-menu")).toBeTruthy();
494+
fireEvent.keyDown(window, { key: "Escape" });
495+
expect(screen.queryByTestId("group-row-menu")).toBeNull();
496+
});
497+
});

0 commit comments

Comments
 (0)