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 */
1821import { useEffect , useRef , useState } from "react" ;
1922import type { CourseGroup , SessionListItem } from "../types" ;
@@ -30,6 +33,7 @@ import type { SessionRenameRequest } from "./SessionListRow";
3033import SessionRowContextMenu from "./SessionRowContextMenu" ;
3134import SessionListBody from "./SessionListBody" ;
3235import SessionSearchBar from "./SessionSearchBar" ;
36+ import SessionSelectionToolbar , { SessionSelectionControls } from "./SessionSelectionToolbar" ;
3337
3438const btn : React . CSSProperties = { padding : "5px 10px" , cursor : "pointer" , fontSize : 12 } ;
3539const 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 { /* 行右键菜单(单行语义;危险项红字) */ }
0 commit comments