11/**
2- * @ai -context: 通用组件:NoteSearchBar。
2+ * 笔记搜索栏
3+ *
4+ * @ai -context: 内测反馈重构:全局搜索(跨模块搜内容)已独立至 Ctrl+K 命令面板,
5+ * 笔记页搜索框收窄为「笔记内部筛选」——关键词即时过滤笔记列表(本地内存过滤,
6+ * getFilteredNotes 同步生效)+ 模板筛选 tabs(与卡片模板 Tag 共用 selectedTemplate
7+ * 状态,双向联动,再点取消,可逆)。
38 */
4- import { useState , useEffect , useRef , useCallback } from 'react' ;
5- import { motion , AnimatePresence } from 'framer-motion' ;
6- import { Input } from '@/components/ui' ;
7- import { Search , X , Loader2 , FileText , Layers , Brain , Lightbulb , GraduationCap } from 'lucide-react' ;
9+ import { useState } from 'react' ;
10+ import { Search , X } from 'lucide-react' ;
811import { cn } from '@/lib/utils' ;
912import { soundPlayer } from '@/lib/audio/SoundPlayer' ;
1013import { useNoteStore } from '../store/useNoteStore' ;
1114import { useShallow } from 'zustand/react/shallow' ;
12- import { useReducedMotion } from '@/hooks/useReducedMotion' ;
13- import type { SearchEntityType } from '@/types/models' ;
14-
15- /**
16- * 笔记搜索栏组件
17- * v0.9.0: 带 300ms 防抖的搜索输入框 + 结果计数 + 关键词高亮辅助
18- * v1.2.0: 全局统一搜索,支持实体类型筛选标签 + 结果类型图标
19- */
20-
21- /** 实体类型筛选配置 */
22- const ENTITY_TYPE_TABS : Array < {
23- type : SearchEntityType | 'all' ;
24- label : string ;
25- icon : React . ReactNode ;
26- } > = [
27- { type : 'all' , label : '全部' , icon : < Search className = "w-3 h-3" /> } ,
28- { type : 'note' , label : '笔记' , icon : < FileText className = "w-3 h-3" /> } ,
29- { type : 'flashcard' , label : '闪卡' , icon : < Layers className = "w-3 h-3" /> } ,
30- { type : 'feynman' , label : '费曼' , icon : < Brain className = "w-3 h-3" /> } ,
31- { type : 'inspiration' , label : '灵感' , icon : < Lightbulb className = "w-3 h-3" /> } ,
32- { type : 'classroom' , label : '课堂' , icon : < GraduationCap className = "w-3 h-3" /> } ,
15+ import { Input } from '@/components/ui/Input' ;
16+ import type { NoteTemplate } from '../components/TemplateSelector' ;
17+
18+ /** 模板筛选 tabs(与卡片模板 Tag 共用 selectedTemplate 状态) */
19+ const TEMPLATE_TABS : Array < { type : NoteTemplate | 'all' ; label : string } > = [
20+ { type : 'all' , label : '全部' } ,
21+ { type : 'outline' , label : '大纲式' } ,
22+ { type : 'cornell' , label : '康奈尔' } ,
23+ { type : 'mindmap' , label : '思维导图' } ,
24+ { type : 'free' , label : '自由笔记' } ,
25+ { type : 'blank' , label : '空白' } ,
26+ { type : 'todo' , label : '待办' } ,
3327] ;
3428
35- /** 搜索结果类型图标 */
36- export function EntityTypeIcon ( { type, className } : { type : SearchEntityType ; className ?: string } ) {
37- const iconMap : Record < SearchEntityType , React . ReactNode > = {
38- note : < FileText className = { cn ( 'w-3.5 h-3.5' , className ) } /> ,
39- flashcard : < Layers className = { cn ( 'w-3.5 h-3.5' , className ) } /> ,
40- feynman : < Brain className = { cn ( 'w-3.5 h-3.5' , className ) } /> ,
41- inspiration : < Lightbulb className = { cn ( 'w-3.5 h-3.5' , className ) } /> ,
42- classroom : < GraduationCap className = { cn ( 'w-3.5 h-3.5' , className ) } /> ,
43- } ;
44- return < > { iconMap [ type ] } </ > ;
45- }
46-
47- /** 实体类型对应的颜色样式 */
48- const ENTITY_TYPE_COLORS : Record < SearchEntityType , string > = {
49- note : 'bg-brand-500/10 text-brand-600 border-brand-300/30' ,
50- flashcard : 'bg-emerald-500/10 text-emerald-600 border-emerald-300/30' ,
51- feynman : 'bg-violet-500/10 text-violet-600 border-violet-300/30' ,
52- inspiration : 'bg-amber-500/10 text-amber-600 border-amber-300/30' ,
53- classroom : 'bg-sky-500/10 text-sky-600 border-sky-300/30' ,
54- } ;
55-
56- /** 实体类型的中文标签 */
57- export const ENTITY_TYPE_LABELS : Record < SearchEntityType , string > = {
58- note : '笔记' ,
59- flashcard : '闪卡' ,
60- feynman : '费曼' ,
61- inspiration : '灵感' ,
62- classroom : '课堂' ,
63- } ;
64-
6529export function NoteSearchBar ( ) {
6630 const {
6731 searchQuery,
68- searchResults,
69- searchNotes,
7032 setSearchQuery,
71- selectedEntityTypes ,
72- setSelectedEntityTypes ,
73- } = useNoteStore ( useShallow ( s => ( {
33+ selectedTemplate ,
34+ toggleTemplate ,
35+ } = useNoteStore ( useShallow ( ( s ) => ( {
7436 searchQuery : s . searchQuery ,
75- searchResults : s . searchResults ,
76- searchNotes : s . searchNotes ,
7737 setSearchQuery : s . setSearchQuery ,
78- selectedEntityTypes : s . selectedEntityTypes ,
79- setSelectedEntityTypes : s . setSelectedEntityTypes ,
38+ selectedTemplate : s . selectedTemplate ,
39+ toggleTemplate : s . toggleTemplate ,
8040 } ) ) ) ;
81- const prefersReduced = useReducedMotion ( ) ;
8241 const [ localQuery , setLocalQuery ] = useState ( searchQuery ) ;
83- const [ isSearching , setIsSearching ] = useState ( false ) ;
84- const timerRef = useRef < ReturnType < typeof setTimeout > > ( ) ;
85- const queryRef = useRef ( localQuery ) ;
86-
87- // 保持 queryRef 最新
88- queryRef . current = localQuery ;
89-
90- // 防抖搜索:300ms
91- const debouncedSearch = useCallback (
92- ( query : string , entityTypes ?: SearchEntityType [ ] ) => {
93- if ( timerRef . current ) clearTimeout ( timerRef . current ) ;
94- if ( ! query . trim ( ) ) {
95- setSearchQuery ( '' ) ;
96- setIsSearching ( false ) ;
97- return ;
98- }
99- setIsSearching ( true ) ;
100- timerRef . current = setTimeout ( async ( ) => {
101- await searchNotes ( query , { limit : 50 , fuzzy : true , entityTypes } ) ;
102- setIsSearching ( false ) ;
103- } , 300 ) ;
104- } ,
105- [ searchNotes , setSearchQuery ] ,
106- ) ;
107-
108- useEffect ( ( ) => {
109- return ( ) => {
110- if ( timerRef . current ) clearTimeout ( timerRef . current ) ;
111- } ;
112- } , [ ] ) ;
11342
11443 const handleChange = ( e : React . ChangeEvent < HTMLInputElement > ) => {
11544 const val = e . target . value ;
11645 setLocalQuery ( val ) ;
117- debouncedSearch ( val , selectedEntityTypes ) ;
46+ // 本地即时过滤:getFilteredNotes 按 searchQuery 过滤笔记列表(无索引延迟)
47+ setSearchQuery ( val ) ;
11848 } ;
11949
12050 const handleClear = ( ) => {
12151 setLocalQuery ( '' ) ;
12252 setSearchQuery ( '' ) ;
123- setIsSearching ( false ) ;
124- if ( timerRef . current ) clearTimeout ( timerRef . current ) ;
12553 } ;
12654
127- const handleTypeFilter = ( type : SearchEntityType | 'all' ) => {
128- const currentType = selectedEntityTypes . length === 0 ? 'all' : selectedEntityTypes [ 0 ] ;
129- if ( type !== currentType ) soundPlayer . play ( 'ui_tab_switch' ) ;
130- const newTypes = type === 'all' ? [ ] : [ type ] ;
131- setSelectedEntityTypes ( newTypes ) ;
132- // 如果有搜索词,立即用新过滤条件搜索
133- if ( queryRef . current . trim ( ) ) {
134- debouncedSearch ( queryRef . current , newTypes ) ;
55+ const handleTemplateFilter = ( type : NoteTemplate | 'all' ) => {
56+ // 「全部」点击清除筛选;模板点击切换(再点取消,可逆)
57+ if ( type === 'all' ) {
58+ if ( selectedTemplate ) {
59+ soundPlayer . play ( 'ui_tab_switch' ) ;
60+ toggleTemplate ( selectedTemplate ) ;
61+ }
62+ } else {
63+ if ( selectedTemplate !== type ) soundPlayer . play ( 'ui_tab_switch' ) ;
64+ toggleTemplate ( type ) ;
13565 }
13666 } ;
13767
138- const resultCount = searchResults . length ;
139- const showResultBadge = searchQuery . trim ( ) . length > 0 && ! isSearching ;
140- const activeType = selectedEntityTypes . length === 0 ? 'all' : selectedEntityTypes [ 0 ] ;
141-
14268 return (
14369 < div className = "flex flex-col gap-2 flex-1 min-w-0" >
14470 < div className = "flex items-center gap-2 flex-1 min-w-0" >
14571 < Input
146- placeholder = "搜索笔记、闪卡、费曼、灵感、课堂笔记... "
72+ placeholder = "搜索笔记… "
14773 prefix = { < Search className = "w-4 h-4" strokeWidth = { 1.5 } /> }
14874 suffix = {
149- < AnimatePresence >
150- { isSearching ? (
151- < motion . div
152- key = "loading"
153- initial = { { opacity : 0 , scale : 0.8 } }
154- animate = { { opacity : 1 , scale : 1 } }
155- exit = { { opacity : 0 , scale : 0.8 } }
156- transition = { prefersReduced ? { duration : 0.01 } : undefined }
157- >
158- < Loader2 className = "w-4 h-4 animate-spin text-text-tertiary" strokeWidth = { 1.5 } />
159- </ motion . div >
160- ) : localQuery ? (
161- < motion . button
162- key = "clear"
163- initial = { { opacity : 0 , scale : 0.8 } }
164- animate = { { opacity : 1 , scale : 1 } }
165- exit = { { opacity : 0 , scale : 0.8 } }
166- transition = { prefersReduced ? { duration : 0.01 } : { type : 'spring' , stiffness : 400 , damping : 25 } }
167- whileTap = { { scale : 0.9 } }
168- onClick = { handleClear }
169- className = "p-0.5 rounded-full text-text-tertiary hover:text-text-primary hover:bg-bg-tertiary/50 transition-all duration-200"
170- >
171- < X className = "w-3.5 h-3.5" strokeWidth = { 1.5 } />
172- </ motion . button >
173- ) : null }
174- </ AnimatePresence >
75+ localQuery ? (
76+ < button
77+ onClick = { handleClear }
78+ className = "p-0.5 rounded-full text-text-tertiary hover:text-text-primary hover:bg-bg-tertiary/50 transition-all duration-200"
79+ aria-label = "清除搜索"
80+ >
81+ < X className = "w-3.5 h-3.5" strokeWidth = { 1.5 } />
82+ </ button >
83+ ) : null
17584 }
17685 size = "sm"
17786 className = "flex-1 min-w-0"
17887 value = { localQuery }
17988 onChange = { handleChange }
18089 />
181- < AnimatePresence >
182- { showResultBadge && (
183- < motion . span
184- initial = { prefersReduced ? false : { opacity : 0 , scale : 0.8 , x : - 8 } }
185- animate = { { opacity : 1 , scale : 1 , x : 0 } }
186- exit = { { opacity : 0 , scale : 0.8 , x : - 8 } }
187- transition = { prefersReduced ? { duration : 0.01 } : { type : 'spring' , stiffness : 400 , damping : 28 } }
188- className = { cn (
189- 'flex-shrink-0 px-2 py-0.5 rounded-full text-[11px] font-medium' ,
190- resultCount > 0
191- ? 'bg-brand-500/10 text-brand-600 border border-brand-300/30'
192- : 'bg-bg-tertiary/40 text-text-tertiary border border-border/30' ,
193- ) }
194- >
195- { resultCount > 0 ? `${ resultCount } 条结果` : '无结果' }
196- </ motion . span >
197- ) }
198- </ AnimatePresence >
19990 </ div >
20091
201- { /* 实体类型筛选标签 */ }
92+ { /* 模板筛选 tabs:笔记内部维度,与卡片模板 Tag 联动 */ }
20293 < div className = "flex items-center gap-1 flex-wrap" >
203- { ENTITY_TYPE_TABS . map ( ( tab ) => {
204- const isActive = activeType === tab . type ;
94+ { TEMPLATE_TABS . map ( ( tab ) => {
95+ const isActive = tab . type === 'all' ? ! selectedTemplate : selectedTemplate === tab . type ;
20596 return (
20697 < button
20798 key = { tab . type }
208- onClick = { ( ) => handleTypeFilter ( tab . type ) }
99+ onClick = { ( ) => handleTemplateFilter ( tab . type ) }
209100 className = { cn (
210101 'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium' ,
211102 'border transition-all duration-200' ,
@@ -214,7 +105,6 @@ export function NoteSearchBar() {
214105 : 'bg-bg-tertiary/20 text-text-tertiary border-border/20 hover:bg-bg-tertiary/40 hover:text-text-secondary' ,
215106 ) }
216107 >
217- { tab . icon }
218108 { tab . label }
219109 </ button >
220110 ) ;
@@ -223,20 +113,3 @@ export function NoteSearchBar() {
223113 </ div >
224114 ) ;
225115}
226-
227- /**
228- * 搜索结果类型徽章(可在搜索结果列表中使用)
229- */
230- export function EntityTypeBadge ( { type } : { type : SearchEntityType } ) {
231- return (
232- < span
233- className = { cn (
234- 'inline-flex items-center gap-0.5 px-1.5 py-0 rounded-full text-[10px] font-medium border' ,
235- ENTITY_TYPE_COLORS [ type ] ,
236- ) }
237- >
238- < EntityTypeIcon type = { type } className = "w-2.5 h-2.5" />
239- { ENTITY_TYPE_LABELS [ type ] }
240- </ span >
241- ) ;
242- }
0 commit comments