feat: server-side attachment thumbnails (480px webp at upload) + session caches - detail grids no longer download full originals
This commit is contained in:
@@ -2,9 +2,10 @@ import { useRef } from 'react';
|
||||
import { Paperclip, FileText, Image, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui';
|
||||
import { fmtSize } from '@/lib/format';
|
||||
import { makeThumb } from '@/lib/img';
|
||||
import type { Attachment } from '@/lib/types';
|
||||
|
||||
export interface PendingAtt { file_name: string; mime?: string; size: number; data_b64: string; preview?: string; }
|
||||
export interface PendingAtt { file_name: string; mime?: string; size: number; data_b64: string; thumb_b64?: string; preview?: string; }
|
||||
|
||||
export function AttachmentPicker({ items, pending, onPick, onDeleteSaved, onDeletePending, onOpenSaved, uploading }: {
|
||||
items: Attachment[];
|
||||
@@ -81,23 +82,29 @@ export function AttachmentPicker({ items, pending, onPick, onDeleteSaved, onDele
|
||||
);
|
||||
}
|
||||
|
||||
export function fileToPending(file: File): Promise<PendingAtt> {
|
||||
return new Promise((resolve, reject) => {
|
||||
export async function fileToPending(file: File): Promise<PendingAtt> {
|
||||
const result = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = String(reader.result || '');
|
||||
const comma = result.indexOf(',');
|
||||
// 图片用 objectURL 做轻量预览(不上 Base64 进 DOM)
|
||||
const preview = file.type?.startsWith('image/') ? URL.createObjectURL(file) : undefined;
|
||||
resolve({
|
||||
file_name: file.name,
|
||||
mime: file.type || undefined,
|
||||
size: file.size,
|
||||
data_b64: comma >= 0 ? result.slice(comma + 1) : result,
|
||||
preview
|
||||
});
|
||||
};
|
||||
reader.onload = () => resolve(String(reader.result || ''));
|
||||
reader.onerror = () => reject(new Error('读取文件失败'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
const comma = result.indexOf(',');
|
||||
const data_b64 = comma >= 0 ? result.slice(comma + 1) : result;
|
||||
// 上传前生成 480px WebP 缩略图(详情网格用它,原图仅在查看时读取)
|
||||
let thumb_b64: string | undefined;
|
||||
if (file.type?.startsWith('image/') && !file.type.includes('gif')) {
|
||||
try {
|
||||
const thumbUrl = await makeThumb(data_b64, file.type);
|
||||
thumb_b64 = thumbUrl.split(',')[1] || undefined;
|
||||
} catch { thumb_b64 = undefined; }
|
||||
}
|
||||
return {
|
||||
file_name: file.name,
|
||||
mime: file.type || undefined,
|
||||
size: file.size,
|
||||
data_b64,
|
||||
thumb_b64,
|
||||
preview: file.type?.startsWith('image/') ? URL.createObjectURL(file) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+3
-1
@@ -129,12 +129,14 @@ export const bandUpsert = (p: GradeBand) => call<{ item: { id: number } }>('band
|
||||
export const bandDelete = (id: number) => call<{ ok: true }>('band_delete', { id });
|
||||
|
||||
// ---- attachment ----
|
||||
export const attachmentAdd = (p: { owner_id: number; file_name: string; mime?: string; size: number; data_b64: string }) =>
|
||||
export const attachmentAdd = (p: { owner_id: number; file_name: string; mime?: string; size: number; data_b64: string; thumb_b64?: string }) =>
|
||||
call<{ item: Attachment }>('attachment_add', p);
|
||||
export const attachmentList = (owner_id: number) => call<{ items: Attachment[] }>('attachment_list', { owner_id });
|
||||
export const attachmentDelete = (id: number) => call<{ ok: true }>('attachment_delete', { id });
|
||||
export const attachmentRead = (id: number) =>
|
||||
call<{ data_b64: string; mime?: string; file_name: string }>('attachment_read', { id });
|
||||
export const attachmentThumb = (id: number) =>
|
||||
call<{ data_b64: string; mime?: string; file_name: string }>('attachment_thumb', { id });
|
||||
|
||||
// ---- saved view ----
|
||||
export interface SavedView { id: number; name: string; filter: Record<string, unknown>; sort?: Record<string, unknown>; is_default: boolean; }
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface Attachment {
|
||||
mime?: string | null;
|
||||
size: number;
|
||||
rel_path: string;
|
||||
thumb_rel_path?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user