Skip to content

Commit 8773a66

Browse files
committed
refactor(ui): 拆 SessionListPanel 第 3 步 — 视图模型 hook
1 parent f012968 commit 8773a66

3 files changed

Lines changed: 119 additions & 70 deletions

File tree

‎app/src/components/SessionListPanel.tsx‎

Lines changed: 12 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,17 @@
1515
* 模式/多选态(清选集退出);右键菜单=单行语义(打开详情/重命名/
1616
* 复制标题/转笔记/删除——SessionRowContextMenu 委托父层处理)。
1717
*/
18-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
18+
import { useEffect, useRef, useState } from "react";
1919
import { invoke } from "@tauri-apps/api/core";
20-
import type { CourseGroup, OcrBlockHit, SegmentHit, Session, SessionListItem } from "../types";
20+
import type { CourseGroup, OcrBlockHit, SegmentHit, SessionListItem } from "../types";
2121
import { fmtMs } from "../utils/fmt";
2222
import { isSessionConvertible } from "../utils/sessionEligibility";
23+
import {
24+
useSessionListView,
25+
type ConvertedFilter,
26+
type SortBy,
27+
type StatusFilter,
28+
} from "../hooks/useSessionListView";
2329
import { useSessionSelection } from "../hooks/useSessionSelection";
2430
import SessionListRow from "./SessionListRow";
2531
import type { SessionRenameRequest } from "./SessionListRow";
@@ -30,9 +36,6 @@ const selectStyle: React.CSSProperties = {
3036
fontSize: 12, padding: "4px 6px", border: "1px solid #e5e7eb", borderRadius: 6, background: "#fff",
3137
};
3238

33-
type StatusFilter = "all" | "recording" | "finished" | "failed";
34-
type ConvertedFilter = "all" | "todo" | "done";
35-
type SortBy = "time-desc" | "time-asc" | "duration";
3639
// TD-2026-08-19-E 清偿:三模式搜索——标题(本地)/ 内容(段搜索)/ 画面(图内文字检索)
3740
type SearchMode = "title" | "content" | "ocr";
3841

@@ -124,69 +127,10 @@ export default function SessionListPanel({
124127
}
125128
};
126129

127-
/** 筛选谓词(列表与课程分组共用) */
128-
const matchFilters = useCallback(
129-
(item: SessionListItem) => {
130-
if (filterStatus !== "all" && item.session.status !== filterStatus) return false;
131-
if (filterConverted === "todo" && (item.hasNote || !item.hasContent)) return false;
132-
if (filterConverted === "done" && !item.hasNote) return false;
133-
const kw = keyword.trim().toLowerCase();
134-
if (kw) {
135-
const hay = `${item.session.title} ${item.session.source_window ?? ""}`.toLowerCase();
136-
if (!hay.includes(kw)) return false;
137-
}
138-
return true;
139-
},
140-
[filterStatus, filterConverted, keyword],
141-
);
142-
143-
/** 本地排序(时间倒序为后端默认序,保持稳定不重排) */
144-
const sorted = useMemo(() => {
145-
const list = [...items];
146-
const endOf = (s: Session) => s.ended_at ?? Math.floor(Date.now() / 1000);
147-
if (sortBy === "time-asc") {
148-
list.sort((a, b) => a.session.started_at - b.session.started_at);
149-
} else if (sortBy === "duration") {
150-
list.sort(
151-
(a, b) =>
152-
(endOf(b.session) - b.session.started_at) - (endOf(a.session) - a.session.started_at),
153-
);
154-
}
155-
return list;
156-
}, [items, sortBy]);
157-
158-
const filtered = useMemo(() => sorted.filter(matchFilters), [sorted, matchFilters]);
159-
160-
/** 课程分组视图(组内同样应用筛选 + 排序) */
161-
const groupedView = useMemo(() => {
162-
if (!groups) return null;
163-
const endOf = (s: Session) => s.ended_at ?? Math.floor(Date.now() / 1000);
164-
return groups
165-
.map((g) => ({
166-
...g,
167-
sessions: g.sessions.filter(matchFilters).sort((a, b) => {
168-
if (sortBy === "time-asc") return a.session.started_at - b.session.started_at;
169-
if (sortBy === "duration")
170-
return (endOf(b.session) - b.session.started_at) - (endOf(a.session) - a.session.started_at);
171-
return 0; // time-desc:后端已按新→旧
172-
}),
173-
}))
174-
.filter((g) => g.sessions.length > 0);
175-
}, [groups, matchFilters, sortBy]);
176-
177-
// 当前可见行序(批 4 区间语义基准):分组视图=展开组顺次;平铺=筛选后序。
178-
// 折叠组行不可见——不参与区间与选集裁剪(与笔记树语义一致)
179-
const visibleOrder = useMemo(() => {
180-
if (grouped && groupedView) {
181-
const out: number[] = [];
182-
for (const g of groupedView) {
183-
if (collapsed[g.course]) continue;
184-
for (const i of g.sessions) out.push(i.session.id);
185-
}
186-
return out;
187-
}
188-
return filtered.map((i) => i.session.id);
189-
}, [grouped, groupedView, filtered, collapsed]);
130+
// ── 视图模型(批 0-C2 拆至 hooks/useSessionListView)──
131+
const { filtered, groupedView, visibleOrder } = useSessionListView({
132+
items, groups, grouped, collapsed, filterStatus, filterConverted, keyword, sortBy,
133+
});
190134

191135
// ── 多选/选择模式状态机(批 0-C2 拆至 hooks/useSessionSelection)──
192136
const {
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* useSessionListView — 会话列表视图模型(批 0-C2 自 SessionListPanel 拆出)。
3+
*
4+
* @ai-context: 纯派生链、零副作用:items/groups + 筛选排序态 → sorted → filtered →
5+
* groupedView → visibleOrder。面板只消费结果(渲染与选择域基准),
6+
* 不再持有推导逻辑。筛选/排序均为面板本地状态——数据已在
7+
* SessionListItem 里,零后端往返。
8+
* @ai-context 边界:
9+
* ① time-desc 保持后端序**不重排**(后端已按新→旧返回);
10+
* ② endOf 在 ended_at==null 时取 Date.now() —— 「时长」排序对**进行中**
11+
* 会话每次求值结果可能不同(既有事实,本批只搬家不优化);
12+
* ③ groupedView 组内同样应用筛选 + 排序,空组剔除;
13+
* ④ visibleOrder 必须保持 useMemo:它是 Shift 区间、选集裁剪、全选三态
14+
* 的唯一基准,跨 hook 传递时**不得** .map() 再包装(依赖每帧变化会让
15+
* 裁剪 effect 每帧空跑)——折叠组隐藏行不在序内(与笔记树同语义)。
16+
*/
17+
import { useCallback, useMemo } from "react";
18+
import type { CourseGroup, Session, SessionListItem } from "../types";
19+
20+
export type StatusFilter = "all" | "recording" | "finished" | "failed";
21+
export type ConvertedFilter = "all" | "todo" | "done";
22+
export type SortBy = "time-desc" | "time-asc" | "duration";
23+
24+
interface Options {
25+
items: SessionListItem[];
26+
groups: CourseGroup[] | null;
27+
grouped: boolean;
28+
/** 分组折叠表(course → 是否折叠);折叠组行不进 visibleOrder */
29+
collapsed: Record<string, boolean>;
30+
filterStatus: StatusFilter;
31+
filterConverted: ConvertedFilter;
32+
/** 标题即时过滤关键词(搜索域:标题 + 来源窗口) */
33+
keyword: string;
34+
sortBy: SortBy;
35+
}
36+
37+
export function useSessionListView({
38+
items, groups, grouped, collapsed, filterStatus, filterConverted, keyword, sortBy,
39+
}: Options) {
40+
/** 筛选谓词(列表与课程分组共用) */
41+
const matchFilters = useCallback(
42+
(item: SessionListItem) => {
43+
if (filterStatus !== "all" && item.session.status !== filterStatus) return false;
44+
if (filterConverted === "todo" && (item.hasNote || !item.hasContent)) return false;
45+
if (filterConverted === "done" && !item.hasNote) return false;
46+
const kw = keyword.trim().toLowerCase();
47+
if (kw) {
48+
const hay = `${item.session.title} ${item.session.source_window ?? ""}`.toLowerCase();
49+
if (!hay.includes(kw)) return false;
50+
}
51+
return true;
52+
},
53+
[filterStatus, filterConverted, keyword],
54+
);
55+
56+
/** 本地排序(时间倒序为后端默认序,保持稳定不重排) */
57+
const sorted = useMemo(() => {
58+
const list = [...items];
59+
const endOf = (s: Session) => s.ended_at ?? Math.floor(Date.now() / 1000);
60+
if (sortBy === "time-asc") {
61+
list.sort((a, b) => a.session.started_at - b.session.started_at);
62+
} else if (sortBy === "duration") {
63+
list.sort(
64+
(a, b) =>
65+
(endOf(b.session) - b.session.started_at) - (endOf(a.session) - a.session.started_at),
66+
);
67+
}
68+
return list;
69+
}, [items, sortBy]);
70+
71+
const filtered = useMemo(() => sorted.filter(matchFilters), [sorted, matchFilters]);
72+
73+
/** 课程分组视图(组内同样应用筛选 + 排序) */
74+
const groupedView = useMemo(() => {
75+
if (!groups) return null;
76+
const endOf = (s: Session) => s.ended_at ?? Math.floor(Date.now() / 1000);
77+
return groups
78+
.map((g) => ({
79+
...g,
80+
sessions: g.sessions.filter(matchFilters).sort((a, b) => {
81+
if (sortBy === "time-asc") return a.session.started_at - b.session.started_at;
82+
if (sortBy === "duration")
83+
return (endOf(b.session) - b.session.started_at) - (endOf(a.session) - a.session.started_at);
84+
return 0; // time-desc:后端已按新→旧
85+
}),
86+
}))
87+
.filter((g) => g.sessions.length > 0);
88+
}, [groups, matchFilters, sortBy]);
89+
90+
// 当前可见行序(批 4 区间语义基准):分组视图=展开组顺次;平铺=筛选后序。
91+
// 折叠组行不可见——不参与区间与选集裁剪(与笔记树语义一致)
92+
const visibleOrder = useMemo(() => {
93+
if (grouped && groupedView) {
94+
const out: number[] = [];
95+
for (const g of groupedView) {
96+
if (collapsed[g.course]) continue;
97+
for (const i of g.sessions) out.push(i.session.id);
98+
}
99+
return out;
100+
}
101+
return filtered.map((i) => i.session.id);
102+
}, [grouped, groupedView, filtered, collapsed]);
103+
104+
return { filtered, groupedView, visibleOrder };
105+
}

0 commit comments

Comments
 (0)