import { useRef, useState } from "react"; import { api, session, uploadFile } from "../lib/client"; import { Download, Paperclip, Plus, Spinner, Trash } from "./icons"; export interface Attachment { id: string; item_id: string; filename: string; content_type: string; size: number; uploader_id: string; created_at: string; can_delete: boolean; } interface Props { wsId: string; itemId: string; attachments: Attachment[]; onChange: (next: Attachment[]) => void; onError?: (message: string) => void; disabled?: boolean; } function humanSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } /** * 条目附件(任务 / 工单 / 知识共用)。 * * 下载走一次带 Authorization 头的 fetch 再转 blob: * 直接用 会把令牌拼进 URL,留在浏览器历史里不值得。 */ export function AttachmentPanel({ wsId, itemId, attachments, onChange, onError, disabled = false, }: Props) { const fileRef = useRef(null); const [busy, setBusy] = useState(false); const [dragging, setDragging] = useState(false); async function addFiles(files: File[]) { if (files.length === 0) return; setBusy(true); try { const created: Attachment[] = []; for (const file of files) { created.push( await uploadFile(`/workspaces/${wsId}/items/${itemId}/attachments`, file), ); } onChange([...created, ...attachments]); } catch (err) { onError?.(err instanceof Error ? err.message : "上传失败"); } finally { setBusy(false); } } async function download(row: Attachment) { try { const resp = await fetch( `/api/v1/workspaces/${wsId}/attachments/${row.id}/download`, { headers: { Authorization: `Bearer ${session.access ?? ""}` } }, ); if (!resp.ok) throw new Error(`下载失败(HTTP ${resp.status})`); const blob = await resp.blob(); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = row.filename; anchor.click(); URL.revokeObjectURL(url); } catch (err) { onError?.(err instanceof Error ? err.message : "下载失败"); } } async function remove(row: Attachment) { if (!window.confirm(`删除附件「${row.filename}」?正文里的引用会变成坏链。`)) return; setBusy(true); try { // 后端要求显式确认:删除不可逆,前端这一步只是二次确认 await api.del(`/workspaces/${wsId}/attachments/${row.id}?confirm=true`); onChange(attachments.filter((item) => item.id !== row.id)); } catch (err) { onError?.(err instanceof Error ? err.message : "删除失败"); } finally { setBusy(false); } } return (
{ if (event.dataTransfer.types.includes("Files")) { event.preventDefault(); setDragging(true); } }} onDragLeave={() => setDragging(false)} onDrop={(event) => { const files = Array.from(event.dataTransfer.files ?? []); if (files.length > 0) { event.preventDefault(); setDragging(false); void addFiles(files); } }} className={dragging ? "rounded-xl ring-2 ring-primary-500/40" : ""} >
附件 {attachments.length > 0 && · {attachments.length}}
{ void addFiles(Array.from(event.target.files ?? [])); event.target.value = ""; }} /> {attachments.length === 0 ? (

可以把文件拖到这里,或点「上传」。

) : (
    {attachments.map((row) => (
  • {row.filename} {humanSize(row.size)} {row.can_delete && !disabled && ( )}
  • ))}
)}
); }