From 236b368537c446c21c542b3eeb3736e1fdfc88be Mon Sep 17 00:00:00 2001 From: gaoziman <2942894660@qq.com> Date: Wed, 24 Dec 2025 22:50:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BB=84=E4=BB=B6):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=85=A8=E5=B1=80=E6=90=9C=E7=B4=A2=E5=BC=B9?= =?UTF-8?q?=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 SearchModal 搜索模态框组件 - 新增 SearchResultItem 搜索结果项组件 - 支持键盘快捷键导航(上下箭头选择、回车打开) - 实现关键词高亮显示和智能截取上下文 - 包含骨架屏加载、空状态、错误状态等完整交互 - 支持按角色筛选搜索结果 --- src/components/features/SearchModal.tsx | 367 +++++++++++++++++++ src/components/features/SearchResultItem.tsx | 198 ++++++++++ 2 files changed, 565 insertions(+) create mode 100644 src/components/features/SearchModal.tsx create mode 100644 src/components/features/SearchResultItem.tsx diff --git a/src/components/features/SearchModal.tsx b/src/components/features/SearchModal.tsx new file mode 100644 index 0000000..980aad3 --- /dev/null +++ b/src/components/features/SearchModal.tsx @@ -0,0 +1,367 @@ +'use client'; + +import { useEffect, useRef, useState, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { + Search, + X, + MessageSquare, + User, + Bot, + Loader2, + SearchX, + MessageCircleQuestion, + ChevronDown, + ArrowUp, + ArrowDown, + CornerDownLeft, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useSearch, type SearchResult, type SearchRole } from '@/hooks/useSearch'; +import { SearchResultItem } from './SearchResultItem'; + +interface SearchModalProps { + isOpen: boolean; + onClose: () => void; +} + +/** + * 消息全局搜索模态框 + */ +export function SearchModal({ isOpen, onClose }: SearchModalProps) { + const router = useRouter(); + const inputRef = useRef(null); + const resultsRef = useRef(null); + + // 搜索状态 + const { + query, + results, + total, + status, + error, + role, + setQuery, + setRole, + reset, + } = useSearch(); + + // 选中项索引(用于键盘导航) + const [selectedIndex, setSelectedIndex] = useState(-1); + + // 聚焦输入框 + useEffect(() => { + if (isOpen && inputRef.current) { + setTimeout(() => { + inputRef.current?.focus(); + }, 100); + } + }, [isOpen]); + + // 关闭时重置状态 + useEffect(() => { + if (!isOpen) { + reset(); + setSelectedIndex(-1); + } + }, [isOpen, reset]); + + // 选中项变化时滚动到可视区域 + useEffect(() => { + if (selectedIndex >= 0 && resultsRef.current) { + const selectedElement = resultsRef.current.querySelector( + `[data-index="${selectedIndex}"]` + ); + if (selectedElement) { + selectedElement.scrollIntoView({ + block: 'nearest', + behavior: 'smooth', + }); + } + } + }, [selectedIndex]); + + /** + * 跳转到对话并高亮消息 + */ + const handleNavigate = useCallback((result: SearchResult) => { + // 添加 highlight 查询参数,用于定位和高亮消息 + router.push(`/chat/${result.conversationId}?highlight=${result.messageId}`); + onClose(); + }, [router, onClose]); + + /** + * 键盘事件处理 + */ + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setSelectedIndex(prev => + prev < results.length - 1 ? prev + 1 : prev + ); + break; + case 'ArrowUp': + e.preventDefault(); + setSelectedIndex(prev => (prev > 0 ? prev - 1 : -1)); + break; + case 'Enter': + e.preventDefault(); + if (selectedIndex >= 0 && results[selectedIndex]) { + handleNavigate(results[selectedIndex]); + } + break; + case 'Escape': + e.preventDefault(); + onClose(); + break; + } + }, [results, selectedIndex, handleNavigate, onClose]); + + /** + * 清除搜索 + */ + const handleClear = useCallback(() => { + setQuery(''); + setSelectedIndex(-1); + inputRef.current?.focus(); + }, [setQuery]); + + /** + * 角色筛选按钮 + */ + const RoleFilterButton = ({ + filterRole, + icon: Icon, + label, + }: { + filterRole: SearchRole; + icon: React.ElementType; + label: string; + }) => ( + + ); + + if (!isOpen) return null; + + return ( + <> + {/* 遮罩层 */} +
+ + {/* 模态框 */} +
+
+ {/* 搜索输入区 */} +
+ + { + setQuery(e.target.value); + setSelectedIndex(-1); + }} + placeholder="搜索消息内容..." + className="flex-1 bg-transparent text-base text-[var(--color-text-primary)] placeholder:text-[var(--color-text-placeholder)] outline-none" + /> + {query && status !== 'loading' && ( + + )} + {status === 'loading' && ( + + )} + +
+ + {/* 筛选器 */} +
+ + + +
+ + {/* 搜索结果区 */} +
+ {/* 初始状态 */} + {status === 'idle' && !query && ( +
+
+ +
+
+ 搜索历史消息 +
+
+ 输入关键词搜索所有对话中的消息内容 +
+
+ )} + + {/* 加载状态 - 骨架屏 */} + {status === 'loading' && results.length === 0 && ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+ ))} +
+ )} + + {/* 搜索结果 */} + {results.length > 0 && ( + <> + {/* 结果统计 */} +
+ + 找到 {total} 条结果 + + +
+ + {/* 结果列表 */} +
+ {results.map((result, index) => ( + handleNavigate(result)} + dataIndex={index} + /> + ))} +
+ + )} + + {/* 无结果状态 */} + {status === 'success' && results.length === 0 && query && ( +
+
+ +
+
+ 未找到相关消息 +
+
+ 试试其他关键词,或检查拼写是否正确 +
+
+ )} + + {/* 错误状态 */} + {status === 'error' && ( +
+
+ +
+
+ 搜索出错 +
+
+ {error || '请稍后重试'} +
+
+ )} +
+ + {/* 底部快捷键提示 */} +
+
+
+ + + + + + + 选择 +
+
+ + + + 打开 +
+
+ + esc + + 关闭 +
+
+
+
+
+ + ); +} diff --git a/src/components/features/SearchResultItem.tsx b/src/components/features/SearchResultItem.tsx new file mode 100644 index 0000000..5591f2a --- /dev/null +++ b/src/components/features/SearchResultItem.tsx @@ -0,0 +1,198 @@ +'use client'; + +import { useMemo } from 'react'; +import { User, Bot } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { SearchResult } from '@/hooks/useSearch'; + +interface SearchResultItemProps { + result: SearchResult; + query: string; + isSelected: boolean; + onClick: () => void; + dataIndex: number; +} + +/** + * 格式化时间显示 + */ +function formatTime(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000); + + const dateOnly = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + + if (dateOnly.getTime() === today.getTime()) { + return `今天 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`; + } else if (dateOnly.getTime() === yesterday.getTime()) { + return `昨天 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`; + } else if (now.getTime() - date.getTime() < 7 * 24 * 60 * 60 * 1000) { + const days = Math.floor((now.getTime() - date.getTime()) / (24 * 60 * 60 * 1000)); + return `${days}天前`; + } else { + return `${date.getMonth() + 1}月${date.getDate()}日`; + } +} + +/** + * 高亮关键词 + */ +function highlightText(text: string, query: string): React.ReactNode { + if (!query.trim()) { + return text; + } + + // 转义正则特殊字符 + const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`(${escapedQuery})`, 'gi'); + const parts = text.split(regex); + + return parts.map((part, index) => { + if (part.toLowerCase() === query.toLowerCase()) { + return ( + + {part} + + ); + } + return part; + }); +} + +/** + * 截取消息内容并保留关键词上下文 + */ +function truncateContent(content: string, query: string, maxLength: number = 150): string { + // 移除多余的换行和空格 + const cleanContent = content.replace(/\s+/g, ' ').trim(); + + if (cleanContent.length <= maxLength) { + return cleanContent; + } + + // 查找关键词位置 + const lowerContent = cleanContent.toLowerCase(); + const lowerQuery = query.toLowerCase(); + const keywordIndex = lowerContent.indexOf(lowerQuery); + + if (keywordIndex === -1) { + // 没有找到关键词,从头截取 + return cleanContent.slice(0, maxLength) + '...'; + } + + // 计算截取范围,尽量让关键词在中间 + const contextBefore = 40; + const contextAfter = maxLength - contextBefore - query.length; + + let start = Math.max(0, keywordIndex - contextBefore); + let end = Math.min(cleanContent.length, keywordIndex + query.length + contextAfter); + + // 调整边界 + if (start > 0) { + // 找到前一个空格,避免截断单词 + const spaceIndex = cleanContent.lastIndexOf(' ', start); + if (spaceIndex > start - 20) { + start = spaceIndex + 1; + } + } + + if (end < cleanContent.length) { + // 找到后一个空格,避免截断单词 + const spaceIndex = cleanContent.indexOf(' ', end); + if (spaceIndex !== -1 && spaceIndex < end + 20) { + end = spaceIndex; + } + } + + let result = cleanContent.slice(start, end); + + // 添加省略号 + if (start > 0) { + result = '...' + result; + } + if (end < cleanContent.length) { + result = result + '...'; + } + + return result; +} + +/** + * 搜索结果项组件 + */ +export function SearchResultItem({ + result, + query, + isSelected, + onClick, + dataIndex, +}: SearchResultItemProps) { + const isUser = result.role === 'user'; + + // 处理显示内容 + const displayContent = useMemo(() => { + return truncateContent(result.content, query); + }, [result.content, query]); + + // 高亮后的内容 + const highlightedContent = useMemo(() => { + return highlightText(displayContent, query); + }, [displayContent, query]); + + return ( +
+ {/* 头像 */} +
+ {isUser ? : } +
+ + {/* 内容 */} +
+ {/* 元信息 */} +
+ + {result.conversationTitle} + + + {isUser ? '我' : 'AI'} + + + {formatTime(result.createdAt)} + +
+ + {/* 消息内容 */} +
+ {highlightedContent} +
+
+
+ ); +}