feat: filename-aware attachment download endpoint (Content-Disposition), preview download uses it (no popup flash)
This commit is contained in:
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Download, Loader2 } from 'lucide-react';
|
||||
import { Button, Dialog, useToast } from '@/components/ui';
|
||||
import { readFull } from '@/lib/att-cache';
|
||||
import { attachmentDownload } from '@/lib/api';
|
||||
import type { Attachment } from '@/lib/types';
|
||||
|
||||
export type AttKind = 'image' | 'pdf' | 'text' | 'audio' | 'video' | 'docx' | 'xlsx' | 'other';
|
||||
@@ -23,14 +24,6 @@ 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: '视频',
|
||||
@@ -109,22 +102,17 @@ export function AttachmentPreview({ att, onClose }: { att: Attachment | null; on
|
||||
footer={att ? (
|
||||
<>
|
||||
<Button variant="secondary" className="mr-auto" onClick={() => {
|
||||
// 远程附件:同步新标签打开(用户手势内触发,不会被拦截;右键即可另存)
|
||||
if (att.remote_url) {
|
||||
const a = document.createElement('a');
|
||||
a.href = att.remote_url;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener';
|
||||
a.click();
|
||||
toast('success', '已在新的标签页打开,右键图片可「另存为」');
|
||||
return;
|
||||
}
|
||||
// 本地附件:base64 下载,失败给出提示
|
||||
// 统一走服务器下载端点:Content-Disposition 带原始文件名,浏览器直接落盘,不闪窗
|
||||
void (async () => {
|
||||
try {
|
||||
toast('success', '已开始下载');
|
||||
const r = await readFull(att);
|
||||
forceDownload(att.file_name, att.mime ?? undefined, r.data_b64);
|
||||
const { blob, file_name } = await attachmentDownload(att.id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = file_name;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
toast('success', '已下载:' + file_name);
|
||||
} catch (e) {
|
||||
toast('error', '下载失败:' + (e as Error).message);
|
||||
}
|
||||
|
||||
@@ -147,6 +147,21 @@ export async function attachmentUploadRaw(ownerId: number, file: File, thumbB64?
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function attachmentDownload(id: number): Promise<{ blob: Blob; file_name: string; mime?: string }> {
|
||||
const r = await fetch(getServerUrl() + '/api/attachment_download', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + getToken() },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const j = await r.json().catch(() => null);
|
||||
throw new Error(j?.error || '下载失败');
|
||||
}
|
||||
const file_name = decodeURIComponent(r.headers.get('x-file-name') || (r.headers.get('content-disposition') || '').match(/UTF-8''([^;]+)/)?.[1] || 'file');
|
||||
const mime = r.headers.get('content-type') || undefined;
|
||||
return { blob: await r.blob(), file_name, mime };
|
||||
}
|
||||
|
||||
export const attachmentThumb = (id: number) =>
|
||||
call<{ data_b64: string; mime?: string; file_name: string }>('attachment_thumb', { id });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user