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:
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Download, Loader2 } from 'lucide-react';
|
||||
import { Button, Dialog } from '@/components/ui';
|
||||
import { readFull, b64ToObjectUrl } from '@/lib/att-cache';
|
||||
import type { Attachment } from '@/lib/types';
|
||||
|
||||
export type AttKind = 'image' | 'pdf' | 'text' | 'audio' | 'video' | 'docx' | 'xlsx' | 'other';
|
||||
|
||||
export function attKind(a: { file_name: string; mime?: string | null }): AttKind {
|
||||
const mime = (a.mime || '').toLowerCase();
|
||||
const ext = (a.file_name.split('.').pop() || '').toLowerCase();
|
||||
if (mime.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) return 'image';
|
||||
if (mime === 'application/pdf' || ext === 'pdf') return 'pdf';
|
||||
if (ext === 'docx' || mime.includes('wordprocessingml')) return 'docx';
|
||||
if (ext === 'xlsx' || ext === 'xls' || mime.includes('spreadsheetml') || mime === 'application/vnd.ms-excel') return 'xlsx';
|
||||
if (mime.startsWith('audio/') || ['mp3', 'wav', 'm4a', 'flac', 'ogg'].includes(ext)) return 'audio';
|
||||
if (mime.startsWith('video/') || ['mp4', 'mov', 'webm', 'mkv'].includes(ext)) return 'video';
|
||||
if (mime.startsWith('text/') || ['txt', 'md', 'csv', 'json', 'log', 'srt'].includes(ext)) return 'text';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function b64ToUint8(dataB64: string): Uint8Array<ArrayBuffer> {
|
||||
return Uint8Array.from(atob(dataB64), c => c.charCodeAt(0)) as Uint8Array<ArrayBuffer>;
|
||||
}
|
||||
|
||||
function forceDownload(fileName: string, mime: string | undefined | null, dataB64: string) {
|
||||
const url = URL.createObjectURL(new Blob([b64ToUint8(dataB64)], { type: mime || 'application/octet-stream' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
const KIND_LABEL: Record<AttKind, string> = {
|
||||
image: '图片', pdf: 'PDF 文档', text: '文本文件', audio: '音频', video: '视频',
|
||||
docx: 'Word 文档(.docx)', xlsx: 'Excel 表格(.xlsx)', other: '该类型暂不支持在线预览',
|
||||
};
|
||||
|
||||
export function AttachmentPreview({ att, onClose }: { att: Attachment | null; onClose: () => void }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [content, setContent] = useState<{ kind: AttKind; objectUrl?: string; html?: string; text?: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!att) { setContent(null); setError(''); return; }
|
||||
let cancelled = false;
|
||||
const created: string[] = [];
|
||||
setLoading(true); setError(''); setContent(null);
|
||||
(async () => {
|
||||
try {
|
||||
const kind = attKind(att);
|
||||
const { data_b64, mime } = await readFull(att);
|
||||
if (cancelled) return;
|
||||
if (kind === 'image' || kind === 'pdf' || kind === 'audio' || kind === 'video') {
|
||||
const url = b64ToObjectUrl(data_b64, mime);
|
||||
if (cancelled) { URL.revokeObjectURL(url); return; }
|
||||
created.push(url);
|
||||
setContent({ kind, objectUrl: url });
|
||||
} else if (kind === 'text') {
|
||||
const text = new TextDecoder().decode(b64ToUint8(data_b64));
|
||||
setContent({ kind, text: text.length > 400_000 ? text.slice(0, 400_000) + '\n…(已截断)' : text });
|
||||
} else if (kind === 'docx') {
|
||||
const mammoth = await import('mammoth/mammoth.browser') as any;
|
||||
const html = (await mammoth.convertToHtml({ arrayBuffer: b64ToUint8(data_b64).buffer })).value as string;
|
||||
if (cancelled) return;
|
||||
setContent({ kind, html });
|
||||
} else if (kind === 'xlsx') {
|
||||
const XLSX = await import('xlsx');
|
||||
const wb = XLSX.read(b64ToUint8(data_b64).buffer, { type: 'array' });
|
||||
const html = XLSX.utils.sheet_to_html(wb.Sheets[wb.SheetNames[0]]);
|
||||
if (cancelled) return;
|
||||
setContent({ kind, html });
|
||||
} else {
|
||||
setContent({ kind: 'other' });
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) setError((e as Error).message || '预览加载失败');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; created.forEach(u => URL.revokeObjectURL(u)); };
|
||||
}, [att?.id, att?.file_name]);
|
||||
|
||||
const kind = att ? attKind(att) : 'other';
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={!!att}
|
||||
onClose={onClose}
|
||||
width={kind === 'image' || kind === 'pdf' ? 880 : 680}
|
||||
title={(att ? '附件预览 · ' + att.file_name : '附件预览') + (att ? '(' + KIND_LABEL[kind] + ')' : '')}
|
||||
footer={att ? (
|
||||
<>
|
||||
<Button variant="secondary" className="mr-auto" onClick={() => { void readFull(att).then(r => forceDownload(att.file_name, att.mime, r.data_b64)); }}>
|
||||
<Download size={13} /> 下载
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onClose}>关闭</Button>
|
||||
</>
|
||||
) : null}
|
||||
>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-[13px] text-ink-3">
|
||||
<Loader2 className="animate-spin" size={16} /> 正在加载预览…
|
||||
</div>
|
||||
)}
|
||||
{!loading && !!error && <div className="py-10 text-center text-[13px] text-danger">{error}</div>}
|
||||
{!loading && !error && content && (
|
||||
<>
|
||||
{content.kind === 'image' && <img src={content.objectUrl} alt={att?.file_name} className="mx-auto max-h-[72vh] rounded-md" />}
|
||||
{content.kind === 'pdf' && <iframe src={content.objectUrl} title={att?.file_name} className="h-[72vh] w-full rounded-md border border-line bg-white" />}
|
||||
{content.kind === 'audio' && <audio controls src={content.objectUrl} className="w-full" />}
|
||||
{content.kind === 'video' && <video controls src={content.objectUrl} className="max-h-[70vh] w-full rounded-md bg-black" />}
|
||||
{content.kind === 'text' && <pre className="max-h-[68vh] overflow-auto whitespace-pre-wrap rounded-md bg-surface-2 p-3 text-[12.5px] leading-relaxed text-ink-1">{content.text}</pre>}
|
||||
{(content.kind === 'docx' || content.kind === 'xlsx') && (
|
||||
<div
|
||||
className="max-h-[70vh] overflow-auto rounded-md border border-line bg-white p-4 text-[13px] leading-relaxed [&_img]:max-w-full [&_table]:border-collapse [&_td]:border [&_td]:border-line [&_td]:px-2 [&_td]:py-1 [&_th]:border [&_th]:border-line [&_th]:px-2 [&_th]:py-1"
|
||||
dangerouslySetInnerHTML={{ __html: content.html || '' }}
|
||||
/>
|
||||
)}
|
||||
{content.kind === 'other' && (
|
||||
<div className="py-10 text-center">
|
||||
<p className="text-[13px] text-ink-2">该文件类型暂不支持在线预览</p>
|
||||
<p className="mt-1 text-[12px] text-ink-3">请点击左下角「下载」,用本地应用打开</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user