172 lines
6.2 KiB
TypeScript
172 lines
6.2 KiB
TypeScript
import { useRef, useState } from 'react';
|
||
import * as api from '../api/client';
|
||
import type { Attachment } from '../types';
|
||
import { PaperclipIcon, DownloadIcon, FileIcon, CloseIcon, SpinnerIcon } from './icons';
|
||
|
||
/** 已发出邮件的附件清单(只读,点击下载)。 */
|
||
export function AttachmentList({ items }: { items: Attachment[] }) {
|
||
if (!items || items.length === 0) return null;
|
||
|
||
return (
|
||
<div className="mt-4 border-t border-gray-100 pt-3">
|
||
<div className="flex items-center gap-1.5 mb-2">
|
||
<PaperclipIcon className="w-3.5 h-3.5 text-gray-400" />
|
||
<span className="text-[11px] font-medium text-gray-500">
|
||
附件 {items.length}
|
||
</span>
|
||
</div>
|
||
<ul className="space-y-1">
|
||
{items.map(a => (
|
||
<li key={a.attachment_id}>
|
||
<a
|
||
href={api.attachmentURL(a.attachment_id)}
|
||
// download 让浏览器保存而非尝试渲染;服务端也已强制 octet-stream + attachment
|
||
download={a.filename}
|
||
className="group flex items-center gap-2 px-2 py-1.5 rounded border border-gray-200 hover:border-blue-300 hover:bg-blue-50 transition-colors"
|
||
>
|
||
<FileIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||
<span className="text-xs text-gray-800 truncate flex-1">{a.filename}</span>
|
||
<span className="text-[10px] text-gray-400 shrink-0">
|
||
{api.formatSize(a.size_bytes)}
|
||
</span>
|
||
<DownloadIcon className="w-3.5 h-3.5 text-gray-300 group-hover:text-blue-500 shrink-0" />
|
||
</a>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 待发送的附件:已上传到服务器、等着随邮件发出。 */
|
||
export interface PendingAttachment {
|
||
id: string;
|
||
filename: string;
|
||
size: number;
|
||
}
|
||
|
||
/**
|
||
* 写信时的附件选择器。
|
||
*
|
||
* 上传是独立一步:选中即上传,拿到 attachment_id 后暂存,发信时一并提交。
|
||
* 之所以不等到点「发送」再传:大文件上传要时间,让用户在写正文时就完成上传体验更好,
|
||
* 而且上传失败能立刻反馈而不是卡在发送那一刻。
|
||
*/
|
||
export function AttachmentPicker({
|
||
items,
|
||
onChange,
|
||
disabled
|
||
}: {
|
||
items: PendingAttachment[];
|
||
onChange: (next: PendingAttachment[]) => void;
|
||
disabled?: boolean;
|
||
}) {
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
const [uploading, setUploading] = useState<{ name: string; pct: number } | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const pick = () => inputRef.current?.click();
|
||
|
||
const handleFiles = async (files: FileList | null) => {
|
||
if (!files || files.length === 0) return;
|
||
setError(null);
|
||
|
||
// 逐个上传而非并发:并发时进度条只能显示其中一个,且大文件同时传更容易触发体积限制
|
||
const added: PendingAttachment[] = [];
|
||
for (const file of Array.from(files)) {
|
||
setUploading({ name: file.name, pct: 0 });
|
||
try {
|
||
const r = await api.uploadAttachment(file, pct => setUploading({ name: file.name, pct }));
|
||
added.push({
|
||
id: r.attachment.attachment_id,
|
||
filename: r.attachment.filename,
|
||
size: r.attachment.size_bytes
|
||
});
|
||
} catch (err) {
|
||
setError(`${file.name}:${err instanceof Error ? err.message : String(err)}`);
|
||
break; // 一个失败就停下,避免连续弹同类错误
|
||
}
|
||
}
|
||
setUploading(null);
|
||
if (added.length > 0) onChange([...items, ...added]);
|
||
|
||
// 清空 input,否则重复选同一个文件不会触发 change
|
||
if (inputRef.current) inputRef.current.value = '';
|
||
};
|
||
|
||
const remove = async (a: PendingAttachment) => {
|
||
// 从服务器删掉未挂载的附件,不然它会占着磁盘等 24 小时 GC
|
||
try {
|
||
await api.deleteAttachment(a.id);
|
||
} catch {
|
||
/* 删不掉也只是留给 GC,不该阻塞用户移除操作 */
|
||
}
|
||
onChange(items.filter(x => x.id !== a.id));
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
<input
|
||
ref={inputRef}
|
||
type="file"
|
||
multiple
|
||
className="hidden"
|
||
onChange={e => handleFiles(e.target.files)}
|
||
/>
|
||
|
||
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||
<button
|
||
type="button"
|
||
onClick={pick}
|
||
disabled={disabled || uploading !== null}
|
||
className="tap shrink-0 inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-40"
|
||
>
|
||
<PaperclipIcon className="w-3.5 h-3.5" />
|
||
添加附件
|
||
</button>
|
||
|
||
{uploading && (
|
||
<span className="min-w-0 flex-1 inline-flex items-center gap-1.5 text-[11px] text-gray-500">
|
||
<SpinnerIcon className="w-3.5 h-3.5 animate-spin shrink-0" />
|
||
<span className="truncate">{uploading.name}</span>
|
||
<span className="shrink-0">{uploading.pct}%</span>
|
||
</span>
|
||
)}
|
||
|
||
{items.length > 0 && !uploading && (
|
||
<span className="text-[11px] text-gray-500 min-w-0 break-words">
|
||
{items.length} 个附件 ·{' '}
|
||
{api.formatSize(items.reduce((sum, a) => sum + a.size, 0))}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{error && <div className="text-[11px] text-red-600 break-words">{error}</div>}
|
||
|
||
{items.length > 0 && (
|
||
<ul className="space-y-1">
|
||
{items.map(a => (
|
||
<li
|
||
key={a.id}
|
||
className="flex items-center gap-2 px-2 py-1.5 rounded border border-gray-200 bg-gray-50"
|
||
>
|
||
<FileIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||
<span className="text-xs text-gray-800 truncate flex-1">{a.filename}</span>
|
||
<span className="text-[10px] text-gray-400 shrink-0">{api.formatSize(a.size)}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => remove(a)}
|
||
disabled={disabled}
|
||
title="移除"
|
||
className="tap shrink-0 inline-flex items-center justify-center text-gray-500 hover:text-red-600 disabled:opacity-40"
|
||
>
|
||
<CloseIcon className="w-3.5 h-3.5" />
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|