Skip to content

Commit de656b4

Browse files
committed
feat(notes): 接入 buildTree 纯函数 + 树模式搜索/新建工具条 + 组过滤语义修复
H1: NoteGroupPanel 消费 noteTree.buildTree 纯函数(删除内联 filter/排序) H2: 树模式搜索/新建工具条(keyword/onKeywordChange/onCreate 受控 props) H3: 组头点击=过滤+展开该组并自动收起其他(expandedGroupId 受控单值) M1: NoteRowView 提升为模块级组件(props 显式传入) M2: 归组笔记不再重复渲染(buildTree all 根只含未归组) M4: focusNoteId 跨页直达展开目标组后滚动 L5: noteTree.ts 文件末尾补换行
1 parent ee7b916 commit de656b4

3 files changed

Lines changed: 144 additions & 66 deletions

File tree

‎app/src/components/NoteGroupPanel.tsx‎

Lines changed: 125 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,14 @@
66
* 笔记移动(REQ-198 可改,修改即记忆)。
77
* @ai-context: 受控过滤态由 NotesPage 持有(groupFilter),本组件自管组数据
88
* 加载与改判交互;任何变更后回调 onChanged 触发笔记列表重载。
9+
* @ai-context: v0.11.5 树模式接入 buildTree 纯函数(审查 H1)——组/未归组
10+
* 笔记的合并与排序下沉到 noteTree.ts,本组件只消费 TreeEntry[]
11+
* 渲染;展开组 id 受控(NotesPage 持有,跨页直达 M4 可设置)。
912
*/
10-
import { useCallback, useEffect, useState } from "react";
13+
import { useCallback, useEffect, useMemo, useState } from "react";
1114
import { invoke } from "@tauri-apps/api/core";
1215
import type { GroupRouteReason, Note, NoteGroup } from "../types";
16+
import { buildTree, type SortMode } from "../utils/noteTree";
1317
import FeedFragmentList from "./FeedFragmentList";
1418
import WeekContractCard from "./WeekContractCard";
1519

@@ -49,6 +53,62 @@ export function parseRouteReason(raw: string | null): GroupRouteReason {
4953
}
5054
}
5155

56+
// ── v0.11.5 树模式笔记行(审查 M1:模块级组件——不再闭包捕获面板 state)──
57+
interface NoteRowViewProps {
58+
note: Note;
59+
/** 当前选中笔记 id(行高亮) */
60+
selectedId: number | null;
61+
/** 笔记选中回调 */
62+
onSelectNote: (note: Note) => void;
63+
/** 跳转来源会话 */
64+
onSelectSession: (id: number) => void;
65+
}
66+
67+
/** v0.11.5 树模式:笔记行(复用 NoteListView 行样式) */
68+
function NoteRowView({ note, selectedId, onSelectNote, onSelectSession }: NoteRowViewProps) {
69+
let tags: string[] = [];
70+
try {
71+
const t = JSON.parse(note.tags);
72+
tags = Array.isArray(t) ? t : [];
73+
} catch { /* 损坏 JSON 防御 */ }
74+
const fmtDate = (unix: number) => new Date(unix * 1000).toLocaleString();
75+
return (
76+
<div
77+
id={`note-row-${note.id}`}
78+
onClick={() => onSelectNote(note)}
79+
style={{
80+
padding: "8px 14px 8px 24px",
81+
borderBottom: "1px solid #f3f4f6",
82+
cursor: "pointer",
83+
background: selectedId === note.id ? "#f0fdfa" : "transparent",
84+
}}
85+
>
86+
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
87+
{note.pin ? <span style={{ fontSize: 11, color: "#b45309" }}>📌</span> : null}
88+
<div style={{ fontSize: 13, fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", flex: 1 }}>
89+
{note.title}
90+
</div>
91+
</div>
92+
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 2, display: "flex", flexWrap: "wrap", alignItems: "center", gap: 4 }}>
93+
<span>{note.source === "classroom" ? "📡 课堂" : "✍ 手动"} · {fmtDate(note.updated_at)}</span>
94+
{tags.slice(0, 3).map((t) => (
95+
<span key={t} style={{ fontSize: 10, color: "#6b7280", background: "#f3f4f6", borderRadius: 8, padding: "0 4px" }}>{t}</span>
96+
))}
97+
{tags.length > 3 && <span style={{ fontSize: 10, color: "#9ca3af" }}>+{tags.length - 3}</span>}
98+
{note.session_id != null && (
99+
<span
100+
onClick={(e) => { e.stopPropagation(); onSelectSession(note.session_id as number); }}
101+
style={{ fontSize: 11, color: "#0f766e", cursor: "pointer", fontWeight: 600 }}
102+
title="跳转到来源会话"
103+
>
104+
来源会话 →
105+
</span>
106+
)}
107+
</div>
108+
</div>
109+
);
110+
}
111+
52112
interface Props {
53113
/** 当前过滤组(null=全部) */
54114
groupFilter: number | null;
@@ -69,6 +129,19 @@ interface Props {
69129
onSelectNote: (note: Note) => void;
70130
/** v0.11.5 跳转来源会话 */
71131
onOpenSession: (sessionId: number) => void;
132+
/** v0.11.5 树模式:搜索关键词(受控——NotesPage 持有) */
133+
keyword: string;
134+
/** v0.11.5 树模式:标签过滤(buildTree 过滤态判定用) */
135+
tagFilter: string | null;
136+
/** v0.11.5 树模式:排序模式(buildTree 排序参数) */
137+
sortMode: SortMode;
138+
/** v0.11.5 树模式:搜索关键词变更回调 */
139+
onKeywordChange: (kw: string) => void;
140+
/** v0.11.5 树模式:新建笔记回调 */
141+
onCreate: () => void;
142+
/** v0.11.5 受控展开组 id(null=无展开;单值——同一时间只展开一个组) */
143+
expandedGroupId: number | null;
144+
onExpandedGroupChange: (id: number | null) => void;
72145
}
73146

74147
/** Blob → base64(分块转换——大截图防 String.fromCharCode 栈溢出) */
@@ -82,9 +155,8 @@ async function blobToBase64(blob: Blob): Promise<string> {
82155
return btoa(bin);
83156
}
84157

85-
export default function NoteGroupPanel({ groupFilter, onGroupFilterChange, selectedNoteId, onChanged, onOpenReview, allNotes = [], flatMode = false, selectedId = null, onSelectNote, onOpenSession: onOpenSessionProp }: Props) {
158+
export default function NoteGroupPanel({ groupFilter, onGroupFilterChange, selectedNoteId, onChanged, onOpenReview, allNotes = [], flatMode = false, selectedId = null, onSelectNote, onOpenSession: onOpenSessionProp, keyword = "", tagFilter = null, sortMode = "updated-desc", onKeywordChange = () => {}, onCreate = () => {}, expandedGroupId = null, onExpandedGroupChange = () => {} }: Props) {
86159
const [groups, setGroups] = useState<NoteGroup[]>([]);
87-
const [expandedId, setExpandedId] = useState<number | null>(null);
88160
const [status, setStatus] = useState("");
89161
// v0.11.1:feed 捕获开关(默认关——功能预览纪律)与捕获输入态
90162
const [feedCaptureOn, setFeedCaptureOn] = useState(false);
@@ -97,6 +169,14 @@ export default function NoteGroupPanel({ groupFilter, onGroupFilterChange, selec
97169
// v0.11.3:结算计划态(展开区仪式)
98170
const [settlePlan, setSettlePlan] = useState<SettlementPlan | null>(null);
99171

172+
// v0.11.5 树模式(审查 H1):buildTree 纯函数组合——组/未归组笔记合并排序
173+
// 下沉 noteTree.ts;过滤态退化为平铺 all 列表。展开组 id 受控(NotesPage
174+
// 持有,M4 跨页直达可直接设置)。
175+
const tree = useMemo(
176+
() => buildTree(groups, allNotes, expandedGroupId, keyword, tagFilter, sortMode),
177+
[groups, allNotes, expandedGroupId, keyword, tagFilter, sortMode],
178+
);
179+
100180
const load = useCallback(async () => {
101181
try {
102182
const list = await invoke<NoteGroup[]>("list_note_groups", { terrain: null });
@@ -219,51 +299,6 @@ export default function NoteGroupPanel({ groupFilter, onGroupFilterChange, selec
219299
}
220300
};
221301

222-
/** v0.11.5 树模式:笔记行(复用 NoteListView 行样式) */
223-
function NoteRowView({ note, onSelectSession }: { note: Note; onSelectSession: (id: number) => void }) {
224-
let tags: string[] = [];
225-
try {
226-
const t = JSON.parse(note.tags);
227-
tags = Array.isArray(t) ? t : [];
228-
} catch { /* 损坏 JSON 防御 */ }
229-
const fmtDate = (unix: number) => new Date(unix * 1000).toLocaleString();
230-
return (
231-
<div
232-
id={`note-row-${note.id}`}
233-
onClick={() => onSelectNote?.(note)}
234-
style={{
235-
padding: "8px 14px 8px 24px",
236-
borderBottom: "1px solid #f3f4f6",
237-
cursor: "pointer",
238-
background: selectedId === note.id ? "#f0fdfa" : "transparent",
239-
}}
240-
>
241-
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
242-
{note.pin ? <span style={{ fontSize: 11, color: "#b45309" }}>📌</span> : null}
243-
<div style={{ fontSize: 13, fontWeight: 500, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", flex: 1 }}>
244-
{note.title}
245-
</div>
246-
</div>
247-
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 2, display: "flex", flexWrap: "wrap", alignItems: "center", gap: 4 }}>
248-
<span>{note.source === "classroom" ? "📡 课堂" : "✍ 手动"} · {fmtDate(note.updated_at)}</span>
249-
{tags.slice(0, 3).map((t) => (
250-
<span key={t} style={{ fontSize: 10, color: "#6b7280", background: "#f3f4f6", borderRadius: 8, padding: "0 4px" }}>{t}</span>
251-
))}
252-
{tags.length > 3 && <span style={{ fontSize: 10, color: "#9ca3af" }}>+{tags.length - 3}</span>}
253-
{note.session_id != null && (
254-
<span
255-
onClick={(e) => { e.stopPropagation(); onSelectSession(note.session_id as number); }}
256-
style={{ fontSize: 11, color: "#0f766e", cursor: "pointer", fontWeight: 600 }}
257-
title="跳转到来源会话"
258-
>
259-
来源会话 →
260-
</span>
261-
)}
262-
</div>
263-
</div>
264-
);
265-
}
266-
267302
return (
268303
<div style={{ width: flatMode ? 230 : 550, flexShrink: 0, borderRight: "1px solid #e5e7eb", display: "flex", flexDirection: "column" }}>
269304
<div style={{ padding: "10px 12px", borderBottom: "1px solid #e5e7eb", fontWeight: 600, display: "flex", alignItems: "center", gap: 8 }}>
@@ -303,6 +338,21 @@ export default function NoteGroupPanel({ groupFilter, onGroupFilterChange, selec
303338
</div>
304339
</div>
305340
)}
341+
{/* v0.11.5 树模式:搜索/新建工具条(审查 H2——树内直接搜索+新建;
342+
搜索关键词受控于 NotesPage,输入即触发 keyword 状态+清标签过滤) */}
343+
{!flatMode && (
344+
<div style={{ padding: "6px 10px", display: "flex", gap: 6, borderBottom: "1px solid #e5e7eb" }}>
345+
<input
346+
value={keyword}
347+
onChange={(e) => onKeywordChange(e.target.value)}
348+
placeholder="搜索笔记…"
349+
style={{ flex: 1, padding: "4px 6px", fontSize: 12, border: "1px solid #e5e7eb", borderRadius: 4 }}
350+
/>
351+
<button onClick={onCreate} style={{ fontSize: 12, cursor: "pointer", padding: "3px 8px", borderRadius: 4, border: "1px solid #d1d5db", background: "#f9fafb" }}>
352+
+ 新建
353+
</button>
354+
</div>
355+
)}
306356
{/* 全部(取消组过滤) */}
307357
<div
308358
onClick={() => onGroupFilterChange(null)}
@@ -314,28 +364,38 @@ export default function NoteGroupPanel({ groupFilter, onGroupFilterChange, selec
314364
>
315365
全部笔记
316366
</div>
317-
{/* v0.11.5 树模式:全部笔记根展开——平铺所有笔记(含未归组) */}
318-
{!flatMode && groupFilter === null && allNotes.length > 0 && (
319-
<div style={{ marginLeft: 8, borderLeft: "1px solid #e5e7eb", marginTop: 2, marginBottom: 4 }}>
320-
{allNotes.map((n) => (
321-
<NoteRowView key={n.id} note={n} onSelectSession={onOpenSessionProp} />
322-
))}
323-
</div>
324-
)}
325367
{groups.length === 0 && (
326368
<p style={{ fontSize: 12, color: "#9ca3af", padding: "12px 8px" }}>
327369
暂无笔记组——会话转笔记时自动归组
328370
</p>
329371
)}
330-
{groups.map((g) => {
372+
{/* v0.11.5 树模式(审查 H1):消费 buildTree 输出——all 根平铺未归组
373+
笔记(M2 消除与组内叶子的重复渲染),group 节点渲染组头+展开叶子 */}
374+
{tree.map((entry) => {
375+
if (entry.kind === "all") {
376+
// all 根:仅树模式 + 无组过滤时展示(H3 语义——组过滤激活时隐藏)
377+
return !flatMode && groupFilter === null && entry.notes.length > 0 ? (
378+
<div key="all-root" style={{ marginLeft: 8, borderLeft: "1px solid #e5e7eb", marginTop: 2, marginBottom: 4 }}>
379+
{entry.notes.map((n) => (
380+
<NoteRowView key={n.id} note={n} selectedId={selectedId} onSelectNote={onSelectNote} onSelectSession={onOpenSessionProp} />
381+
))}
382+
</div>
383+
) : null;
384+
}
385+
const g = entry.group;
331386
const badge = kindBadge(g.kind);
332387
const reason = parseRouteReason(g.routeReason);
333388
const active = groupFilter === g.id;
334-
const expanded = expandedId === g.id;
389+
const expanded = entry.expanded;
335390
return (
336391
<div key={g.id} style={{ marginTop: 4 }}>
337392
<div
338-
onClick={() => onGroupFilterChange(active ? null : g.id)}
393+
onClick={() => {
394+
// 审查 H3:组头点击=设置过滤 + 展开该组并自动收起其他
395+
//(expandedGroupId 单值受控——同一时间只展开一个组)
396+
onGroupFilterChange(active ? null : g.id);
397+
onExpandedGroupChange(active ? null : g.id);
398+
}}
339399
style={{
340400
padding: "7px 10px", borderRadius: 6, cursor: "pointer",
341401
background: active ? "#f0fdfa" : "transparent",
@@ -348,7 +408,7 @@ export default function NoteGroupPanel({ groupFilter, onGroupFilterChange, selec
348408
</span>
349409
<span style={{ fontSize: 11, color: "#9ca3af" }}>{g.noteCount}</span>
350410
<span
351-
onClick={(e) => { e.stopPropagation(); setExpandedId(expanded ? null : g.id); setOverrideKind(g.kind); setOverrideDomain(g.domainTag ?? ""); setSettlePlan(null); }}
411+
onClick={(e) => { e.stopPropagation(); onExpandedGroupChange(expanded ? null : g.id); setOverrideKind(g.kind); setOverrideDomain(g.domainTag ?? ""); setSettlePlan(null); }}
352412
style={{ fontSize: 11, color: "#6b7280", cursor: "pointer", padding: "0 3px" }}
353413
title="路由详情/改判"
354414
>
@@ -460,15 +520,16 @@ export default function NoteGroupPanel({ groupFilter, onGroupFilterChange, selec
460520
{g.terrain === "feed" && (
461521
<FeedFragmentList groupId={g.id} onChanged={() => void load()} />
462522
)}
463-
{/* v0.11.5 树模式:组内笔记叶子 */}
523+
{/* v0.11.5 树模式:组内笔记叶子(buildTree 已按组聚合排序——
524+
与 all 根互斥,M2 不再重复渲染) */}
464525
{!flatMode && (
465526
<div style={{ marginTop: 6 }}>
466527
<div style={{ fontSize: 10, color: "#9ca3af", marginBottom: 2, paddingLeft: 4 }}>组内笔记</div>
467-
{allNotes.filter((n) => n.group_id === g.id).length === 0 && (
528+
{entry.notes.length === 0 && (
468529
<div style={{ fontSize: 11, color: "#d1d5db", paddingLeft: 8 }}>此组暂无笔记</div>
469530
)}
470-
{allNotes.filter((n) => n.group_id === g.id).map((n) => (
471-
<NoteRowView key={n.id} note={n} onSelectSession={onOpenSessionProp} />
531+
{entry.notes.map((n) => (
532+
<NoteRowView key={n.id} note={n} selectedId={selectedId} onSelectNote={onSelectNote} onSelectSession={onOpenSessionProp} />
472533
))}
473534
</div>
474535
)}

‎app/src/pages/NotesPage.tsx‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export default function NotesPage({ focusNoteId, onOpenSessions }: Props) {
3636
const [sortMode, setSortMode] = useState<SortMode>("updated-desc");
3737
// v0.11.0:组过滤(null=全部;NoteGroupPanel 受控)
3838
const [groupFilter, setGroupFilter] = useState<number | null>(null);
39+
// v0.11.5:树模式受控展开组 id(单值——同一时间只展开一个组;M4 跨页直达设置)
40+
const [expandedGroupId, setExpandedGroupId] = useState<number | null>(null);
3941
// v0.11.2:复习面(groupId=null 全量;undefined=关闭)
4042
const [review, setReview] = useState<{ groupId: number | null; name: string } | undefined>(undefined);
4143
const [selected, setSelected] = useState<Note | null>(null);
@@ -47,6 +49,9 @@ export default function NotesPage({ focusNoteId, onOpenSessions }: Props) {
4749
const seqRef = useRef(0);
4850
// L2:收集异步流程中的 setTimeout——effect cleanup 统一清理防卸载后触发
4951
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
52+
// M4:跨页直达 effect 读取最新展开组(避免 stale closure)
53+
const expandedGroupIdRef = useRef<number | null>(null);
54+
useEffect(() => { expandedGroupIdRef.current = expandedGroupId; }, [expandedGroupId]);
5055
// A6:注意力跟踪
5156
useNoteAttention(selected?.id ?? null, selected?.title ?? "");
5257

@@ -96,11 +101,16 @@ export default function NotesPage({ focusNoteId, onOpenSessions }: Props) {
96101
const target = list.find((n) => n.id === focusNoteId);
97102
if (target) {
98103
setSelected(target);
104+
// M4(审查修复):跨页直达——目标笔记已归组且该组未展开时,
105+
// 先展开目标组(受控 expandedGroupId),再等待 DOM 渲染后滚动
106+
const targetGroupId = target.group_id ?? null;
107+
const needExpand = targetGroupId != null && expandedGroupIdRef.current !== targetGroupId;
108+
if (needExpand) setExpandedGroupId(targetGroupId);
99109
// L2:定时器登记入 ref(cleanup 可清理),不再裸 setTimeout
100110
timersRef.current.push(
101111
setTimeout(() => {
102112
document.getElementById(`note-row-${target.id}`)?.scrollIntoView({ block: "center" });
103-
}, 50),
113+
}, needExpand ? 200 : 50),
104114
);
105115
}
106116
} catch (e) {
@@ -237,6 +247,13 @@ export default function NotesPage({ focusNoteId, onOpenSessions }: Props) {
237247
selectedId={selected?.id ?? null}
238248
onSelectNote={handleSelect}
239249
onOpenSession={(id) => onOpenSessions?.(id)}
250+
keyword={keyword}
251+
tagFilter={tagFilter}
252+
sortMode={sortMode}
253+
onKeywordChange={(kw) => { setKeyword(kw); setTagFilter(null); }}
254+
onCreate={handleCreate}
255+
expandedGroupId={expandedGroupId}
256+
onExpandedGroupChange={setExpandedGroupId}
240257
/>
241258
{/* v0.11.5:平铺模式(搜索/标签过滤激活)→ 显示传统列表 */}
242259
{flatMode && (

‎app/src/utils/noteTree.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,4 @@ export function buildTree(
8989
}
9090

9191
return entries;
92-
}
92+
}

0 commit comments

Comments
 (0)