Files
MailUI4Agents/client/electron/src/components/Attachments.tsx

172 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}