Skip to content

Commit d548522

Browse files
committed
refactor(ui): 拆 NoteListView 第 1 步 — 节模型纯函数层
1 parent 14b8aaa commit d548522

2 files changed

Lines changed: 129 additions & 72 deletions

File tree

‎app/src/components/NoteListView.tsx‎

Lines changed: 12 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ import { paletteHex } from "../utils/colorPalette";
2222
import type { ThemeMode } from "../utils/colorPalette";
2323
import { emptySelection, rangeSelection, toggleSelection } from "../utils/noteSelection";
2424
// REQ-315:scope 内 置顶→手排→自动 排序纯函数 + 显式移动(树视图 pin 生效)
25-
import { dropNotesIntoOrder, orderScopeNotes, shiftNoteOrder } from "../utils/noteOrder";
26-
// REQ-315:组展示序纯函数(树组头排序与组侧栏同规则)
27-
import { orderGroups } from "../utils/groupOrder";
25+
import { dropNotesIntoOrder, shiftNoteOrder } from "../utils/noteOrder";
26+
// 批 0-C2:展示节/可见序纯函数层(含 scope 键与裸折叠键派生——纯逻辑与副作用分离)
27+
import { buildSections, buildVisibleOrder, isTreeMode, manualBaseIds, scopeKey } from "../utils/noteSectionModel";
2828
import NoteListRow from "./NoteListRow";
2929
import NoteTreeSection from "./NoteTreeSection";
3030
import NoteRowContextMenu from "./NoteRowContextMenu";
@@ -71,22 +71,11 @@ interface Props {
7171
refreshToken?: number;
7272
}
7373

74-
/** scope 键(组/null=未分组) */
75-
const scopeKey = (groupId: number | null): string => (groupId == null ? "none" : `g:${groupId}`);
76-
7774
/** 读组折叠记忆(localStorage 损坏/无 → 默认展开) */
7875
function readGroupFold(key: string): boolean {
7976
try { return window.localStorage.getItem(`notes:group-fold:${key}`) === "1"; } catch { return false; }
8077
}
8178

82-
interface SectionData {
83-
scope: string;
84-
groupId: number | null;
85-
title: string;
86-
accent: string;
87-
items: Note[];
88-
}
89-
9079
export default function NoteListView({
9180
width = 320, notes, groups = [], groupFilter = null, onGroupFilterChange,
9281
keyword, tagFilter, sortMode, allTags, selectedId, status,
@@ -184,7 +173,7 @@ export default function NoteListView({
184173
// ── 分组树数据(同 v0.15 结构)——可见序统一从本结构生成 ──
185174
// Why 不再有分桶 memo:旧 `grouped` 是死载荷——仅被当作 treeMode 的第二真值,
186175
// 其 ungrouped/byGroup 从未被读取(真分桶一直在 sections 内重算);批 0-C2 去重
187-
const treeMode = keyword.trim() === "" && tagFilter === null && sortMode === "updated-desc";
176+
const treeMode = isTreeMode(keyword, tagFilter, sortMode);
188177

189178
// 折叠初始值(沿用 v0.15)
190179
useEffect(() => {
@@ -205,56 +194,15 @@ export default function NoteListView({
205194
} catch { /* 隐私模式 */ }
206195
}, [groupFolds]);
207196

208-
/**
209-
* scope 展示序(REQ-315):置顶区(updated_at 降序)→ 手排 seq → 自动区;
210-
* 平铺(scope=flat)保持后端排序原样(排序模式语义在后端 list_notes——
211-
* 平铺是过滤结果非完整 scope,不做 scope 级重排)。
212-
*/
213-
const orderSectionItems = useCallback(
214-
(items: Note[], scope: string): Note[] =>
215-
scope === "flat" ? items : orderScopeNotes(items, manualOrders[scope]),
216-
[manualOrders],
197+
// 显示节(树/平铺)——节内展示序/分桶/accent 全在 utils/noteSectionModel.buildSections
198+
// deps 保留 groupFolds:函数体不读它,但移除会改变重算次数(本批只搬家不优化)
199+
const sections = useMemo(
200+
() => buildSections({ notes, groups, groupFilter, theme, treeMode, manualOrders, groupOrderRows }),
201+
[treeMode, groups, notes, groupFolds, manualOrders, theme, groupFilter, groupOrderRows],
217202
);
218203

219-
// 显示节(树/平铺)→ sections(可见序)
220-
const sections: SectionData[] = useMemo(() => {
221-
const mk = (scope: string, groupId: number | null, title: string, accent: string, items: Note[]): SectionData =>
222-
({ scope, groupId, title, accent, items: orderSectionItems(items, scope) });
223-
const out: SectionData[] = [];
224-
if (treeMode) {
225-
const ungrouped: Note[] = [];
226-
const byGroup = new Map<number, Note[]>();
227-
for (const n of notes) {
228-
if (n.group_id == null) ungrouped.push(n);
229-
else { const a = byGroup.get(n.group_id) ?? []; a.push(n); byGroup.set(n.group_id, a); }
230-
}
231-
// 折叠只影响 body(folded prop)——组头必须常驻(chevron 再点可展开)
232-
if (ungrouped.length > 0 || groupFilter === null) {
233-
out.push(mk("none", null, "未分组", paletteHex(null, theme), ungrouped));
234-
}
235-
// REQ-315:组头序与组侧栏同规则(置顶→手排→自动)——组置顶在树面可见生效
236-
const orderedGroups = orderGroups(groups, groupOrderRows);
237-
for (const g of orderedGroups) {
238-
const items = byGroup.get(g.id) ?? [];
239-
if (items.length === 0) continue;
240-
out.push(mk(scopeKey(g.id), g.id, g.name, paletteHex(g.color ?? null, theme), items));
241-
}
242-
} else {
243-
out.push(mk("flat", null, "", paletteHex(null, theme), notes));
244-
}
245-
return out;
246-
}, [treeMode, groups, notes, groupFolds, orderSectionItems, theme, groupFilter, groupOrderRows]);
247-
248204
// 可见序(L1 审查:折叠组行不参与区间/划选——与渲染可见一致;折叠组头仍在)
249-
const visibleOrder = useMemo(() => {
250-
const out: number[] = [];
251-
for (const sec of sections) {
252-
const key = sec.groupId == null ? "none" : String(sec.groupId);
253-
if (groupFolds[key] === true) continue; // 折叠=行不可见,排除出选择语义
254-
for (const n of sec.items) out.push(n.id);
255-
}
256-
return out;
257-
}, [sections, groupFolds]);
205+
const visibleOrder = useMemo(() => buildVisibleOrder(sections, groupFolds), [sections, groupFolds]);
258206
useEffect(() => { visibleIdsRef.current = visibleOrder; }, [visibleOrder]);
259207

260208
// ── 行交互 ──
@@ -287,16 +235,8 @@ export default function NoteListView({
287235
}
288236
}, [anchor]);
289237

290-
/**
291-
* scope 手动底序(REQ-315):可见展示序去掉置顶区——置顶笔记由 pin 列置顶区
292-
* 表达(按更新时间定序),不占手动位;快照只写本子序列。置顶区外的笔记
293-
* (含新笔记/未置顶)都在底序内——显式移动即整序快照(自动组首移转手排)。
294-
*/
295-
const manualBaseOf = useCallback((scope: string): number[] | null => {
296-
const section = sections.find((s) => s.scope === scope);
297-
if (!section) return null;
298-
return section.items.filter((n) => n.pin !== 1).map((n) => n.id);
299-
}, [sections]);
238+
/** scope 手动底序(REQ-315):可见展示序去掉置顶区(快照只写本子序列)——纯函数在 noteSectionModel */
239+
const manualBaseOf = useCallback((scope: string): number[] | null => manualBaseIds(sections, scope), [sections]);
300240

301241
/** 拖拽归组(组头/左侧组行复用单 id 兜底仍可用) */
302242
const moveToGroup = useCallback(async (ids: number[], groupId: number | null) => {

‎app/src/utils/noteSectionModel.ts‎

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* noteSectionModel — 笔记列表「展示节 / 可见序」纯函数层(批 0-C2·Task 2 自 NoteListView 抽出)。
3+
*
4+
* @ai-context: 树视图展示结构的唯一来源(REQ-287/315)。节 = 未分组(有成员或未按组
5+
* 过滤时恒在)+ 各组(空组不产节;组头序与组侧栏同规则 orderGroups=
6+
* 置顶→手排→自动);scope 键 `none`/`g:{id}` 与 Rust 侧 note_orders.scope
7+
* 字符串契约绑定。
8+
* @ai-context: 全局可见序 = 各节成员顺次拼接,**折叠组行排除**(与渲染可见一致——
9+
* Shift 区间选与划选的唯一基准;折叠组头本身仍在)。折叠键是**裸键**
10+
* (`String(groupId)`/`"none"`,见 foldKeyOf),**不是** scope 键:误用
11+
* scope 键会让用户折叠记忆静默失效(localStorage 键 `notes:group-fold:{裸键}`)。
12+
* @ai-context: 边界——节内展示序 scope==="flat" 时保持后端排序原样(平铺是过滤结果、
13+
* 非完整 scope,不做 scope 级重排);其余走 orderScopeNotes。本模块纯函数、
14+
* 无 React 依赖、无副作用(配单测 utils/noteSectionModel.test.ts)。
15+
*/
16+
import type { Note, NoteGroup } from "../types";
17+
import { paletteHex } from "./colorPalette";
18+
import type { ThemeMode } from "./colorPalette";
19+
import { orderScopeNotes } from "./noteOrder";
20+
import { orderGroups } from "./groupOrder";
21+
import type { GroupOrderRows } from "./groupOrder";
22+
23+
/** scope 键(组/null=未分组)——落库 note_orders 时使用的 scope 字面量 */
24+
export const scopeKey = (groupId: number | null): string => (groupId == null ? "none" : `g:${groupId}`);
25+
26+
/** 折叠键(裸 group id / "none")——折叠记忆与可见序共用的唯一派生 */
27+
export const foldKeyOf = (groupId: number | null): string => (groupId == null ? "none" : String(groupId));
28+
29+
/** 树模式判据(无关键词、无标签、默认排序)——拖拽/移动矩阵总闸 */
30+
export const isTreeMode = (keyword: string, tagFilter: string | null, sortMode: string): boolean =>
31+
keyword.trim() === "" && tagFilter === null && sortMode === "updated-desc";
32+
33+
/** 展示节:scope 键 + 组信息 + 组内展示序成员 */
34+
export interface SectionData {
35+
scope: string;
36+
groupId: number | null;
37+
title: string;
38+
accent: string;
39+
items: Note[];
40+
}
41+
42+
/** buildSections 入参(展示节的全部数据源=原 useMemo 的依赖集) */
43+
export interface SectionInput {
44+
notes: Note[];
45+
groups: NoteGroup[];
46+
groupFilter: number | null;
47+
theme: ThemeMode;
48+
treeMode: boolean;
49+
/** scope → 手排有序 ids(useNoteOrders 提供) */
50+
manualOrders: Record<string, number[]>;
51+
/** 组手排序行(树组头排序消费) */
52+
groupOrderRows: GroupOrderRows;
53+
}
54+
55+
/**
56+
* scope 展示序(REQ-315):置顶区(updated_at 降序)→ 手排 seq → 自动区;
57+
* 平铺(scope=flat)保持后端排序原样(排序模式语义在后端 list_notes——
58+
* 平铺是过滤结果非完整 scope,不做 scope 级重排)。
59+
*/
60+
function orderSectionItems(items: Note[], scope: string, manualOrders: Record<string, number[]>): Note[] {
61+
return scope === "flat" ? items : orderScopeNotes(items, manualOrders[scope]);
62+
}
63+
64+
/**
65+
* 展示节(树=未分组+各组顺次;平铺=单 flat 节)。
66+
* @ai-context: 分桶只做一次——原实现的 `grouped` memo 是死载荷(仅当 treeMode 真值用,
67+
* 其 ungrouped/byGroup 从未被读取;批 0-C2 去重,输出不变)。
68+
*/
69+
export function buildSections(input: SectionInput): SectionData[] {
70+
const { notes, groups, groupFilter, theme, treeMode, manualOrders, groupOrderRows } = input;
71+
const mk = (scope: string, groupId: number | null, title: string, accent: string, items: Note[]): SectionData =>
72+
({ scope, groupId, title, accent, items: orderSectionItems(items, scope, manualOrders) });
73+
const out: SectionData[] = [];
74+
if (treeMode) {
75+
const ungrouped: Note[] = [];
76+
const byGroup = new Map<number, Note[]>();
77+
for (const n of notes) {
78+
if (n.group_id == null) ungrouped.push(n);
79+
else { const a = byGroup.get(n.group_id) ?? []; a.push(n); byGroup.set(n.group_id, a); }
80+
}
81+
// 折叠只影响 body(folded prop)——组头必须常驻(chevron 再点可展开)
82+
if (ungrouped.length > 0 || groupFilter === null) {
83+
out.push(mk("none", null, "未分组", paletteHex(null, theme), ungrouped));
84+
}
85+
// REQ-315:组头序与组侧栏同规则(置顶→手排→自动)——组置顶在树面可见生效
86+
const orderedGroups = orderGroups(groups, groupOrderRows);
87+
for (const g of orderedGroups) {
88+
const items = byGroup.get(g.id) ?? [];
89+
if (items.length === 0) continue;
90+
out.push(mk(scopeKey(g.id), g.id, g.name, paletteHex(g.color ?? null, theme), items));
91+
}
92+
} else {
93+
out.push(mk("flat", null, "", paletteHex(null, theme), notes));
94+
}
95+
return out;
96+
}
97+
98+
/** 全局可见序(L1 审查:折叠组行不参与区间/划选——与渲染可见一致;折叠组头仍在) */
99+
export function buildVisibleOrder(sections: readonly SectionData[], groupFolds: Record<string, boolean>): number[] {
100+
const out: number[] = [];
101+
for (const sec of sections) {
102+
const key = foldKeyOf(sec.groupId);
103+
if (groupFolds[key] === true) continue; // 折叠=行不可见,排除出选择语义
104+
for (const n of sec.items) out.push(n.id);
105+
}
106+
return out;
107+
}
108+
109+
/**
110+
* scope 手动底序(REQ-315):可见展示序去掉置顶区——置顶笔记由 pin 列置顶区
111+
* 表达(按更新时间定序),不占手动位;快照只写本子序列。
112+
*/
113+
export function manualBaseIds(sections: readonly SectionData[], scope: string): number[] | null {
114+
const section = sections.find((s) => s.scope === scope);
115+
if (!section) return null;
116+
return section.items.filter((n) => n.pin !== 1).map((n) => n.id);
117+
}

0 commit comments

Comments
 (0)