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 { 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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()}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user