|
| 1 | +/** |
| 2 | + * SessionListBody — 会话列表区视图(批 0-C2 自 SessionListPanel 拆出)。 |
| 3 | + * |
| 4 | + * @ai-context: 列表容器(flex:1 可滚动)+ 全部分支渲染:加载态 / 空态 / 检索命中 |
| 5 | + * (委托 SessionSearchHits)/ 课程分组折叠 / 筛选无结果(含「清除筛选」 |
| 6 | + * 入口)/ 平铺行。`renderRow` 行工厂一并下沉——行点击语义由父层的 |
| 7 | + * useSessionSelection 决定(onRowOpen / onRowModifier 原样转发), |
| 8 | + * 行内编辑与多选视觉仍在 SessionListRow。 |
| 9 | + * @ai-context 边界(分支顺序=优先级,不得重排): |
| 10 | + * ① 加载态与空态是**独立**的 `&&` 块,先于分支链;空态抑制条件含 |
| 11 | + * `!hits`(检索中不显示"暂无会话"); |
| 12 | + * ② 命中视图(hits 或 ocrHits)**接管**整个列表区——分组与平铺都不渲染; |
| 13 | + * ③ 分组分支要求 `grouped && groupedView` 同时成立(groups=null 时回落平铺); |
| 14 | + * ④ 无匹配提示要求 `itemCount > 0 && filtered.length === 0` |
| 15 | + * (items 为空时走空态,不显示"无匹配会话"); |
| 16 | + * ⑤ canConvert 走 utils/sessionEligibility 纯函数(与批量栏同口径)。 |
| 17 | + */ |
| 18 | +import type { CourseGroup, OcrBlockHit, SegmentHit, SessionListItem } from "../types"; |
| 19 | +import { isSessionConvertible } from "../utils/sessionEligibility"; |
| 20 | +import SessionListRow from "./SessionListRow"; |
| 21 | +import type { SessionRenameRequest } from "./SessionListRow"; |
| 22 | +import SessionSearchHits from "./SessionSearchHits"; |
| 23 | + |
| 24 | +const btn: React.CSSProperties = { padding: "5px 10px", cursor: "pointer", fontSize: 12 }; |
| 25 | + |
| 26 | +interface Props { |
| 27 | + loading: boolean; |
| 28 | + /** items.length(空态判定——不传整个列表,避免视图层再遍历) */ |
| 29 | + itemCount: number; |
| 30 | + hits: SegmentHit[] | null; |
| 31 | + ocrHits: OcrBlockHit[] | null; |
| 32 | + searchKw: string; |
| 33 | + grouped: boolean; |
| 34 | + groupedView: CourseGroup[] | null; |
| 35 | + collapsed: Record<string, boolean>; |
| 36 | + onToggleCollapse: (course: string) => void; |
| 37 | + filtered: SessionListItem[]; |
| 38 | + onClearFilters: () => void; |
| 39 | + onOpenDetail: (id: number, targetSegId?: number) => void; |
| 40 | + // ── 行渲染下发项(原面板 renderRow 的入参) ── |
| 41 | + openSessionId: number | null; |
| 42 | + selected: ReadonlySet<number>; |
| 43 | + renameReq: SessionRenameRequest | null; |
| 44 | + onRenameEnd: () => void; |
| 45 | + onSessionRenamed: (id: number) => void; |
| 46 | + showToast: (msg: string, kind: "ok" | "err") => void; |
| 47 | + onRowOpen: (item: SessionListItem) => void; |
| 48 | + onRowModifier: (item: SessionListItem, ctrl: boolean, shift: boolean) => void; |
| 49 | + onRowContextMenu: (e: React.MouseEvent, item: SessionListItem) => void; |
| 50 | + onConvert: (item: SessionListItem) => void; |
| 51 | + onOpenNote: (noteId: number) => void; |
| 52 | +} |
| 53 | + |
| 54 | +export default function SessionListBody({ |
| 55 | + loading, itemCount, hits, ocrHits, searchKw, grouped, groupedView, collapsed, onToggleCollapse, |
| 56 | + filtered, onClearFilters, onOpenDetail, openSessionId, selected, renameReq, onRenameEnd, |
| 57 | + onSessionRenamed, showToast, onRowOpen, onRowModifier, onRowContextMenu, onConvert, onOpenNote, |
| 58 | +}: Props) { |
| 59 | + /** 行渲染(平铺/分组共用);行内编辑与多选视觉在 SessionListRow */ |
| 60 | + const renderRow = (item: SessionListItem) => ( |
| 61 | + <SessionListRow |
| 62 | + key={item.session.id} |
| 63 | + item={item} |
| 64 | + isOpen={openSessionId === item.session.id} |
| 65 | + multiSelected={selected.has(item.session.id)} |
| 66 | + canConvert={isSessionConvertible(item)} |
| 67 | + renameRequest={renameReq} |
| 68 | + onRenameEnd={onRenameEnd} |
| 69 | + onRenamed={(id) => onSessionRenamed(id)} |
| 70 | + showToast={showToast} |
| 71 | + onOpen={onRowOpen} |
| 72 | + onModifierClick={onRowModifier} |
| 73 | + onContextMenu={onRowContextMenu} |
| 74 | + onConvert={(it) => onConvert(it)} |
| 75 | + onOpenNote={onOpenNote} |
| 76 | + /> |
| 77 | + ); |
| 78 | + |
| 79 | + return ( |
| 80 | + <div style={{ flex: 1, minHeight: 0, overflowY: "auto" }}> |
| 81 | + {loading && itemCount === 0 && ( |
| 82 | + <p style={{ fontSize: 12, color: "#9ca3af", padding: 16, textAlign: "center" }}>加载中…</p> |
| 83 | + )} |
| 84 | + {!loading && itemCount === 0 && !hits && ( |
| 85 | + <p style={{ fontSize: 12, color: "#9ca3af", padding: 16, textAlign: "center" }}> |
| 86 | + 暂无会话,去「课堂助手」开始实时捕获 |
| 87 | + </p> |
| 88 | + )} |
| 89 | + {hits || ocrHits ? ( |
| 90 | + <SessionSearchHits hits={hits} ocrHits={ocrHits} searchKw={searchKw} onOpenDetail={onOpenDetail} /> |
| 91 | + ) : grouped && groupedView ? ( |
| 92 | + /* 课程分组(折叠 + 组内筛选排序) */ |
| 93 | + groupedView.map((g) => ( |
| 94 | + <div key={g.course}> |
| 95 | + <div |
| 96 | + onClick={() => onToggleCollapse(g.course)} |
| 97 | + style={{ padding: "8px 14px", fontSize: 12, fontWeight: 600, color: "#0f766e", cursor: "pointer", background: "#f8fafc", borderBottom: "1px solid #e5e7eb" }} |
| 98 | + > |
| 99 | + {collapsed[g.course] ? "▸" : "▾"} {g.course}({g.sessions.length}) |
| 100 | + </div> |
| 101 | + {!collapsed[g.course] && g.sessions.map(renderRow)} |
| 102 | + </div> |
| 103 | + )) |
| 104 | + ) : itemCount > 0 && filtered.length === 0 ? ( |
| 105 | + /* 筛选无结果:给出清除入口 */ |
| 106 | + <p style={{ fontSize: 12, color: "#9ca3af", padding: 16, textAlign: "center" }}> |
| 107 | + 无匹配会话{" "} |
| 108 | + <button style={{ ...btn, fontSize: 11 }} onClick={onClearFilters}> |
| 109 | + 清除筛选 |
| 110 | + </button> |
| 111 | + </p> |
| 112 | + ) : ( |
| 113 | + filtered.map(renderRow) |
| 114 | + )} |
| 115 | + </div> |
| 116 | + ); |
| 117 | +} |
0 commit comments