feat: unified in-app attachment preview (image/PDF/text/docx via mammoth/xlsx via SheetJS/audio/video) with lazy-loaded renderers

This commit is contained in:
root
2026-10-16 23:23:08 +08:00
parent c25c39a0aa
commit 3ba44aa2de
22 changed files with 902 additions and 358 deletions
@@ -5,13 +5,12 @@ import { attachmentRead, attachmentThumb, footprintGet, listDimensions, listType
import type { Attachment, FormField } from '@/lib/types';
import { Badge, Button, Confirm, Dialog, useToast } from '@/components/ui';
import { StatusBadge, LevelBadge, GradeBadge, RoleBadge, NegativeBadge } from '@/components/common/badges';
import { openOrDownload } from '@/lib/file';
import { AttachmentPreview } from '@/components/common/AttachmentPreview';
import { makeThumb } from '@/lib/img';
import { fmtRange, fmtDateTime, fmtSize } from '@/lib/format';
// 缩略图/原图会话级缓存:同一附件一次会话只读一次
// 缩略图会话级缓存:同一附件一次会话只读一次(原图缓存统一在 lib/att-cache)
const thumbCache = new Map<number, string>();
const fullCache = new Map<number, string>();
export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
open: boolean;
@@ -36,6 +35,7 @@ export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
.filter(Boolean) as string[];
const [confirmOpen, setConfirmOpen] = useState(false);
const [previewAtt, setPreviewAtt] = useState<Attachment | null>(null);
const delMut = useMutation({
mutationFn: () => import('@/lib/api').then(m => m.footprintDelete(footprintId!)),
onSuccess: () => {
@@ -48,19 +48,7 @@ export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
}
});
const openAtt = async (a: Attachment) => {
try {
let b64 = fullCache.get(a.id);
if (!b64) {
const r = await attachmentRead(a.id);
b64 = r.data_b64;
if ((a.size ?? 0) <= 2 * 1024 * 1024) fullCache.set(a.id, b64); // 大原图不驻留内存
}
openOrDownload(a.file_name, a.mime ?? undefined, b64);
} catch (e) {
toast('error', (e as Error).message);
}
};
// 图片直出:逐张读取为 data URL(渐进渲染)
const [imgMap, setImgMap] = useState<Record<number, string>>({});
@@ -217,8 +205,8 @@ export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
<button
key={a.id}
className="group relative aspect-square overflow-hidden rounded-md border border-line bg-surface-2"
onClick={() => openAtt(a)}
title={'点击查看原图:' + a.file_name}
onClick={() => setPreviewAtt(a)}
title={'点击预览:' + a.file_name}
>
{imgMap[a.id] ? (
<img
@@ -243,7 +231,7 @@ export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
<button
key={a.id}
className="flex items-center gap-2 rounded-md border border-line bg-surface px-2.5 py-2 text-left text-[12.5px] transition-colors hover:border-line-strong"
onClick={() => openAtt(a)}
onClick={() => setPreviewAtt(a)}
>
<FileText size={14} className="shrink-0 text-ink-3" />
<span className="truncate text-ink-1">{a.file_name}</span>
@@ -253,7 +241,7 @@ export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
))}
</div>
)}
<div className="text-[11px] text-ink-3">点击图片查看原图(原始比例)</div>
<div className="text-[11px] text-ink-3">点击图片 / 文件可在线预览(图片 · PDF · 文本 · Word · Excel · 音视频)</div>
</div>
) : (
<p className="text-[12px] text-ink-3">暂无佐证附件</p>
@@ -262,6 +250,8 @@ export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
</div>
)}
<AttachmentPreview att={previewAtt} onClose={() => setPreviewAtt(null)} />
<Confirm
open={confirmOpen}
title="删除记录"
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
attachmentAdd, attachmentDelete, attachmentList, attachmentRead,
attachmentAdd, attachmentDelete, attachmentList,
footprintGet, footprintList, footprintUpsert, listDimensions, listTaxonomy, listTypes
} from '@/lib/api';
import { AttachmentPreview } from '@/components/common/AttachmentPreview';
import type { Attachment } from '@/lib/types';
import type { FootprintInput } from '@/lib/types';
import { Button, Dialog, Field, Input, Select, Textarea, useToast } from '@/components/ui';
@@ -49,7 +50,6 @@ const STATUS_OPTS = [
{ value: 'finished', label: '已完结' }
];
import { openOrDownload } from '@/lib/file';
export function RecordEditorDialog({ open, onClose, footprintId, onSaved }: {
open: boolean;
@@ -62,6 +62,7 @@ export function RecordEditorDialog({ open, onClose, footprintId, onSaved }: {
const [form, setForm] = useState<FormState>(BLANK);
const [pendingAtts, setPendingAtts] = useState<PendingAtt[]>([]);
const [uploadingAtt, setUploadingAtt] = useState(false);
const [previewAtt, setPreviewAtt] = useState<Attachment | null>(null);
const [error, setError] = useState('');
const typesQ = useQuery({ queryKey: ['types'], queryFn: listTypes, enabled: open });
@@ -362,17 +363,10 @@ export function RecordEditorDialog({ open, onClose, footprintId, onSaved }: {
}}
onDeleteSaved={id => delAtt.mutate(id)}
onDeletePending={() => {}}
onOpenSaved={async (a: Attachment) => {
try {
const r = await attachmentRead(a.id);
openOrDownload(r.file_name, r.mime ?? undefined, r.data_b64);
} catch (e) {
toast('error', (e as Error).message);
}
}}
onOpenSaved={a => setPreviewAtt(a)}
/>
<div className="mt-1.5 text-[11px] text-ink-3">
点击文件名可预览(支持图片 / PDF,其余下载)· 单个文件不超过 80MB
点击文件名可在线预览(图片 · PDF · 文本 · Word · Excel · 音视频)· 单个文件不超过 80MB
</div>
</div>
) : (
@@ -400,6 +394,7 @@ export function RecordEditorDialog({ open, onClose, footprintId, onSaved }: {
</div>
)}
</div>
<AttachmentPreview att={previewAtt} onClose={() => setPreviewAtt(null)} />
</Dialog>
);
}