Skip to content

Commit dd4dabf

Browse files
committed
refactor(ui): 拆 SessionListPanel 第 6 步 — 选择与批量栏
1 parent 01ed59c commit dd4dabf

2 files changed

Lines changed: 176 additions & 98 deletions

File tree

‎app/src/components/SessionListPanel.tsx‎

Lines changed: 54 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
/**
22
* SessionListPanel — 会话管理台左栏(列表/筛选/批量/搜索,v0.7.1 自 SessionsPage 拆出)。
33
*
4-
* @ai-context: 纯列表域 UI:双模式搜索(标题本地即时过滤/转写内容段搜索)、
5-
* 状态与转化筛选、排序、课程分组折叠、勾选批量操作栏、内联一键转笔记。
6-
* 筛选/排序/选择均为面板本地状态(数据已在 SessionListItem 里,
7-
* 零后端往返);数据获取与转化/删除副作用经回调上抛给 SessionsPage。
8-
* @ai-context: 批 4 交互矩阵:去行内 checkbox → 笔记同款选择模式——「选择」
9-
* 按钮进入(单击行=勾选)/ 再点或 Esc 退出;Ctrl/⌘+单击=加/减单
10-
* 行、Shift+单击=按当前可见列表位置区间(复用 utils/noteSelection
11-
* 纯函数,零笔记域耦合);多选态视觉=靛蓝底 + ✓ 前缀(行级实现
12-
* 在 SessionListRow)。列表变化(筛选/排序/折叠/刷新)自动裁剪
13-
* 选集至当前可见行;批量栏口径=当前可见列表(全选框三态保留)。
14-
* @ai-context: Esc 退出链:行内重命名(输入内 Esc 自吞)→ 右键菜单 → 选择
15-
* 模式/多选态(清选集退出);右键菜单=单行语义(打开详情/重命名/
16-
* 复制标题/转笔记/删除——SessionRowContextMenu 委托父层处理)。
4+
* @ai-context: 纯列表域 UI:三模式搜索(标题本地即时过滤 / 转写内容段搜索 / 画面图内
5+
* 文字检索)、状态与转化筛选、排序、课程分组折叠、勾选批量操作栏、内联
6+
* 一键转笔记。筛选/排序/选择均为本地状态(数据已在 SessionListItem 里,
7+
* 零后端往返);转化/删除副作用经回调上抛给 SessionsPage。
8+
* @ai-context: 批 4 交互矩阵:选择模式「选择」按钮进入(单击行=勾选)/ 再点或 Esc
9+
* 退出;Ctrl/⌘+单击=加/减单行、Shift+单击=按当前可见列表位置区间(复用
10+
* utils/noteSelection 纯函数);多选态视觉=靛蓝底 + ✓ 前缀(行级实现在
11+
* SessionListRow)。列表变化自动裁剪选集至当前可见行;批量栏口径=当前
12+
* 可见列表(全选框三态保留)。
13+
* @ai-context: 批 0-C2 拆分后本文件=**编排层**,消费 utils/sessionEligibility +
14+
* hooks/useSessionSearch(两个 invoke 的落点)· useSessionListView ·
15+
* useSessionSelection(多选态机)· components/SessionSearchBar ·
16+
* SessionSearchHits · SessionListBody · SessionSelectionToolbar。
17+
* Esc 退出链(重命名自吞 → 右键菜单 → 选择模式/多选态)的**监听刻意留在
18+
* 本文件**:全局只能有一个 window keydown,且菜单优先。本层仍持有右键菜单
19+
* 态 · 行内改名 nonce · batchBusy/Ref 与批量转/删(清选集时机不对称)。
1720
*/
1821
import { useEffect, useRef, useState } from "react";
1922
import type { CourseGroup, SessionListItem } from "../types";
@@ -30,6 +33,7 @@ import type { SessionRenameRequest } from "./SessionListRow";
3033
import SessionRowContextMenu from "./SessionRowContextMenu";
3134
import SessionListBody from "./SessionListBody";
3235
import SessionSearchBar from "./SessionSearchBar";
36+
import SessionSelectionToolbar, { SessionSelectionControls } from "./SessionSelectionToolbar";
3337

3438
const btn: React.CSSProperties = { padding: "5px 10px", cursor: "pointer", fontSize: 12 };
3539
const selectStyle: React.CSSProperties = {
@@ -84,10 +88,9 @@ export default function SessionListPanel({
8488
// 行内重命名请求(nonce:同一行连续两次「重命名」也能重启编辑态)
8589
const [renameReq, setRenameReq] = useState<SessionRenameRequest | null>(null);
8690
const renameNonceRef = useRef(0);
87-
// 批 4 审查修复(P3-2):批量操作 pending——state=按钮禁用视觉,ref=同 tick
88-
// 拦截(state 更新异步,连点需 ref 立即生效;与行内改名 busyRef 同模式)。
89-
// Why:原无防护,双击「批量删除」二次提交报"已删除 0 个"(首次已删空选集
90-
// 数据、二次空跑);批量转同理。
91+
// 批 4 审查修复(P3-2):批量 pending——state=按钮禁用视觉,ref=同 tick 拦截
92+
// (state 更新异步,连点需 ref 立即生效)。Why:原无防护,双击「批量删除」二次
93+
// 提交报"已删除 0 个"(首次已删空选集数据、二次空跑);批量转同理。
9194
const [batchBusy, setBatchBusy] = useState<"convert" | "delete" | null>(null);
9295
const batchBusyRef = useRef(false);
9396

@@ -104,9 +107,8 @@ export default function SessionListPanel({
104107

105108
// Esc 退出链:右键菜单 → 选择模式/多选态(清选集退出)。
106109
// (行内重命名输入内已 stopPropagation 自吞 Esc——不在此列)
107-
// ★ 全面板唯一 window keydown:优先级=右键菜单先 return(菜单开着时按 Esc 只关菜单,
108-
// 选集保留)。**不得**把 Esc 挪进 useSessionSelection 另建监听——两个监听会让一次
109-
// Esc 同时关菜单 + 清选集(行为不等价)。
110+
// ★ 全局唯一 window keydown,优先级=右键菜单先 return(菜单开着时按 Esc 只关菜单,选集
111+
// 保留)。**不得**挪进 useSessionSelection 另建监听——那会让一次 Esc 同时关菜单 + 清选集。
110112
useEffect(() => {
111113
const onKey = (e: KeyboardEvent) => {
112114
if (e.key !== "Escape") return;
@@ -123,7 +125,7 @@ export default function SessionListPanel({
123125
setKeyword("");
124126
};
125127

126-
/** 分组折叠切换(列表区组头点击——原内联 setCollapsed 收敛于此,语义不变) */
128+
// 分组折叠切换(列表区组头点击——原内联 setCollapsed 收敛于此,语义不变)
127129
const toggleCollapse = (course: string) => setCollapsed((c) => ({ ...c, [course]: !c[course] }));
128130

129131
/** 批量转:先过滤出可转化集合(已转/进行中/无内容不在其中);pending 防连点
@@ -152,15 +154,20 @@ export default function SessionListPanel({
152154
}
153155
};
154156

155-
const selectModeBtn = (on: boolean): React.CSSProperties => ({
156-
...btn,
157-
fontSize: 11,
158-
borderRadius: 6,
159-
border: on ? "1px solid #4f46e5" : "1px solid #d1d5db",
160-
background: on ? "#eef2ff" : "#fff",
161-
color: on ? "#3730a3" : "#4b5563",
162-
fontWeight: on ? 600 : 400,
163-
});
157+
/** 批量删:**成功后**才清选集(全删或全不删——后端单事务原子);失败保留选集
158+
* 以便重试。与批量转的「转前清选集」**不对称**,不得统一。 */
159+
const runBatchDelete = async () => {
160+
// P3-2 pending 防连点(ref 同 tick 拦截——双击不再二次提交)
161+
if (batchBusyRef.current) return;
162+
batchBusyRef.current = true;
163+
setBatchBusy("delete");
164+
try {
165+
if (await onBatchDelete([...selected])) clearSelection();
166+
} finally {
167+
batchBusyRef.current = false;
168+
setBatchBusy(null);
169+
}
170+
};
164171

165172
return (
166173
<div style={{ width, flexShrink: 0, borderRight: "1px solid #e5e7eb", display: "flex", flexDirection: "column", minWidth: 0 }}>
@@ -174,23 +181,12 @@ export default function SessionListPanel({
174181
⟨
175182
</button>
176183
<div style={{ marginLeft: "auto", display: "flex", gap: 6, alignItems: "center" }}>
177-
{/* 批 4:选择模式进入/退出(再点或 Esc 退出;进入后单击行=勾选) */}
178-
{selectionMode && (
179-
<span
180-
data-testid="session-select-mode-chip"
181-
style={{ fontSize: 10.5, color: "#4f46e5", border: "1px solid #c7d2fe", borderRadius: 10, padding: "0 6px", background: "#eef2ff", lineHeight: "16px", fontWeight: 400 }}
182-
>
183-
选择模式{selected.size > 0 ? `(${selected.size})` : ""}
184-
</span>
185-
)}
186-
<button
187-
data-testid="session-select-mode-btn"
188-
style={selectModeBtn(selectionMode)}
189-
onClick={() => (selectionMode ? exitBatch() : enterSelectionMode())}
190-
title={selectionMode ? "退出选择模式(Esc)" : "进入选择模式:单击会话=勾选(Ctrl/Shift 多选)"}
191-
>
192-
选择
193-
</button>
184+
<SessionSelectionControls
185+
selectionMode={selectionMode}
186+
selectedCount={selected.size}
187+
onEnter={enterSelectionMode}
188+
onExit={exitBatch}
189+
/>
194190
<button
195191
style={{ ...btn, fontSize: 11, borderRadius: 6, border: grouped ? "1px solid #0d9488" : "1px solid #e5e7eb", background: grouped ? "#ccfbf1" : "#fff" }}
196192
onClick={onToggleGrouped}
@@ -269,58 +265,18 @@ export default function SessionListPanel({
269265
onOpenNote={onOpenNote}
270266
/>
271267

272-
{/* 批量操作栏(出现后出现;段搜索命中视图隐藏——避免对不可见列表误操作) */}
268+
{/* 批量操作栏(段搜索命中视图隐藏——避免对不可见列表误操作)。batchBusy/Ref 与两条
269+
动线留在面板:批量转会先清空选集使本栏卸载,状态随组件走会让连点拦截失效。 */}
273270
{!hits && !ocrHits && selected.size > 0 && (
274-
<div style={{ borderTop: "1px solid #e5e7eb", padding: 8, display: "flex", gap: 6, alignItems: "center", background: "#fff" }}>
275-
{/* 批 4 审查修复(P3-1):全选框口径 = 当前可见行序 visibleOrder——
276-
平铺=筛选后序、分组=展开组顺次。原 filtered 口径在折叠组时把
277-
不可见行也纳入全选(勾选后随即被裁剪 effect 清掉,计数误导且
278-
折叠内容被误批量操作),与区间/自动裁剪同基准。三态 indeterminate
279-
用回调 ref 每渲染刷新——部分选中显示横杠 */}
280-
<input
281-
type="checkbox"
282-
data-testid="session-select-all"
283-
ref={(el) => {
284-
if (el) el.indeterminate = selected.size > 0 && selected.size < visibleOrder.length;
285-
}}
286-
checked={selected.size === visibleOrder.length && visibleOrder.length > 0}
287-
onChange={toggleAllVisible}
288-
style={{ cursor: "pointer", flexShrink: 0 }}
289-
title="全选当前可见的会话(折叠组行不含在内)"
290-
/>
291-
<span style={{ fontSize: 12, color: "#374151" }}>已选 {selected.size} 个</span>
292-
<button
293-
style={{ ...btn, fontSize: 11, borderRadius: 6, border: "1px solid #0d9488", background: "#f0fdfa", color: "#0f766e", fontWeight: 600, opacity: batchBusy ? 0.55 : 1 }}
294-
disabled={batchBusy !== null}
295-
onClick={() => void runBatchConvert()}
296-
title={batchBusy ? "批量转处理中…" : undefined}
297-
>
298-
批量转笔记
299-
</button>
300-
<button
301-
style={{ ...btn, fontSize: 11, borderRadius: 6, border: "1px solid #fca5a5", color: "#dc2626", opacity: batchBusy ? 0.55 : 1 }}
302-
disabled={batchBusy !== null}
303-
onClick={() => void (async () => {
304-
// P3-2 pending 防连点(ref 同 tick 拦截——双击不再二次提交)
305-
if (batchBusyRef.current) return;
306-
batchBusyRef.current = true;
307-
setBatchBusy("delete");
308-
try {
309-
// 成功后清选集(全删或全不删——后端单事务原子,无半删计数)
310-
if (await onBatchDelete([...selected])) clearSelection();
311-
} finally {
312-
batchBusyRef.current = false;
313-
setBatchBusy(null);
314-
}
315-
})()}
316-
title={batchBusy ? "批量删除处理中…" : undefined}
317-
>
318-
批量删除
319-
</button>
320-
<button style={{ ...btn, marginLeft: "auto", fontSize: 11 }} onClick={clearSelection}>
321-
取消
322-
</button>
323-
</div>
271+
<SessionSelectionToolbar
272+
selectedCount={selected.size}
273+
visibleCount={visibleOrder.length}
274+
batchBusy={batchBusy}
275+
onToggleAll={toggleAllVisible}
276+
onConvert={() => void runBatchConvert()}
277+
onDelete={() => void runBatchDelete()}
278+
onCancel={clearSelection}
279+
/>
324280
)}
325281

326282
{/* 行右键菜单(单行语义;危险项红字) */}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* SessionSelectionToolbar — 会话选择模式控件与批量操作栏(批 0-C2 自 SessionListPanel 拆出)。
3+
*
4+
* @ai-context: 批 4 交互矩阵的两处 UI:① 具名导出 `SessionSelectionControls` = 列表头部
5+
* 右侧的「选择」按钮 + 选择模式 chip(点击进入/退出;Esc 由面板统一处理);
6+
* ② 默认导出 = 列表底部批量栏(三态全选框 / 已选计数 / 批量转笔记 /
7+
* 批量删除 / 取消)。两者的 DOM 位置与拆前一一对应(Controls 嵌在头部
8+
* flex 行内,Toolbar 紧随列表区),零 className、全部内联样式。
9+
* @ai-context 边界(★ 均为等价性红线,不得"顺手统一"):
10+
* ① 全选框三态口径 = **可见行数 visibleCount**(visibleOrder.length,
11+
* 不是 filtered)——折叠组隐藏行不在内,这是 P3-1 的修复点;
12+
* ② 批量转「转前清选集」、批量删除「成功后清选集」——**不对称**,
13+
* 两条动线(含 pending 与清选集时序)由面板的 runBatchConvert /
14+
* runBatchDelete 持有,本组件只上报点击;
15+
* ③ batchBusy(state=禁用视觉)与 batchBusyRef(ref=同 tick 防连点)
16+
* 必须同属一处(面板)——若把它搬进本组件,批量转清空选集会让
17+
* 本组件卸载,pending 期间的连点拦截随之失效;
18+
* ④ 批量栏的渲染条件(!hits && !ocrHits && 选集非空)由面板决定,
19+
* 本组件不做可见性判断。
20+
* @ai-context 注意:data-testid `session-select-mode-chip` / `session-select-all`
21+
* 与文案「批量转笔记」「批量删除」「已选 N 个」是测试锚点,逐字保留。
22+
*/
23+
const btn: React.CSSProperties = { padding: "5px 10px", cursor: "pointer", fontSize: 12 };
24+
25+
interface ControlsProps {
26+
selectionMode: boolean;
27+
/** 多选集大小(chip 仅在 >0 时显示计数) */
28+
selectedCount: number;
29+
onEnter: () => void;
30+
onExit: () => void;
31+
}
32+
33+
/** 头部:选择模式 chip + 「选择」按钮(再点或 Esc 退出;进入后单击行=勾选) */
34+
export function SessionSelectionControls({ selectionMode, selectedCount, onEnter, onExit }: ControlsProps) {
35+
const selectModeBtn = (on: boolean): React.CSSProperties => ({
36+
...btn,
37+
fontSize: 11,
38+
borderRadius: 6,
39+
border: on ? "1px solid #4f46e5" : "1px solid #d1d5db",
40+
background: on ? "#eef2ff" : "#fff",
41+
color: on ? "#3730a3" : "#4b5563",
42+
fontWeight: on ? 600 : 400,
43+
});
44+
return (
45+
<>
46+
{/* 批 4:选择模式进入/退出(再点或 Esc 退出;进入后单击行=勾选) */}
47+
{selectionMode && (
48+
<span
49+
data-testid="session-select-mode-chip"
50+
style={{ fontSize: 10.5, color: "#4f46e5", border: "1px solid #c7d2fe", borderRadius: 10, padding: "0 6px", background: "#eef2ff", lineHeight: "16px", fontWeight: 400 }}
51+
>
52+
选择模式{selectedCount > 0 ? `(${selectedCount})` : ""}
53+
</span>
54+
)}
55+
<button
56+
data-testid="session-select-mode-btn"
57+
style={selectModeBtn(selectionMode)}
58+
onClick={() => (selectionMode ? onExit() : onEnter())}
59+
title={selectionMode ? "退出选择模式(Esc)" : "进入选择模式:单击会话=勾选(Ctrl/Shift 多选)"}
60+
>
61+
选择
62+
</button>
63+
</>
64+
);
65+
}
66+
67+
interface Props {
68+
selectedCount: number;
69+
/** 当前可见行数(visibleOrder.length——全选三态口径,非 filtered) */
70+
visibleCount: number;
71+
batchBusy: "convert" | "delete" | null;
72+
onToggleAll: () => void;
73+
onConvert: () => void;
74+
onDelete: () => void;
75+
onCancel: () => void;
76+
}
77+
78+
/** 底部批量操作栏(段搜索命中视图隐藏——避免对不可见列表误操作,由面板条件控制) */
79+
export default function SessionSelectionToolbar({
80+
selectedCount, visibleCount, batchBusy, onToggleAll, onConvert, onDelete, onCancel,
81+
}: Props) {
82+
return (
83+
<div style={{ borderTop: "1px solid #e5e7eb", padding: 8, display: "flex", gap: 6, alignItems: "center", background: "#fff" }}>
84+
{/* 批 4 审查修复(P3-1):全选框口径 = 当前可见行序 visibleOrder——
85+
平铺=筛选后序、分组=展开组顺次。原 filtered 口径在折叠组时把
86+
不可见行也纳入全选(勾选后随即被裁剪 effect 清掉,计数误导且
87+
折叠内容被误批量操作),与区间/自动裁剪同基准。三态 indeterminate
88+
用回调 ref 每渲染刷新——部分选中显示横杠 */}
89+
<input
90+
type="checkbox"
91+
data-testid="session-select-all"
92+
ref={(el) => {
93+
if (el) el.indeterminate = selectedCount > 0 && selectedCount < visibleCount;
94+
}}
95+
checked={selectedCount === visibleCount && visibleCount > 0}
96+
onChange={onToggleAll}
97+
style={{ cursor: "pointer", flexShrink: 0 }}
98+
title="全选当前可见的会话(折叠组行不含在内)"
99+
/>
100+
<span style={{ fontSize: 12, color: "#374151" }}>已选 {selectedCount} 个</span>
101+
<button
102+
style={{ ...btn, fontSize: 11, borderRadius: 6, border: "1px solid #0d9488", background: "#f0fdfa", color: "#0f766e", fontWeight: 600, opacity: batchBusy ? 0.55 : 1 }}
103+
disabled={batchBusy !== null}
104+
onClick={onConvert}
105+
title={batchBusy ? "批量转处理中…" : undefined}
106+
>
107+
批量转笔记
108+
</button>
109+
<button
110+
style={{ ...btn, fontSize: 11, borderRadius: 6, border: "1px solid #fca5a5", color: "#dc2626", opacity: batchBusy ? 0.55 : 1 }}
111+
disabled={batchBusy !== null}
112+
onClick={onDelete}
113+
title={batchBusy ? "批量删除处理中…" : undefined}
114+
>
115+
批量删除
116+
</button>
117+
<button style={{ ...btn, marginLeft: "auto", fontSize: 11 }} onClick={onCancel}>
118+
取消
119+
</button>
120+
</div>
121+
);
122+
}

0 commit comments

Comments
 (0)