Compare commits
5 Commits
56b7ffa68d
...
0f8fd2ce1f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f8fd2ce1f | ||
|
|
57e8631e10 | ||
|
|
236b368537 | ||
|
|
2b44aca254 | ||
|
|
dcd757e584 |
146
src/app/api/messages/search/route.ts
Normal file
146
src/app/api/messages/search/route.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/drizzle/db';
|
||||
import { messages, conversations } from '@/drizzle/schema';
|
||||
import { getCurrentUser } from '@/lib/auth';
|
||||
import { eq, and, ilike, desc, sql, or } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* 搜索结果类型
|
||||
*/
|
||||
export interface SearchResult {
|
||||
messageId: string;
|
||||
conversationId: string;
|
||||
conversationTitle: string;
|
||||
role: string;
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/messages/search - 全局消息搜索
|
||||
*
|
||||
* Query Parameters:
|
||||
* - q: string (必填) - 搜索关键词
|
||||
* - role: 'user' | 'assistant' | 'all' (可选) - 角色筛选,默认 'all'
|
||||
* - page: number (可选) - 页码,默认 1
|
||||
* - limit: number (可选) - 每页数量,默认 20,最大 50
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
// 验证用户身份
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: '请先登录' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 解析查询参数
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q')?.trim();
|
||||
const role = searchParams.get('role') || 'all';
|
||||
const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10));
|
||||
const limit = Math.min(50, Math.max(1, parseInt(searchParams.get('limit') || '20', 10)));
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// 验证搜索关键词
|
||||
if (!query || query.length < 1) {
|
||||
return NextResponse.json(
|
||||
{ error: '请输入搜索关键词' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 关键词长度限制
|
||||
if (query.length > 100) {
|
||||
return NextResponse.json(
|
||||
{ error: '搜索关键词过长,请限制在 100 字符以内' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 构建搜索条件
|
||||
const searchPattern = `%${query}%`;
|
||||
|
||||
// 基础条件:用户的对话
|
||||
const baseConditions = [
|
||||
eq(conversations.userId, user.userId),
|
||||
eq(conversations.isArchived, false), // 排除已归档对话
|
||||
];
|
||||
|
||||
// 角色筛选条件
|
||||
const roleCondition = role === 'all'
|
||||
? or(eq(messages.role, 'user'), eq(messages.role, 'assistant'))
|
||||
: eq(messages.role, role);
|
||||
|
||||
// 内容搜索条件
|
||||
const contentCondition = ilike(messages.content, searchPattern);
|
||||
|
||||
// 执行搜索查询
|
||||
const results = await db
|
||||
.select({
|
||||
messageId: messages.messageId,
|
||||
conversationId: messages.conversationId,
|
||||
conversationTitle: conversations.title,
|
||||
role: messages.role,
|
||||
content: messages.content,
|
||||
createdAt: messages.createdAt,
|
||||
})
|
||||
.from(messages)
|
||||
.innerJoin(
|
||||
conversations,
|
||||
eq(messages.conversationId, conversations.conversationId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
...baseConditions,
|
||||
roleCondition,
|
||||
contentCondition
|
||||
)
|
||||
)
|
||||
.orderBy(desc(messages.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
// 获取总数(用于分页)
|
||||
const countResult = await db
|
||||
.select({
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(messages)
|
||||
.innerJoin(
|
||||
conversations,
|
||||
eq(messages.conversationId, conversations.conversationId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
...baseConditions,
|
||||
roleCondition,
|
||||
contentCondition
|
||||
)
|
||||
);
|
||||
|
||||
const total = countResult[0]?.count || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
results,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages,
|
||||
query,
|
||||
role,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Search messages error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '搜索失败,请稍后重试' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -30,14 +30,19 @@ export default function ChatPage({ params }: PageProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const initialMessage = searchParams.get('message');
|
||||
const highlightParam = searchParams.get('highlight'); // 搜索高亮参数
|
||||
const { user } = useAuth();
|
||||
const { setOptimizedPrompt } = usePromptOptimizer();
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isNewChat, setIsNewChat] = useState(false);
|
||||
const [initialMessageSent, setInitialMessageSent] = useState(false);
|
||||
|
||||
// 高亮消息状态
|
||||
const [highlightedMessageId, setHighlightedMessageId] = useState<string | null>(null);
|
||||
|
||||
// 标题下拉菜单状态
|
||||
const [titleMenuOpen, setTitleMenuOpen] = useState(false);
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
@ -128,6 +133,36 @@ export default function ChatPage({ params }: PageProps) {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
// 处理搜索高亮:滚动到目标消息并高亮
|
||||
useEffect(() => {
|
||||
if (highlightParam && messages.length > 0) {
|
||||
// 查找目标消息
|
||||
const targetMessage = messages.find(m => m.id === highlightParam);
|
||||
if (targetMessage) {
|
||||
// 设置高亮状态
|
||||
setHighlightedMessageId(highlightParam);
|
||||
|
||||
// 延迟滚动,确保 DOM 已渲染
|
||||
setTimeout(() => {
|
||||
const messageElement = document.querySelector(`[data-message-id="${highlightParam}"]`);
|
||||
if (messageElement) {
|
||||
messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// 清除 URL 中的 highlight 参数
|
||||
router.replace(`/chat/${chatId}`, { scroll: false });
|
||||
|
||||
// 3秒后清除高亮状态
|
||||
const timer = setTimeout(() => {
|
||||
setHighlightedMessageId(null);
|
||||
}, 3000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [highlightParam, messages, chatId, router]);
|
||||
|
||||
// 聚焦标题输入框
|
||||
useEffect(() => {
|
||||
if (isEditingTitle && titleInputRef.current) {
|
||||
@ -595,6 +630,7 @@ export default function ChatPage({ params }: PageProps) {
|
||||
onSaveToNote={message.role === 'assistant' && !isStreaming ? handleSaveToNote : undefined}
|
||||
onLinkClick={handleLinkClick}
|
||||
conversationId={chatId}
|
||||
isHighlighted={highlightedMessageId === message.id}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@ -616,3 +616,36 @@ pre[class*="language-"] {
|
||||
[data-theme="dark"] .token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
消息搜索高亮动画
|
||||
用于搜索跳转后高亮显示目标消息
|
||||
风格:轻量闪烁,类似搜索引擎高亮
|
||||
======================================== */
|
||||
@keyframes messageHighlight {
|
||||
0% {
|
||||
background-color: rgba(255, 235, 120, 0.6);
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.message-highlight {
|
||||
animation: messageHighlight 2.5s ease-out forwards;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* 暗色模式下使用更暗的黄色 */
|
||||
[data-theme="dark"] .message-highlight {
|
||||
animation-name: messageHighlightDark;
|
||||
}
|
||||
|
||||
@keyframes messageHighlightDark {
|
||||
0% {
|
||||
background-color: rgba(255, 200, 50, 0.25);
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,6 +56,8 @@ interface MessageBubbleProps {
|
||||
onLinkClick?: (url: string) => void;
|
||||
/** 对话 ID(用于关联笔记来源) */
|
||||
conversationId?: string;
|
||||
/** 是否高亮显示(搜索跳转时使用) */
|
||||
isHighlighted?: boolean;
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
@ -75,7 +77,7 @@ function getDocumentIcon(type: string) {
|
||||
return FileText;
|
||||
}
|
||||
|
||||
export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, searchImages, searchVideos, uploadedImages, uploadedDocuments, usedTools, pyodideStatus, onRegenerate, onSaveToNote, onLinkClick, conversationId }: MessageBubbleProps) {
|
||||
export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, searchImages, searchVideos, uploadedImages, uploadedDocuments, usedTools, pyodideStatus, onRegenerate, onSaveToNote, onLinkClick, conversationId, isHighlighted }: MessageBubbleProps) {
|
||||
const isUser = message.role === 'user';
|
||||
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
@ -138,7 +140,13 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="flex justify-end items-start gap-3 mb-8 animate-fade-in group">
|
||||
<div
|
||||
data-message-id={message.id}
|
||||
className={cn(
|
||||
"flex justify-end items-start gap-3 mb-8 animate-fade-in group",
|
||||
isHighlighted && "message-highlight"
|
||||
)}
|
||||
>
|
||||
<div className="max-w-[70%] relative">
|
||||
{/* 用户上传的图片 */}
|
||||
{uploadedImages && uploadedImages.length > 0 && (
|
||||
@ -227,7 +235,13 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-4 mb-8 animate-fade-in">
|
||||
<div
|
||||
data-message-id={message.id}
|
||||
className={cn(
|
||||
"flex items-start gap-4 mb-8 animate-fade-in",
|
||||
isHighlighted && "message-highlight"
|
||||
)}
|
||||
>
|
||||
{/* AI 图标 */}
|
||||
<div className="flex-shrink-0 mt-4">
|
||||
<AILogo size={28} />
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -2,14 +2,15 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { Plus, PanelLeft, Trash2, MoreHorizontal, Loader2, Pencil, Check, X, Bot, Bookmark } from 'lucide-react';
|
||||
import { Plus, PanelLeft, Trash2, MoreHorizontal, Loader2, Pencil, Check, X, Bot, Bookmark, Search } from 'lucide-react';
|
||||
import { UserMenu } from '@/components/ui/UserMenu';
|
||||
import { NewChatModal } from '@/components/features/NewChatModal';
|
||||
import { SearchModal } from '@/components/features/SearchModal';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConversations } from '@/hooks/useConversations';
|
||||
import { useAuth } from '@/providers/AuthProvider';
|
||||
import type { Conversation } from '@/drizzle/schema';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen?: boolean;
|
||||
@ -26,6 +27,7 @@ export function Sidebar({ isOpen = true }: SidebarProps) {
|
||||
const [editingTitle, setEditingTitle] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showNewChatModal, setShowNewChatModal] = useState(false);
|
||||
const [showSearchModal, setShowSearchModal] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@ -52,6 +54,22 @@ export function Sidebar({ isOpen = true }: SidebarProps) {
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
// 全局键盘快捷键:⌘K / Ctrl+K 打开搜索
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// ⌘K (Mac) 或 Ctrl+K (Windows/Linux)
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
setShowSearchModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 创建新对话 - 显示选择助手弹框
|
||||
const handleNewChat = () => {
|
||||
setShowNewChatModal(true);
|
||||
@ -142,6 +160,21 @@ export function Sidebar({ isOpen = true }: SidebarProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 搜索入口按钮 */}
|
||||
<div className="px-3 pb-3">
|
||||
<button
|
||||
onClick={() => setShowSearchModal(true)}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2.5 bg-[var(--color-bg-tertiary)] border border-[var(--color-border-light)] rounded text-[var(--color-text-tertiary)] text-sm hover:bg-[var(--color-bg-hover)] hover:border-[var(--color-border)] hover:text-[var(--color-text-secondary)] transition-all"
|
||||
>
|
||||
<Search size={16} className="flex-shrink-0" />
|
||||
<span className="flex-1 text-left">搜索消息...</span>
|
||||
<span className="flex items-center gap-0.5 text-[11px]">
|
||||
<kbd className="inline-flex items-center justify-center px-1.5 py-0.5 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded text-[10px] shadow-sm">⌘</kbd>
|
||||
<kbd className="inline-flex items-center justify-center px-1.5 py-0.5 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded text-[10px] shadow-sm">K</kbd>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 助手库入口 */}
|
||||
<div className="px-4 pb-2">
|
||||
<Link
|
||||
@ -319,6 +352,12 @@ export function Sidebar({ isOpen = true }: SidebarProps) {
|
||||
isOpen={showNewChatModal}
|
||||
onClose={() => setShowNewChatModal(false)}
|
||||
/>
|
||||
|
||||
{/* 搜索弹框 */}
|
||||
<SearchModal
|
||||
isOpen={showSearchModal}
|
||||
onClose={() => setShowSearchModal(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
290
src/hooks/useSearch.ts
Normal file
290
src/hooks/useSearch.ts
Normal file
@ -0,0 +1,290 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* 搜索结果项类型
|
||||
*/
|
||||
export interface SearchResult {
|
||||
messageId: string;
|
||||
conversationId: string;
|
||||
conversationTitle: string;
|
||||
role: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索响应类型
|
||||
*/
|
||||
interface SearchResponse {
|
||||
success: boolean;
|
||||
data?: {
|
||||
results: SearchResult[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
query: string;
|
||||
role: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索状态类型
|
||||
*/
|
||||
type SearchStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
/**
|
||||
* 搜索角色筛选类型
|
||||
*/
|
||||
export type SearchRole = 'all' | 'user' | 'assistant';
|
||||
|
||||
/**
|
||||
* useSearch Hook 返回类型
|
||||
*/
|
||||
interface UseSearchReturn {
|
||||
// 状态
|
||||
query: string;
|
||||
results: SearchResult[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
status: SearchStatus;
|
||||
error: string | null;
|
||||
role: SearchRole;
|
||||
|
||||
// 操作
|
||||
setQuery: (query: string) => void;
|
||||
setRole: (role: SearchRole) => void;
|
||||
search: (searchQuery?: string) => Promise<void>;
|
||||
loadMore: () => Promise<void>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息搜索 Hook
|
||||
*
|
||||
* 功能:
|
||||
* - 防抖搜索(300ms)
|
||||
* - 角色筛选
|
||||
* - 分页加载
|
||||
* - 结果缓存
|
||||
* - 状态管理
|
||||
*/
|
||||
export function useSearch(): UseSearchReturn {
|
||||
// 搜索状态
|
||||
const [query, setQueryState] = useState('');
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [status, setStatus] = useState<SearchStatus>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [role, setRoleState] = useState<SearchRole>('all');
|
||||
|
||||
// 防抖定时器
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 缓存最后一次搜索的参数,避免重复请求
|
||||
const lastSearchRef = useRef<{ query: string; role: SearchRole } | null>(null);
|
||||
|
||||
// AbortController 用于取消请求
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
/**
|
||||
* 执行搜索请求
|
||||
*/
|
||||
const doSearch = useCallback(async (
|
||||
searchQuery: string,
|
||||
searchRole: SearchRole,
|
||||
searchPage: number = 1,
|
||||
append: boolean = false
|
||||
) => {
|
||||
// 空查询时重置状态
|
||||
if (!searchQuery.trim()) {
|
||||
setResults([]);
|
||||
setTotal(0);
|
||||
setPage(1);
|
||||
setTotalPages(0);
|
||||
setStatus('idle');
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 取消之前的请求
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
setStatus('loading');
|
||||
setError(null);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
q: searchQuery.trim(),
|
||||
role: searchRole,
|
||||
page: searchPage.toString(),
|
||||
limit: '20',
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/messages/search?${params}`, {
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
|
||||
const data: SearchResponse = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || '搜索失败');
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
if (append && data.data) {
|
||||
setResults(prev => [...prev, ...data.data!.results]);
|
||||
} else if (data.data) {
|
||||
setResults(data.data.results);
|
||||
}
|
||||
|
||||
if (data.data) {
|
||||
setTotal(data.data.total);
|
||||
setPage(data.data.page);
|
||||
setTotalPages(data.data.totalPages);
|
||||
}
|
||||
setStatus('success');
|
||||
|
||||
// 更新缓存
|
||||
lastSearchRef.current = { query: searchQuery, role: searchRole };
|
||||
} catch (err) {
|
||||
// 忽略取消的请求
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
setStatus('error');
|
||||
setError(err instanceof Error ? err.message : '搜索失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 设置搜索关键词(带防抖)
|
||||
*/
|
||||
const setQuery = useCallback((newQuery: string) => {
|
||||
setQueryState(newQuery);
|
||||
|
||||
// 清除之前的定时器
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
// 空查询立即清空结果
|
||||
if (!newQuery.trim()) {
|
||||
setResults([]);
|
||||
setTotal(0);
|
||||
setPage(1);
|
||||
setTotalPages(0);
|
||||
setStatus('idle');
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置新的防抖定时器
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
doSearch(newQuery, role, 1, false);
|
||||
}, 300);
|
||||
}, [role, doSearch]);
|
||||
|
||||
/**
|
||||
* 设置角色筛选
|
||||
*/
|
||||
const setRole = useCallback((newRole: SearchRole) => {
|
||||
setRoleState(newRole);
|
||||
|
||||
// 如果有搜索关键词,立即重新搜索
|
||||
if (query.trim()) {
|
||||
// 清除防抖定时器
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
doSearch(query, newRole, 1, false);
|
||||
}
|
||||
}, [query, doSearch]);
|
||||
|
||||
/**
|
||||
* 手动触发搜索
|
||||
*/
|
||||
const search = useCallback(async (searchQuery?: string) => {
|
||||
const q = searchQuery ?? query;
|
||||
if (q.trim()) {
|
||||
// 清除防抖定时器
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
await doSearch(q, role, 1, false);
|
||||
}
|
||||
}, [query, role, doSearch]);
|
||||
|
||||
/**
|
||||
* 加载更多结果
|
||||
*/
|
||||
const loadMore = useCallback(async () => {
|
||||
if (status === 'loading' || page >= totalPages) {
|
||||
return;
|
||||
}
|
||||
|
||||
await doSearch(query, role, page + 1, true);
|
||||
}, [query, role, page, totalPages, status, doSearch]);
|
||||
|
||||
/**
|
||||
* 重置搜索状态
|
||||
*/
|
||||
const reset = useCallback(() => {
|
||||
// 清除防抖定时器
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
// 取消进行中的请求
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
setQueryState('');
|
||||
setResults([]);
|
||||
setTotal(0);
|
||||
setPage(1);
|
||||
setTotalPages(0);
|
||||
setStatus('idle');
|
||||
setError(null);
|
||||
setRoleState('all');
|
||||
lastSearchRef.current = null;
|
||||
}, []);
|
||||
|
||||
// 清理定时器和请求
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
query,
|
||||
results,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
status,
|
||||
error,
|
||||
role,
|
||||
setQuery,
|
||||
setRole,
|
||||
search,
|
||||
loadMore,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user