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" ;
1114import { invoke } from "@tauri-apps/api/core" ;
1215import type { GroupRouteReason , Note , NoteGroup } from "../types" ;
16+ import { buildTree , type SortMode } from "../utils/noteTree" ;
1317import FeedFragmentList from "./FeedFragmentList" ;
1418import 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+
52112interface 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 ) }
0 commit comments