chore: directory migration - gateway→server, web→client/electron
This commit is contained in:
171
client/electron/src/components/Attachments.tsx
Normal file
171
client/electron/src/components/Attachments.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user