feat: Lsky/COS gateway - attachments upload direct to object storage (remote_url/remote_thumb), preview loads from COS, graceful local fallback

This commit is contained in:
root
2026-10-17 01:16:59 +08:00
parent a94c1f6e25
commit 2bba3be08e
23 changed files with 174 additions and 51 deletions
+2
View File
@@ -90,6 +90,8 @@ CREATE TABLE IF NOT EXISTS attachment (
rel_path TEXT NOT NULL,
sha256 TEXT,
thumb_rel_path TEXT,
remote_url TEXT,
remote_thumb TEXT,
taken_at TEXT,
deleted_at TEXT,
origin TEXT,
+14 -1
View File
@@ -125,7 +125,7 @@ function preMigrateColumns(d) {
}
};
for (const t of SYNC_TABLES) addCols(t, SYNC_COLS);
addCols('attachment', [['sha256', 'TEXT'], ['thumb_rel_path', 'TEXT']]);
addCols('attachment', [['sha256', 'TEXT'], ['thumb_rel_path', 'TEXT'], ['remote_url', 'TEXT'], ['remote_thumb', 'TEXT']]);
}
export function migrateSync(d) {
@@ -1078,6 +1078,19 @@ function listFootprints(d, query) {
return { data_b64: data.toString('base64'), mime: row.thumb_rel_path ? 'image/webp' : row.mime, file_name: row.file_name };
}
case 'attachment_set_remote': {
// 图床(Lsky/COS)直传成功后回填远程地址;同步到所有端
const row = d.prepare('SELECT * FROM attachment WHERE id=?').get(payload.id);
if (!row) throw new Error('附件不存在');
withTx(d, () => {
const h = hlcNext(d);
d.prepare('UPDATE attachment SET remote_url=?, remote_thumb=?, hlc=? WHERE id=?')
.run(payload.remote_url ?? null, payload.remote_thumb ?? null, h, row.id);
touchOplog(d, 'attachment', row.uuid, 'upsert', h);
});
return { ok: true };
}
case 'view_list':
return { items: d.prepare('SELECT * FROM saved_view WHERE deleted_at IS NULL ORDER BY id').all().map(v => ({
...v, filter: JSON.parse(v.filter || '{}'), sort: JSON.parse(v.sort || '{}'), is_default: !!v.is_default
+28 -14
View File
@@ -1,7 +1,7 @@
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 { readFull } from '@/lib/att-cache';
import type { Attachment } from '@/lib/types';
export type AttKind = 'image' | 'pdf' | 'text' | 'audio' | 'video' | 'docx' | 'xlsx' | 'other';
@@ -50,29 +50,43 @@ export function AttachmentPreview({ att, onClose }: { att: Attachment | null; on
(async () => {
try {
const kind = attKind(att);
const { data_b64, mime } = await readFull(att);
const remote = att.remote_url || null;
// 远程直连(COS/图床 URL):图片/PDF/音视频直接作 src,零 base64 往返
if (remote && (kind === 'image' || kind === 'pdf' || kind === 'audio' || kind === 'video')) {
setContent({ kind, objectUrl: remote });
setLoading(false);
return;
}
let mime: string | undefined = att.mime ?? undefined;
let bytes: Uint8Array<ArrayBuffer>;
if (remote) {
bytes = new Uint8Array(await (await fetch(remote)).arrayBuffer());
} else {
const r = await readFull(att);
mime = r.mime;
bytes = b64ToUint8(r.data_b64);
}
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));
if (kind === 'text') {
const text = new TextDecoder().decode(bytes);
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;
const html = (await mammoth.convertToHtml({ arrayBuffer: bytes.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 wb = XLSX.read(bytes, { type: 'array' });
const html = XLSX.utils.sheet_to_html(wb.Sheets[wb.SheetNames[0]]);
if (cancelled) return;
setContent({ kind, html });
} else {
setContent({ kind: 'other' });
// 本地回退的图片/PDF/音视频
const url = URL.createObjectURL(new Blob([bytes], { type: mime || 'application/octet-stream' }));
if (cancelled) { URL.revokeObjectURL(url); return; }
created.push(url);
setContent({ kind, objectUrl: url });
}
} catch (e) {
if (!cancelled) setError((e as Error).message || '预览加载失败');
@@ -81,7 +95,7 @@ export function AttachmentPreview({ att, onClose }: { att: Attachment | null; on
}
})();
return () => { cancelled = true; created.forEach(u => URL.revokeObjectURL(u)); };
}, [att?.id, att?.file_name]);
}, [att?.id, att?.file_name, att?.remote_url]);
const kind = att ? attKind(att) : 'other';
@@ -93,7 +107,7 @@ export function AttachmentPreview({ att, onClose }: { att: Attachment | null; on
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)); }}>
<Button variant="secondary" className="mr-auto" onClick={() => { void readFull(att).then(r => forceDownload(att.file_name, att.mime ?? undefined, r.data_b64)); }}>
<Download size={13} /> 下载
</Button>
<Button variant="primary" onClick={onClose}>关闭</Button>
@@ -64,6 +64,7 @@ export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
for (const a of list) {
if (cancelled) return;
if (thumbCache.has(a.id)) { setImgMap(m => ({ ...m, [a.id]: thumbCache.get(a.id)! })); continue; }
if (a.remote_thumb) { thumbCache.set(a.id, a.remote_thumb); setImgMap(m => ({ ...m, [a.id]: a.remote_thumb! })); continue; }
try {
if (a.thumb_rel_path) {
// 新上传:服务端已存 480px WebP 缩略图,20KB 级,秒开
+2
View File
@@ -51,6 +51,8 @@ export interface Attachment {
size: number;
rel_path: string;
thumb_rel_path?: string | null;
remote_url?: string | null;
remote_thumb?: string | null;
created_at: string;
}