feat: server-side attachment thumbnails (480px webp at upload) + session caches - detail grids no longer download full originals

This commit is contained in:
root
2026-10-16 23:10:44 +08:00
parent 76cee988c0
commit 07f7cd51b2
19 changed files with 426 additions and 359 deletions
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Pencil, Trash2, Paperclip, FileText } from 'lucide-react';
import { attachmentRead, footprintGet, listDimensions, listTypes } from '@/lib/api';
import { attachmentRead, attachmentThumb, footprintGet, listDimensions, listTypes } from '@/lib/api';
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';
@@ -9,6 +9,10 @@ import { openOrDownload } from '@/lib/file';
import { makeThumb } from '@/lib/img';
import { fmtRange, fmtDateTime, fmtSize } from '@/lib/format';
// 缩略图/原图会话级缓存:同一附件一次会话只读一次
const thumbCache = new Map<number, string>();
const fullCache = new Map<number, string>();
export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
open: boolean;
onClose: () => void;
@@ -46,8 +50,13 @@ export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
const openAtt = async (a: Attachment) => {
try {
const r = await attachmentRead(a.id);
openOrDownload(r.file_name, r.mime ?? undefined, r.data_b64);
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);
}
@@ -60,25 +69,36 @@ export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
const docs = atts.filter(a => !a.mime?.startsWith('image/'));
const attsKey = atts.map(a => a.id).join(',');
useEffect(() => {
setImgMap({});
if (!open || !attsKey) return;
let cancelled = false;
const list = atts.filter(a => a.mime?.startsWith('image/'));
(async () => {
const map: Record<number, string> = {};
const list = atts.filter(a => a.mime?.startsWith('image/'));
for (const a of list) {
if (cancelled) return;
if (thumbCache.has(a.id)) { setImgMap(m => ({ ...m, [a.id]: thumbCache.get(a.id)! })); continue; }
try {
const r = await attachmentRead(a.id);
if (cancelled) return;
// 网格展示用缩略图(降采样至 480px WebP),原图仅在点击查看时读取
map[a.id] = await makeThumb(r.data_b64, r.mime || 'image/png');
setImgMap({ ...map });
if (a.thumb_rel_path) {
// 新上传:服务端已存 480px WebP 缩略图,20KB 级,秒开
const r = await attachmentThumb(a.id);
if (cancelled) return;
const url = 'data:' + (r.mime || 'image/webp') + ';base64,' + r.data_b64;
thumbCache.set(a.id, url);
setImgMap(m => ({ ...m, [a.id]: url }));
} else {
// 历史附件无缩略图:读原图 + 客户端降采样,结果进会话缓存
const r = await attachmentRead(a.id);
if (cancelled) return;
const url = await makeThumb(r.data_b64, r.mime || 'image/png');
thumbCache.set(a.id, url);
setImgMap(m => ({ ...m, [a.id]: url }));
}
} catch {
// 解码失败则退回原图
try {
const r = await attachmentRead(a.id);
map[a.id] = 'data:' + (r.mime || 'image/png') + ';base64,' + r.data_b64;
setImgMap({ ...map });
if (cancelled) return;
const url = 'data:' + (r.mime || 'image/png') + ';base64,' + r.data_b64;
thumbCache.set(a.id, url);
setImgMap(m => ({ ...m, [a.id]: url }));
} catch { /* 单张失败不影响其余 */ }
}
}
@@ -156,7 +156,7 @@ export function RecordEditorDialog({ open, onClose, footprintId, onSaved }: {
};
const saved = await footprintUpsert(input);
for (const p of pendingAtts) {
await attachmentAdd({ owner_id: saved.id, file_name: p.file_name, mime: p.mime, size: p.size, data_b64: p.data_b64 });
await attachmentAdd({ owner_id: saved.id, file_name: p.file_name, mime: p.mime, size: p.size, data_b64: p.data_b64, thumb_b64: p.thumb_b64 });
}
return saved.id;
},
@@ -347,7 +347,7 @@ export function RecordEditorDialog({ open, onClose, footprintId, onSaved }: {
continue;
}
const p = await fileToPending(f);
await attachmentAdd({ owner_id: footprintId, file_name: p.file_name, mime: p.mime, size: p.size, data_b64: p.data_b64 });
await attachmentAdd({ owner_id: footprintId, file_name: p.file_name, mime: p.mime, size: p.size, data_b64: p.data_b64, thumb_b64: p.thumb_b64 });
done++;
}
if (done > 0) {