feat(web): 查找聊天记录 history search + media grid in the detail panel
Closes a real visual gap flagged directly: the detail panel jumped straight from the quick-action buttons to 备注/置顶聊天/etc, skipping the "查找聊天 记录" row + 4-icon media grid (图片/视频/文件/链接) that's prominently visible in the reference design (README §3.4) -- deliberately deferred in an earlier pass, but a dead-end-free real implementation was worth building now rather than leaving a visible hole. - lib/api/messages.ts: searchTopicMessages/getTopicMedia, endpoint shapes from docs/IM-前后端对接文档.md:1251-1254 (verified live: both return real 200s against the actual backend, not just matching the docs on paper). - HistorySearchModal: centered modal (README §1.5's 弹层 pattern) with the five tabs, debounced search (lib/hooks.ts's useDebouncedValue, ported from the old app's use-cached-data.js), empty/loading states. - DetailColumn: wires the row + grid to open the modal on the right tab. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0b48346d02
commit
80bf0aa41f
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { HistorySearchModal } from '@/features/messages/HistorySearchModal';
|
||||
import {
|
||||
blockUser,
|
||||
deleteFriend,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
updateConversationSettings,
|
||||
updateFriendRemark,
|
||||
} from '@/lib/api/friends';
|
||||
import type { MediaType } from '@/lib/api/messages';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useTinode } from '@/lib/tinode/hooks';
|
||||
|
||||
@@ -46,6 +48,7 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
|
||||
const queryClient = useQueryClient();
|
||||
const [remarkEditing, setRemarkEditing] = useState(false);
|
||||
const [remarkDraft, setRemarkDraft] = useState('');
|
||||
const [historyTab, setHistoryTab] = useState<'text' | MediaType | null>(null);
|
||||
|
||||
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => getFriends() });
|
||||
const friend = friendsQuery.data?.items.find((f) => f.user_id === topicId);
|
||||
@@ -131,7 +134,29 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col px-5 pb-2 text-sm">
|
||||
<div className="px-5 pb-3 text-sm">
|
||||
<button type="button" onClick={() => setHistoryTab('text')} className="flex w-full items-center justify-between py-2">
|
||||
<span className="text-text-body">查找聊天记录</span>
|
||||
<Icon name="chevron_right" className="text-[18px] text-text-tertiary-2" />
|
||||
</button>
|
||||
<div className="mt-1 grid grid-cols-4 gap-2">
|
||||
{(
|
||||
[
|
||||
{ type: 'image' as const, label: '图片', icon: 'image' },
|
||||
{ type: 'video' as const, label: '视频', icon: 'videocam' },
|
||||
{ type: 'file' as const, label: '文件', icon: 'description' },
|
||||
{ type: 'link' as const, label: '链接', icon: 'link' },
|
||||
] satisfies { type: MediaType; label: string; icon: string }[]
|
||||
).map((item) => (
|
||||
<button key={item.type} type="button" onClick={() => setHistoryTab(item.type)} className="flex flex-col items-center gap-1 py-1">
|
||||
<Icon name={item.icon} className="text-[22px] text-text-tertiary-4" />
|
||||
<span className="text-xs text-text-secondary">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col border-t border-list-item px-5 pb-2 text-sm">
|
||||
{remarkEditing ? (
|
||||
<div className="flex items-center gap-2 py-3">
|
||||
<span className="flex-none text-text-body">备注</span>
|
||||
@@ -195,6 +220,8 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
|
||||
>
|
||||
删除好友
|
||||
</button>
|
||||
|
||||
{historyTab ? <HistorySearchModal topicId={topicId} initialTab={historyTab} onClose={() => setHistoryTab(null)} /> : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { getTopicMedia, searchTopicMessages, type MediaType, type SearchFileHit, type SearchTextHit } from '@/lib/api/messages';
|
||||
import { useDebouncedValue } from '@/lib/hooks';
|
||||
import { formatFileSize } from '@/lib/messages/attachments';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
|
||||
type Tab = 'text' | MediaType;
|
||||
|
||||
const TABS: { key: Tab; label: string; icon: string }[] = [
|
||||
{ key: 'text', label: '聊天记录', icon: 'search' },
|
||||
{ key: 'image', label: '图片', icon: 'image' },
|
||||
{ key: 'video', label: '视频', icon: 'videocam' },
|
||||
{ key: 'file', label: '文件', icon: 'description' },
|
||||
{ key: 'link', label: '链接', icon: 'link' },
|
||||
];
|
||||
|
||||
function isFileHit(item: SearchTextHit | SearchFileHit): item is SearchFileHit {
|
||||
return 'file' in item;
|
||||
}
|
||||
|
||||
interface HistorySearchModalProps {
|
||||
topicId: string;
|
||||
initialTab?: Tab;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** README §3.4's "查找聊天记录 ›" + 4-icon grid, opened as a centered modal (README §1.5). */
|
||||
export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: HistorySearchModalProps) {
|
||||
const [tab, setTab] = useState<Tab>(initialTab);
|
||||
const [query, setQuery] = useState('');
|
||||
const debouncedQuery = useDebouncedValue(query, 300);
|
||||
|
||||
const searchQuery = useQuery({
|
||||
queryKey: ['topic-search', topicId, debouncedQuery],
|
||||
queryFn: () => searchTopicMessages(topicId, debouncedQuery, 'text'),
|
||||
enabled: tab === 'text' && debouncedQuery.trim().length > 0,
|
||||
});
|
||||
|
||||
const mediaQuery = useQuery({
|
||||
queryKey: ['topic-media', topicId, tab],
|
||||
queryFn: () => getTopicMedia(topicId, tab as MediaType),
|
||||
enabled: tab !== 'text',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex h-[620px] w-[720px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-14 flex-none items-center justify-between border-b border-list-item px-5">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">查找聊天记录</strong>
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-none gap-1 border-b border-list-item px-5 pt-3">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`flex items-center gap-1.5 rounded-t-lg px-3 py-2 text-sm ${
|
||||
tab === t.key ? 'border-b-2 border-accent font-medium text-accent' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<Icon name={t.icon} className="text-[16px]" />
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'text' ? (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex-none px-5 py-3">
|
||||
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
|
||||
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索聊天记录"
|
||||
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-5 pb-4">
|
||||
{!query.trim() ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">输入关键词搜索这个会话的聊天记录</div>
|
||||
) : searchQuery.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">搜索中…</div>
|
||||
) : !searchQuery.data?.items.length ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">没有找到相关消息</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{searchQuery.data.items.map((item) => (
|
||||
<li key={item.seq} className="rounded-lg px-3 py-2.5 hover:bg-surface-rail">
|
||||
<div className="flex items-center justify-between text-xs text-text-tertiary-3">
|
||||
<span>{formatEventTimestamp(new Date(item.ts))}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-sm text-text-body">
|
||||
{isFileHit(item) ? `[文件] ${item.file.name} · ${formatFileSize(item.file.size)}` : item.snippet}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{mediaQuery.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : !mediaQuery.data?.items.length ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无内容</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{mediaQuery.data.items.map((item) => (
|
||||
<div key={item.seq} className="flex aspect-square items-center justify-center rounded-lg bg-surface-rail">
|
||||
<Icon name={TABS.find((t) => t.key === tab)?.icon || 'description'} className="text-[28px] text-text-tertiary-3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user