Skip to content

Commit 410d582

Browse files
committed
feat(ui): 命令面板集成全局内容搜索(content类别)+Tag可交互+work-area属性
1 parent b6735e2 commit 410d582

4 files changed

Lines changed: 86 additions & 9 deletions

File tree

‎client/src/components/overlay/FunctionalOverlay.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export function FunctionalOverlay({ children, visible, className, panelClassName
6464

6565
{/* 功能面板 — 隐藏时移除 backdrop-blur,避免 backdrop-filter 合成层遮挡 canvas */}
6666
<motion.div
67+
data-work-area="module-content"
6768
className={cn(
6869
"relative z-10 w-full",
6970
"max-w-5xl",

‎client/src/components/ui/CommandPalette.tsx‎

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
Search, X, ArrowRight,
99
LayoutDashboard, Timer, FileText, Layers, Lightbulb, BarChart3, Settings,
1010
FilePlus, FolderPlus, Import, Download, Moon, CheckCircle,
11-
Palette, Brain, Database, Info,
11+
Palette, Brain, Database, Info, GraduationCap,
1212
type LucideIcon,
1313
} from 'lucide-react';
1414
import { cn } from '@/lib/utils';
@@ -17,22 +17,50 @@ import { useToast } from '@/components/ui/Toast';
1717
import { commandRegistry } from '@/lib/commandPalette/registry';
1818
import { registerDefaultCommands } from '@/lib/commandPalette/defaultCommands';
1919
import type { Command } from '@/lib/commandPalette/registry';
20+
import { useNoteStore } from '@/features/notes/store/useNoteStore';
21+
import type { SearchEntityType } from '@/types/models';
2022
import { soundPlayer } from '@/lib/audio/SoundPlayer';
2123

2224
const iconMap: Record<string, LucideIcon> = {
2325
LayoutDashboard, Timer, FileText, Layers, Lightbulb, BarChart3, Settings,
2426
FilePlus, FolderPlus, Import, Download, Moon, CheckCircle,
25-
Palette, Brain, Database, Info,
27+
Palette, Brain, Database, Info, GraduationCap,
2628
};
2729

30+
/** 搜索结果实体类型 → 图标名(与 iconMap 对应) */
31+
const ENTITY_ICONS: Record<SearchEntityType, string> = {
32+
note: 'FileText',
33+
flashcard: 'Layers',
34+
feynman: 'Brain',
35+
inspiration: 'Lightbulb',
36+
classroom: 'GraduationCap',
37+
};
38+
39+
/** 搜索结果实体类型 → 目标路由(note 精确到单篇,其余落模块页) */
40+
function contentRoute(type: SearchEntityType, entityId: string): string {
41+
switch (type) {
42+
case 'note': return `/notes/${entityId}`;
43+
case 'flashcard': return '/flashcards';
44+
case 'feynman': return '/feynman';
45+
case 'inspiration': return '/inspiration';
46+
case 'classroom': return '/classroom';
47+
default: return '/';
48+
}
49+
}
50+
51+
/** 去掉搜索高亮标记后的纯文本片段 */
52+
function stripMarkup(html: string): string {
53+
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
54+
}
55+
2856
function CommandIcon({ name, className }: { name?: string; className?: string }) {
2957
if (!name || !iconMap[name]) return null;
3058
const Icon = iconMap[name];
3159
return <Icon className={className} />;
3260
}
3361

3462
const categoryLabels: Record<Command['category'], string> = {
35-
navigation: '导航', action: '操作', settings: '设置',
63+
navigation: '导航', action: '操作', settings: '设置', content: '内容',
3664
};
3765

3866
/** 导航命令 ID → 目标路由(用于判断目标≠当前时播放切换音效) */
@@ -45,6 +73,7 @@ const categoryColors: Record<Command['category'], string> = {
4573
navigation: 'bg-accent-500/15 text-accent-500',
4674
action: 'bg-semantic-success/15 text-semantic-success',
4775
settings: 'bg-accent/15 text-accent-500',
76+
content: 'bg-brand-500/15 text-brand-600',
4877
};
4978

5079
function groupByCategory(commands: Command[]): Array<{ category: Command['category']; items: Command[] }> {
@@ -68,6 +97,8 @@ export default function CommandPalette() {
6897
const registeredRef = useRef(false);
6998
const listRef = useRef<HTMLDivElement>(null);
7099
const inputRef = useRef<HTMLInputElement>(null);
100+
// 全局内容搜索:复用笔记 store 的 searchNotes(dexie 全局索引,多实体)
101+
const searchResults = useNoteStore((s) => s.searchResults);
71102

72103
useEffect(() => {
73104
if (!registeredRef.current) {
@@ -76,7 +107,36 @@ export default function CommandPalette() {
76107
}
77108
}, []);
78109

79-
const filtered = useMemo(() => commandRegistry.search(query), [query]);
110+
// 输入防抖 250ms 后执行全局内容搜索;清空输入时清除结果
111+
useEffect(() => {
112+
if (!query.trim()) {
113+
useNoteStore.getState().searchNotes('');
114+
return;
115+
}
116+
const timer = setTimeout(() => {
117+
useNoteStore.getState().searchNotes(query, { limit: 6, fuzzy: true });
118+
}, 250);
119+
return () => clearTimeout(timer);
120+
}, [query]);
121+
122+
// 搜索结果包装为 Command(category=content),与命令列表统一键盘导航/渲染
123+
const contentCommands = useMemo<Command[]>(() => {
124+
if (!query.trim()) return [];
125+
return searchResults.map((r) => ({
126+
id: `search-${r.entityType}-${r.entityId}`,
127+
label: r.title || '未命名',
128+
description: stripMarkup(r.snippet).slice(0, 60),
129+
icon: ENTITY_ICONS[r.entityType],
130+
category: 'content' as const,
131+
execute: () => navigate(contentRoute(r.entityType, r.entityId)),
132+
}));
133+
}, [query, searchResults, navigate]);
134+
135+
// 有输入时:内容结果置顶 + 命令匹配结果;无输入时:全部命令(分组展示)
136+
const filtered = useMemo(
137+
() => (query.trim() ? [...contentCommands, ...commandRegistry.search(query)] : commandRegistry.search(query)),
138+
[query, contentCommands],
139+
);
80140
const groups = useMemo(() => (query ? null : groupByCategory(filtered)), [query, filtered]);
81141
const flatList = filtered;
82142

@@ -152,7 +212,7 @@ export default function CommandPalette() {
152212
<motion.div
153213
className={cn(
154214
'bg-bg-elevated/90 backdrop-blur-2xl rounded-[var(--kb-radius-xl)] shadow-2xl',
155-
'w-[560px] max-h-[400px] flex flex-col',
215+
'w-[560px] max-h-[520px] flex flex-col',
156216
'border border-border/40',
157217
)}
158218
initial={{ opacity: 0, scale: 0.95, y: -20 }}
@@ -171,7 +231,7 @@ export default function CommandPalette() {
171231
ref={inputRef}
172232
value={query}
173233
onChange={(e) => { setQuery(e.target.value); setSelectedIndex(0); }}
174-
placeholder="输入命令或搜索功能…"
234+
placeholder="搜索内容或输入命令…"
175235
size="lg"
176236
prefix={<Search className="w-4 h-4" />}
177237
suffix={query ? (
@@ -193,7 +253,7 @@ export default function CommandPalette() {
193253
<motion.div className="flex flex-col items-center justify-center py-8 gap-2"
194254
initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
195255
<Search className="w-8 h-8 text-text-tertiary" />
196-
<p className="text-b2 text-text-tertiary">未找到匹配命令</p>
256+
<p className="text-b2 text-text-tertiary">未找到匹配内容或命令</p>
197257
</motion.div>
198258
) : groups ? (
199259
groups.map((group) => (

‎client/src/components/ui/Tag.tsx‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ export interface TagProps {
3737
color?: TagColor;
3838
closable?: boolean;
3939
onClose?: () => void;
40+
/** 点击回调(存在时渲染为可交互元素:cursor + hover/active 反馈) */
41+
onClick?: (e: React.MouseEvent<HTMLSpanElement>) => void;
42+
/** 选中态(用于标签筛选等场景高亮) */
43+
active?: boolean;
4044
children: React.ReactNode;
4145
className?: string;
4246
}
@@ -45,11 +49,23 @@ export const Tag: React.FC<TagProps> = ({
4549
color,
4650
closable = false,
4751
onClose,
52+
onClick,
53+
active = false,
4854
children,
4955
className,
5056
}) => {
5157
return (
52-
<span className={cn(tagVariants({ color }), className)}>
58+
<span
59+
onClick={onClick}
60+
role={onClick ? 'button' : undefined}
61+
aria-pressed={onClick ? active : undefined}
62+
className={cn(
63+
tagVariants({ color }),
64+
onClick && 'cursor-pointer hover:bg-black/5 dark:hover:bg-white/10 active:scale-[0.97] select-none',
65+
active && 'ring-1 ring-brand-500/40 bg-brand-50/60 shadow-sm',
66+
className,
67+
)}
68+
>
5369
{children}
5470
{closable && (
5571
<button

‎client/src/lib/commandPalette/registry.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export interface Command {
88
label: string;
99
description?: string;
1010
icon?: string;
11-
category: 'navigation' | 'action' | 'settings';
11+
category: 'navigation' | 'action' | 'settings' | 'content';
1212
shortcut?: string;
1313
execute: () => void | Promise<void>;
1414
}

0 commit comments

Comments
 (0)