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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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 级,秒开
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user