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:
Your Name
2026-09-08 02:27:12 +08:00
co-authored by Claude Sonnet 5
parent 0b48346d02
commit 80bf0aa41f
4 changed files with 216 additions and 2 deletions
+28 -1
View File
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon'; import { Icon } from '@/app/Icon';
import { HistorySearchModal } from '@/features/messages/HistorySearchModal';
import { import {
blockUser, blockUser,
deleteFriend, deleteFriend,
@@ -14,6 +15,7 @@ import {
updateConversationSettings, updateConversationSettings,
updateFriendRemark, updateFriendRemark,
} from '@/lib/api/friends'; } from '@/lib/api/friends';
import type { MediaType } from '@/lib/api/messages';
import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useTinode } from '@/lib/tinode/hooks'; import { useTinode } from '@/lib/tinode/hooks';
@@ -46,6 +48,7 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [remarkEditing, setRemarkEditing] = useState(false); const [remarkEditing, setRemarkEditing] = useState(false);
const [remarkDraft, setRemarkDraft] = useState(''); const [remarkDraft, setRemarkDraft] = useState('');
const [historyTab, setHistoryTab] = useState<'text' | MediaType | null>(null);
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => getFriends() }); const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => getFriends() });
const friend = friendsQuery.data?.items.find((f) => f.user_id === topicId); 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> </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 ? ( {remarkEditing ? (
<div className="flex items-center gap-2 py-3"> <div className="flex items-center gap-2 py-3">
<span className="flex-none text-text-body">备注</span> <span className="flex-none text-text-body">备注</span>
@@ -195,6 +220,8 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
> >
删除好友 删除好友
</button> </button>
{historyTab ? <HistorySearchModal topicId={topicId} initialTab={historyTab} onClose={() => setHistoryTab(null)} /> : null}
</aside> </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>
);
}
+45 -1
View File
@@ -1,7 +1,7 @@
// Message-level REST endpoints that sit alongside the native WS protocol. Recall goes through REST // Message-level REST endpoints that sit alongside the native WS protocol. Recall goes through REST
// (not a Tinode WS operation) -- ported from the old app's context-menu.jsx recallOwnMessage, which // (not a Tinode WS operation) -- ported from the old app's context-menu.jsx recallOwnMessage, which
// calls this then hard-deletes the message from the local topic cache itself. // calls this then hard-deletes the message from the local topic cache itself.
import { imPost } from './client'; import { imGet, imPost } from './client';
export interface RecallResult { export interface RecallResult {
topic: string; topic: string;
@@ -24,3 +24,47 @@ export interface RecallResult {
export function recallMessage(topic: string, seq: number, version = 1, reason?: string) { export function recallMessage(topic: string, seq: number, version = 1, reason?: string) {
return imPost<RecallResult>(`/topics/${encodeURIComponent(topic)}/messages/${seq}/recall`, { version, reason }); return imPost<RecallResult>(`/topics/${encodeURIComponent(topic)}/messages/${seq}/recall`, { version, reason });
} }
export interface SearchTextHit {
seq: number;
from: string;
ts: string;
snippet: string;
matches?: Array<{ start: number; end: number }>;
}
export interface SearchFileHit {
seq: number;
from: string;
ts: string;
file: { file_id: string; name: string; size: number; mime: string };
availability: string;
}
/** README §3.4's "查找聊天记录" -- ported endpoint shapes from docs/IM-前后端对接文档.md:1251-1252. */
export function searchTopicMessages(topic: string, query: string, type: 'text' | 'file' = 'text', cursor = '') {
const qs = new URLSearchParams({ q: query, type });
if (cursor) qs.set('cursor', cursor);
return imGet<{ topic: string; items: Array<SearchTextHit | SearchFileHit>; next_cursor?: string }>(
`/topics/${encodeURIComponent(topic)}/messages/search?${qs.toString()}`,
);
}
export type MediaType = 'image' | 'video' | 'file' | 'link';
export interface MediaItem {
seq: number;
thumbnail_file_id?: string;
file_id?: string;
width?: number;
height?: number;
ts: string;
availability: string;
}
/** README §3.4's 4-icon grid (图片/视频/文件/链接). */
export function getTopicMedia(topic: string, type: MediaType, cursor = '') {
const qs = new URLSearchParams({ type });
if (cursor) qs.set('cursor', cursor);
return imGet<{ topic: string; items: MediaItem[]; next_cursor?: string }>(`/topics/${encodeURIComponent(topic)}/media?${qs.toString()}`);
}
+11
View File
@@ -0,0 +1,11 @@
import { useEffect, useState } from 'react';
/** Ported from the old app's use-cached-data.js -- debounces a fast-changing value (e.g. search input). */
export function useDebouncedValue<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = window.setTimeout(() => setDebounced(value), delay);
return () => window.clearTimeout(timer);
}, [value, delay]);
return debounced;
}