fix(web): history search used response shapes the real server doesn't send

Confirmed live against the real backend: docs/IM-前后端对接文档.md's flat
`/media` items ({thumbnail_file_id,file_id,width,height}) and flat search
hits ({file:{...}}, {url,title,...}) don't match what the server actually
returns. Both endpoints really return the raw original message (`{txt,fmt,
ent}`, search hits wrapped one level deeper in `snapshot`) -- the same shape
lib/messages/drafty.tsx's renderDrafty already knows how to render, reused
here instead of hand-picking fields from a shape that doesn't exist. This is
also why image/video thumbnails were rendering as generic icons: the field
they were reading was never present in a real response.

Extracted useAuthedBlobUrl (authenticated file-fetch-to-blob-URL) out of
AttachmentPreview into lib/hooks.ts so the history modal's own thumbnail
grid can reuse it instead of duplicating the fetch/header logic.

Also adds the URL safety-check gate (docs/IM-前后端对接文档.md:1822,1898)
before opening a link found via the 链接 tab, which this view didn't have
at all before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-08 03:58:22 +08:00
co-authored by Claude Sonnet 5
parent 93d4077d1e
commit 1bd8d7f804
5 changed files with 255 additions and 88 deletions
+130 -15
View File
@@ -1,11 +1,12 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useMutation, 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 { getTopicMedia, searchTopicMessages, type MediaFetchType, type MediaItem, type MediaType, type SearchHit } from '@/lib/api/messages';
import { checkUrlSafety } from '@/lib/api/security';
import { useAuthedBlobUrl, useDebouncedValue } from '@/lib/hooks';
import { renderDrafty } from '@/lib/messages/drafty';
import { formatEventTimestamp } from '@/lib/messages/time';
type Tab = 'text' | MediaType;
@@ -18,8 +19,104 @@ const TABS: { key: Tab; label: string; icon: string }[] = [
{ key: 'link', label: '链接', icon: 'link' },
];
function isFileHit(item: SearchTextHit | SearchFileHit): item is SearchFileHit {
return 'file' in item;
function formatDuration(ms: number | undefined): string {
const totalSeconds = Math.round((ms || 0) / 1000);
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
}
/** README §3.4 grid thumbnails -- same inline-vs-authenticated-fetch duality as AttachmentPreview's
* ImageAttachment (a grid cell needs its own aspect-square crop, so this doesn't reuse that
* component directly, but reads the same `ent[0].data.{val,ref}` shape). */
function MediaThumb({ item, isVideo }: { item: MediaItem; isVideo: boolean }) {
const data = item.ent?.[0]?.data;
const inline = typeof data?.val === 'string' ? `data:${data.mime || 'image/jpeg'};base64,${data.val}` : null;
const remote = useAuthedBlobUrl(inline ? undefined : data?.ref);
const src = inline || remote.url;
return (
<div className="relative aspect-square overflow-hidden bg-surface-rail">
{src ? (
<img src={src} alt="" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center">
<Icon name={remote.error ? 'broken_image' : 'image'} className="text-[24px] text-text-tertiary-3" />
</div>
)}
{isVideo && src ? (
<div className="absolute inset-0 bg-black/10">
<Icon name="play_circle" className="absolute inset-0 m-auto text-[32px] text-white/90" />
{data?.duration ? (
<span className="absolute bottom-1 right-1.5 rounded bg-black/50 px-1 text-[11px] text-white">{formatDuration(data.duration)}</span>
) : null}
</div>
) : null}
</div>
);
}
/** Renders a search hit's original message content (text/file/voice/...) via the same Drafty
* formatter regular chat bubbles use -- `snapshot.content` is a real, complete Drafty document
* (docs/IM-前后端对接文档.md's flat `SearchFileHit`-style shape doesn't actually exist on this
* server; see messages.ts's SearchHit doc comment), so this passes it straight through rather than
* picking fields back out of it by hand. */
function SnapshotContent({ item }: { item: SearchHit }) {
const content = item.snapshot?.content;
if (!content) return <span className="text-text-tertiary-4">(内容不可用)</span>;
return (
<div className="text-sm text-text-body">
{renderDrafty({
txt: content.txt || ' ',
fmt: (content.fmt as { at?: number; len?: number; tp?: string; key?: number }[] | undefined) || [{ at: -1 }],
ent: (content.ent || []).map((e) => ({ tp: e.tp || '', data: (e.data || {}) as Record<string, unknown> })),
})}
</div>
);
}
/**
* docs/IM-前后端对接文档.md:1822,1898 -- links found in chat history must pass a server-side risk
* check before opening (the server fetches/classifies the URL out-of-band; the client never probes
* it directly). `warn` still lets the user proceed after an explicit confirm; `block` does not.
* This only guards the enrichment shape (`item.url` etc, per docs) -- if a hit doesn't carry that
* (unconfirmed live, see SearchHit's doc comment), it falls back to the plain snapshot rendering,
* whose inline links aren't gated here (same as any other chat-bubble link).
*/
function LinkResult({ item }: { item: SearchHit & { url?: string; title?: string; summary?: string } }) {
const safetyCheck = useMutation({ mutationFn: (url: string) => checkUrlSafety(url, 'history_search') });
if (!item.url) return <SnapshotContent item={item} />;
const open = async () => {
let result;
try {
result = await safetyCheck.mutateAsync(item.url!);
} catch {
window.alert('链接安全检查失败,请稍后重试。');
return;
}
if (result.decision === 'block') {
window.alert('该链接存在安全风险,已被拦截。');
return;
}
if (result.decision === 'warn' && !window.confirm('该链接可能存在风险,是否仍要打开?')) return;
window.open(item.url, '_blank', 'noopener,noreferrer');
};
return (
<div className="rounded-lg bg-surface-rail px-3.5 py-3">
<div className="text-xs text-text-tertiary-3">{formatEventTimestamp(new Date(item.ts))}</div>
{item.title ? <div className="mt-0.5 truncate text-sm font-medium text-text-body">{item.title}</div> : null}
<button
type="button"
onClick={open}
disabled={safetyCheck.isPending}
className="mt-0.5 line-clamp-2 break-all text-left text-sm text-accent disabled:opacity-60"
>
{item.url}
</button>
{item.summary ? <div className="mt-0.5 line-clamp-2 text-xs text-text-tertiary-2">{item.summary}</div> : null}
</div>
);
}
interface HistorySearchModalProps {
@@ -42,8 +139,14 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
const mediaQuery = useQuery({
queryKey: ['topic-media', topicId, tab],
queryFn: () => getTopicMedia(topicId, tab as MediaType),
enabled: tab !== 'text',
queryFn: () => getTopicMedia(topicId, tab as MediaFetchType),
enabled: tab === 'image' || tab === 'video',
});
const fileLinkQuery = useQuery({
queryKey: ['topic-search-media', topicId, tab],
queryFn: () => searchTopicMessages(topicId, '', tab as 'file' | 'link'),
enabled: tab === 'file' || tab === 'link',
});
return (
@@ -100,8 +203,8 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
<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 className="mt-0.5 truncate">
<SnapshotContent item={item} />
</div>
</li>
))}
@@ -109,22 +212,34 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
)}
</div>
</div>
) : (
) : tab === 'image' || tab === 'video' ? (
<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">
<div className="grid grid-cols-4 gap-0.5 overflow-hidden rounded-lg">
{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>
<MediaThumb key={item.seq} item={item} isVideo={tab === 'video'} />
))}
</div>
)}
</div>
) : (
<div className="flex-1 overflow-y-auto p-5">
{fileLinkQuery.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : !fileLinkQuery.data?.items.length ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无内容</div>
) : (
<div className="flex flex-col gap-2">
{fileLinkQuery.data.items.map((item) =>
tab === 'link' ? <LinkResult key={item.seq} item={item} /> : <SnapshotContent key={item.seq} item={item} />,
)}
</div>
)}
</div>
)}
</div>
</div>