chore: directory migration - gateway→server, web→client/electron

This commit is contained in:
2026-09-08 19:16:35 +08:00
parent fd9f99a3f9
commit f9d757b5e5
243 changed files with 5095 additions and 228 deletions

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