Skip to content

Commit ed6153a

Browse files
committed
refactor(ui): 拆 NoteListView 第 9 步 — 批量栏与选集菜单
1 parent b8ff1ef commit ed6153a

2 files changed

Lines changed: 125 additions & 30 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* NoteListBatchMenu — 批量操作栏 + 选集批处理菜单(批 0-C2·Task 2 自 NoteListView 抽出)。
3+
*
4+
* @ai-context: 两个**受控展示件**同置一个模块(共享按钮基底与同一批处理语义):
5+
* ① `NoteListBatchBar`——选集非空时浮出的底部条(移动到组 / 批量删除 /
6+
* 完成 / 取消);② `NoteListBatchContextMenu`——行右键选集或批量栏唤起的
7+
* 菜单,含二级「移动到组」视图(全部组 + 移出组)。
8+
* @ai-context: 做成受控件的理由(R8 风险):菜单态与 **唯一 Esc handler** 的优先级链
9+
* (batchMenu → contextMenu → 批量模式)留在 hooks/useNoteListSelection。
10+
* 若本模块自带 window keydown(照抄 NoteRowContextMenu 的范式),会出现两个
11+
* window 监听同时判定,行为依赖 React 批处理时序。⇒ 本模块**零状态、
12+
* 零副作用、零 invoke**。
13+
* @ai-context: 文案与 data-testid 是测试契约(`batch-move-btn` / `batch-delete-btn` /
14+
* `batch-done-btn` / `batch-context-menu` / `batch-menu-delete`,以及
15+
* 「已选 N 个」「📁 移动到组…」「删除选中(N)」「清除选择」「完成」
16+
* 「取消」「(移出组)」「📁 {组名}」「← 返回」——测试用 getByText 精确匹配,
17+
* 含全角括号与省略号),**逐字保留**。zIndex 40/41 与拆分前一致
18+
* (本批不迁移到 ADR-032 的 zIndex 标尺);`busyMove` 禁用态同前。
19+
*/
20+
import type { NoteGroup } from "../types";
21+
import { ghostBtn } from "./NoteListToolbar";
22+
23+
interface BatchBarProps {
24+
/** 选集大小(「已选 N 个」) */
25+
count: number;
26+
/** 批量选择模式(决定是否显示「完成」) */
27+
selectionMode: boolean;
28+
/** 打开选集菜单(父层以选集构造 batchMenu,坐标为 0/0 → 由菜单回退到 12/12) */
29+
onMoveToGroup: () => void;
30+
/** 批量删除(父层确认;成功才清选) */
31+
onDelete: () => void;
32+
/** 退出选择模式并清选 */
33+
onDone: () => void;
34+
/** 仅清选(保留模式) */
35+
onCancel: () => void;
36+
}
37+
38+
/** 批量操作栏(去勾选框后的批量入口:删除 + 移动到组;选择模式下同样可用) */
39+
export function NoteListBatchBar({ count, selectionMode, onMoveToGroup, onDelete, onDone, onCancel }: BatchBarProps) {
40+
return (
41+
<div style={{ borderTop: "1px solid #e5e7eb", padding: 8, display: "flex", gap: 6, alignItems: "center", background: "#fff" }}>
42+
<span style={{ fontSize: 12, color: "#3730a3" }}>已选 {count} 个</span>
43+
<button data-testid="batch-move-btn" style={ghostBtn} onClick={onMoveToGroup}>移动到组</button>
44+
<button data-testid="batch-delete-btn" style={{ ...ghostBtn, borderColor: "#fca5a5", color: "#dc2626" }} onClick={onDelete}>批量删除</button>
45+
{selectionMode && <button data-testid="batch-done-btn" style={{ ...ghostBtn, color: "#3730a3" }} onClick={onDone}>完成</button>}
46+
<button style={{ ...ghostBtn, marginLeft: "auto" }} onClick={onCancel}>取消</button>
47+
</div>
48+
);
49+
}
50+
51+
interface BatchMenuProps {
52+
/** 选集 ids(菜单标题「已选 N 个」与「删除选中(N)」) */
53+
ids: number[];
54+
x: number;
55+
y: number;
56+
/** 二级「移动到组」视图开关(父层持有——Esc 优先级链同闭包) */
57+
moveOpen: boolean;
58+
groups: NoteGroup[];
59+
/** 归组进行中:二级视图全部按钮禁用 */
60+
busyMove: boolean;
61+
/** 收起菜单(背板点击) */
62+
onClose: () => void;
63+
onOpenMove: () => void;
64+
onDelete: () => void;
65+
/** 清除选择(收起菜单 + 清选集) */
66+
onClearSelection: () => void;
67+
/** 归入目标组(null=移出组) */
68+
onMoveToGroup: (groupId: number | null) => void;
69+
/** 从二级视图返回一级 */
70+
onBack: () => void;
71+
}
72+
73+
/** 选集批处理菜单(删除/移动到组)——透明背板 + 面板,整体一个 fragment(无额外 DOM 层) */
74+
export function NoteListBatchContextMenu({
75+
ids, x, y, moveOpen, groups, busyMove, onClose, onOpenMove, onDelete, onClearSelection, onMoveToGroup, onBack,
76+
}: BatchMenuProps) {
77+
return (
78+
<>
79+
<div style={{ position: "fixed", inset: 0, zIndex: 40 }} onClick={onClose} />
80+
<div data-testid="batch-context-menu" style={{ position: "fixed", zIndex: 41, left: x || 12, top: y || 12, minWidth: 180, background: "#fff", border: "1px solid #e5e7eb", borderRadius: 8, boxShadow: "0 6px 18px rgba(0,0,0,0.12)", padding: 6, fontSize: 12 }}>
81+
<div style={{ padding: "2px 6px", color: "#9ca3af", fontSize: 11 }}>已选 {ids.length} 个</div>
82+
{!moveOpen ? (
83+
<>
84+
<button style={{ ...ghostBtn, width: "100%", marginTop: 4, textAlign: "left" }} disabled={busyMove} onClick={onOpenMove}>📁 移动到组…</button>
85+
<button data-testid="batch-menu-delete" style={{ ...ghostBtn, width: "100%", marginTop: 4, textAlign: "left", borderColor: "#fca5a5", color: "#dc2626" }} disabled={busyMove} onClick={onDelete}>删除选中({ids.length})</button>
86+
<button style={{ ...ghostBtn, width: "100%", marginTop: 4, textAlign: "left" }} onClick={onClearSelection}>清除选择</button>
87+
</>
88+
) : (
89+
<div style={{ display: "flex", flexDirection: "column", gap: 2, maxHeight: 260, overflowY: "auto" }}>
90+
<button style={{ ...ghostBtn, textAlign: "left" }} disabled={busyMove} onClick={() => onMoveToGroup(null)}>(移出组)</button>
91+
{groups.map((g) => (
92+
<button key={g.id} style={{ ...ghostBtn, textAlign: "left" }} disabled={busyMove} onClick={() => onMoveToGroup(g.id)}>📁 {g.name}</button>
93+
))}
94+
<button style={{ border: "none", background: "none", color: "#6b7280", cursor: "pointer", textAlign: "left" }} onClick={onBack}>← 返回</button>
95+
</div>
96+
)}
97+
</div>
98+
</>
99+
);
100+
}

‎app/src/components/NoteListView.tsx‎

Lines changed: 25 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ import { useNoteListSelection } from "../hooks/useNoteListSelection";
3030
import { useNoteMarquee } from "../hooks/useNoteMarquee";
3131
// 批 0-C2:展示件(列表体 / 顶栏 / 批量栏与选集菜单)——纯透传适配器,逻辑在 hook
3232
import NoteListBody from "./NoteListBody";
33-
import NoteListToolbar, { ghostBtn } from "./NoteListToolbar";
33+
import NoteListToolbar from "./NoteListToolbar";
34+
import { NoteListBatchBar, NoteListBatchContextMenu } from "./NoteListBatchMenu";
3435
import NoteRowContextMenu from "./NoteRowContextMenu";
3536

3637
// 兼容既有导入面(NoteReadingView/NotesPage/parseTags.test 从此解析——v0.15 移厝 utils)
@@ -175,13 +176,14 @@ export default function NoteListView({
175176

176177
{/* 批量操作栏(去勾选框后的批量入口:删除 + 移动到组;选择模式下同样可用) */}
177178
{selection.size > 0 && (
178-
<div style={{ borderTop: "1px solid #e5e7eb", padding: 8, display: "flex", gap: 6, alignItems: "center", background: "#fff" }}>
179-
<span style={{ fontSize: 12, color: "#3730a3" }}>已选 {selection.size} 个</span>
180-
<button data-testid="batch-move-btn" style={ghostBtn} onClick={() => setBatchMenu({ ids: [...selection], x: 0, y: 0 })}>移动到组</button>
181-
<button data-testid="batch-delete-btn" style={{ ...ghostBtn, borderColor: "#fca5a5", color: "#dc2626" }} onClick={() => void (async () => { if (await onBatchDelete([...selection])) clearSelection(); })()}>批量删除</button>
182-
{selectionMode && <button data-testid="batch-done-btn" style={{ ...ghostBtn, color: "#3730a3" }} onClick={exitBatch}>完成</button>}
183-
<button style={{ ...ghostBtn, marginLeft: "auto" }} onClick={clearSelection}>取消</button>
184-
</div>
179+
<NoteListBatchBar
180+
count={selection.size}
181+
selectionMode={selectionMode}
182+
onMoveToGroup={() => setBatchMenu({ ids: [...selection], x: 0, y: 0 })}
183+
onDelete={() => void (async () => { if (await onBatchDelete([...selection])) clearSelection(); })()}
184+
onDone={exitBatch}
185+
onCancel={clearSelection}
186+
/>
185187
)}
186188

187189
{status && <p style={{ padding: 8, fontSize: 12, color: "#dc2626" }}>{status}</p>}
@@ -206,29 +208,22 @@ export default function NoteListView({
206208
/>
207209
)}
208210

209-
{/* 选集批处理菜单(删除/移动到组) */}
211+
{/* 选集批处理菜单(删除/移动到组)——受控:菜单态与 Esc 链在 useNoteListSelection */}
210212
{batchMenu && (
211-
<>
212-
<div style={{ position: "fixed", inset: 0, zIndex: 40 }} onClick={() => { setBatchMenu(null); setBatchMoveOpen(false); }} />
213-
<div data-testid="batch-context-menu" style={{ position: "fixed", zIndex: 41, left: batchMenu.x || 12, top: batchMenu.y || 12, minWidth: 180, background: "#fff", border: "1px solid #e5e7eb", borderRadius: 8, boxShadow: "0 6px 18px rgba(0,0,0,0.12)", padding: 6, fontSize: 12 }}>
214-
<div style={{ padding: "2px 6px", color: "#9ca3af", fontSize: 11 }}>已选 {batchMenu.ids.length} 个</div>
215-
{!batchMoveOpen ? (
216-
<>
217-
<button style={{ ...ghostBtn, width: "100%", marginTop: 4, textAlign: "left" }} disabled={busyMove} onClick={() => setBatchMoveOpen(true)}>📁 移动到组…</button>
218-
<button data-testid="batch-menu-delete" style={{ ...ghostBtn, width: "100%", marginTop: 4, textAlign: "left", borderColor: "#fca5a5", color: "#dc2626" }} disabled={busyMove} onClick={() => void batchDelete()}>删除选中({batchMenu.ids.length})</button>
219-
<button style={{ ...ghostBtn, width: "100%", marginTop: 4, textAlign: "left" }} onClick={() => { setBatchMenu(null); clearSelection(); }}>清除选择</button>
220-
</>
221-
) : (
222-
<div style={{ display: "flex", flexDirection: "column", gap: 2, maxHeight: 260, overflowY: "auto" }}>
223-
<button style={{ ...ghostBtn, textAlign: "left" }} disabled={busyMove} onClick={() => void moveToGroup(batchMenu.ids, null)}>(移出组)</button>
224-
{groups.map((g) => (
225-
<button key={g.id} style={{ ...ghostBtn, textAlign: "left" }} disabled={busyMove} onClick={() => void moveToGroup(batchMenu.ids, g.id)}>📁 {g.name}</button>
226-
))}
227-
<button style={{ border: "none", background: "none", color: "#6b7280", cursor: "pointer", textAlign: "left" }} onClick={() => setBatchMoveOpen(false)}>← 返回</button>
228-
</div>
229-
)}
230-
</div>
231-
</>
213+
<NoteListBatchContextMenu
214+
ids={batchMenu.ids}
215+
x={batchMenu.x}
216+
y={batchMenu.y}
217+
moveOpen={batchMoveOpen}
218+
groups={groups}
219+
busyMove={busyMove}
220+
onClose={() => { setBatchMenu(null); setBatchMoveOpen(false); }}
221+
onOpenMove={() => setBatchMoveOpen(true)}
222+
onDelete={() => void batchDelete()}
223+
onClearSelection={() => { setBatchMenu(null); clearSelection(); }}
224+
onMoveToGroup={(groupId) => void moveToGroup(batchMenu.ids, groupId)}
225+
onBack={() => setBatchMoveOpen(false)}
226+
/>
232227
)}
233228
</div>
234229
);

0 commit comments

Comments
 (0)