feat(组件): 添加消息全局搜索弹框
- 新增 SearchModal 搜索模态框组件 - 新增 SearchResultItem 搜索结果项组件 - 支持键盘快捷键导航(上下箭头选择、回车打开) - 实现关键词高亮显示和智能截取上下文 - 包含骨架屏加载、空状态、错误状态等完整交互 - 支持按角色筛选搜索结果
This commit is contained in:
parent
2b44aca254
commit
236b368537
367
src/components/features/SearchModal.tsx
Normal file
367
src/components/features/SearchModal.tsx
Normal file
@ -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<HTMLInputElement>(null);
|
||||||
|
const resultsRef = useRef<HTMLDivElement>(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;
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
onClick={() => setRole(filterRole)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-all',
|
||||||
|
'border',
|
||||||
|
role === filterRole
|
||||||
|
? 'bg-[var(--color-primary-alpha)] border-[var(--color-primary)] text-[var(--color-primary)]'
|
||||||
|
: 'bg-[var(--color-bg-primary)] border-[var(--color-border)] text-[var(--color-text-secondary)] hover:border-[var(--color-primary)] hover:text-[var(--color-primary)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={14} />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 遮罩层 */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[100] animate-fade-in-fast"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 模态框 */}
|
||||||
|
<div
|
||||||
|
className="fixed top-[10vh] left-1/2 -translate-x-1/2 w-full max-w-[640px] z-[101] px-4"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<div className="bg-[var(--color-bg-primary)] rounded shadow-2xl overflow-hidden animate-scale-in-fast">
|
||||||
|
{/* 搜索输入区 */}
|
||||||
|
<div className="flex items-center gap-3 px-5 py-4 border-b border-[var(--color-border-light)]">
|
||||||
|
<Search
|
||||||
|
size={20}
|
||||||
|
className="text-[var(--color-text-tertiary)] flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
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' && (
|
||||||
|
<button
|
||||||
|
onClick={handleClear}
|
||||||
|
className="flex items-center justify-center w-6 h-6 rounded bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{status === 'loading' && (
|
||||||
|
<Loader2
|
||||||
|
size={18}
|
||||||
|
className="text-[var(--color-primary)] animate-spin"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-2.5 py-1 text-xs text-[var(--color-text-secondary)] border border-[var(--color-border)] rounded-md hover:bg-[var(--color-bg-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
ESC
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 筛选器 */}
|
||||||
|
<div className="flex items-center gap-2 px-5 py-3 bg-[var(--color-bg-secondary)] border-b border-[var(--color-border-light)]">
|
||||||
|
<RoleFilterButton
|
||||||
|
filterRole="all"
|
||||||
|
icon={MessageSquare}
|
||||||
|
label="全部"
|
||||||
|
/>
|
||||||
|
<RoleFilterButton
|
||||||
|
filterRole="user"
|
||||||
|
icon={User}
|
||||||
|
label="我的消息"
|
||||||
|
/>
|
||||||
|
<RoleFilterButton
|
||||||
|
filterRole="assistant"
|
||||||
|
icon={Bot}
|
||||||
|
label="AI 回复"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索结果区 */}
|
||||||
|
<div
|
||||||
|
ref={resultsRef}
|
||||||
|
className="max-h-[400px] overflow-y-auto"
|
||||||
|
>
|
||||||
|
{/* 初始状态 */}
|
||||||
|
{status === 'idle' && !query && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div className="w-16 h-16 flex items-center justify-center bg-[var(--color-bg-tertiary)] rounded-full mb-4">
|
||||||
|
<MessageCircleQuestion
|
||||||
|
size={28}
|
||||||
|
className="text-[var(--color-text-tertiary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-base font-medium text-[var(--color-text-primary)] mb-2">
|
||||||
|
搜索历史消息
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--color-text-tertiary)] max-w-[280px]">
|
||||||
|
输入关键词搜索所有对话中的消息内容
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 加载状态 - 骨架屏 */}
|
||||||
|
{status === 'loading' && results.length === 0 && (
|
||||||
|
<div className="space-y-0">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex gap-3.5 px-5 py-4 border-b border-[var(--color-border-light)]"
|
||||||
|
>
|
||||||
|
<div className="w-9 h-9 rounded-full bg-[var(--color-bg-tertiary)] animate-pulse flex-shrink-0" />
|
||||||
|
<div className="flex-1 space-y-2.5">
|
||||||
|
<div className="h-4 w-48 bg-[var(--color-bg-tertiary)] rounded animate-pulse" />
|
||||||
|
<div className="h-4 w-full bg-[var(--color-bg-tertiary)] rounded animate-pulse" />
|
||||||
|
<div className="h-4 w-3/4 bg-[var(--color-bg-tertiary)] rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 搜索结果 */}
|
||||||
|
{results.length > 0 && (
|
||||||
|
<>
|
||||||
|
{/* 结果统计 */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-2.5 bg-[var(--color-bg-secondary)] border-b border-[var(--color-border-light)]">
|
||||||
|
<span className="text-xs text-[var(--color-text-tertiary)]">
|
||||||
|
找到 <strong className="text-[var(--color-text-secondary)]">{total}</strong> 条结果
|
||||||
|
</span>
|
||||||
|
<button className="flex items-center gap-1 text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors">
|
||||||
|
按时间排序
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 结果列表 */}
|
||||||
|
<div>
|
||||||
|
{results.map((result, index) => (
|
||||||
|
<SearchResultItem
|
||||||
|
key={result.messageId}
|
||||||
|
result={result}
|
||||||
|
query={query}
|
||||||
|
isSelected={index === selectedIndex}
|
||||||
|
onClick={() => handleNavigate(result)}
|
||||||
|
dataIndex={index}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 无结果状态 */}
|
||||||
|
{status === 'success' && results.length === 0 && query && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div className="w-16 h-16 flex items-center justify-center bg-[var(--color-bg-tertiary)] rounded-full mb-4">
|
||||||
|
<SearchX
|
||||||
|
size={28}
|
||||||
|
className="text-[var(--color-text-tertiary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-base font-medium text-[var(--color-text-primary)] mb-2">
|
||||||
|
未找到相关消息
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--color-text-tertiary)] max-w-[280px]">
|
||||||
|
试试其他关键词,或检查拼写是否正确
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 错误状态 */}
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div className="w-16 h-16 flex items-center justify-center bg-red-100 dark:bg-red-900/20 rounded-full mb-4">
|
||||||
|
<X size={28} className="text-red-500" />
|
||||||
|
</div>
|
||||||
|
<div className="text-base font-medium text-[var(--color-text-primary)] mb-2">
|
||||||
|
搜索出错
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--color-text-tertiary)] max-w-[280px]">
|
||||||
|
{error || '请稍后重试'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部快捷键提示 */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-3 bg-[var(--color-bg-secondary)] border-t border-[var(--color-border-light)]">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-[var(--color-text-tertiary)]">
|
||||||
|
<kbd className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded text-[10px]">
|
||||||
|
<ArrowUp size={10} />
|
||||||
|
</kbd>
|
||||||
|
<kbd className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded text-[10px]">
|
||||||
|
<ArrowDown size={10} />
|
||||||
|
</kbd>
|
||||||
|
<span>选择</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-[var(--color-text-tertiary)]">
|
||||||
|
<kbd className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded text-[10px]">
|
||||||
|
<CornerDownLeft size={10} />
|
||||||
|
</kbd>
|
||||||
|
<span>打开</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-[var(--color-text-tertiary)]">
|
||||||
|
<kbd className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded text-[11px]">
|
||||||
|
esc
|
||||||
|
</kbd>
|
||||||
|
<span>关闭</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
src/components/features/SearchResultItem.tsx
Normal file
198
src/components/features/SearchResultItem.tsx
Normal file
@ -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 (
|
||||||
|
<mark
|
||||||
|
key={index}
|
||||||
|
className="bg-[rgba(224,107,62,0.25)] text-[var(--color-primary)] px-0.5 rounded font-medium"
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</mark>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
data-index={dataIndex}
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'flex gap-3.5 px-5 py-4 cursor-pointer transition-colors',
|
||||||
|
'border-b border-[var(--color-border-light)] last:border-b-0',
|
||||||
|
isSelected
|
||||||
|
? 'bg-[var(--color-bg-hover)]'
|
||||||
|
: 'hover:bg-[var(--color-bg-hover)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 头像 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-9 h-9 rounded-full flex-shrink-0 flex items-center justify-center',
|
||||||
|
isUser
|
||||||
|
? 'bg-[var(--color-message-user)] text-[var(--color-text-secondary)]'
|
||||||
|
: 'bg-gradient-to-br from-[var(--color-primary)] to-[#D4643E] text-white'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isUser ? <User size={18} /> : <Bot size={18} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容 */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* 元信息 */}
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="text-sm font-medium text-[var(--color-text-primary)] truncate max-w-[200px]">
|
||||||
|
{result.conversationTitle}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-[11px] px-2 py-0.5 rounded-full flex-shrink-0',
|
||||||
|
'bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isUser ? '我' : 'AI'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--color-text-tertiary)] ml-auto flex-shrink-0">
|
||||||
|
{formatTime(result.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 消息内容 */}
|
||||||
|
<div className="text-sm text-[var(--color-text-secondary)] leading-relaxed line-clamp-2">
|
||||||
|
{highlightedContent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user