Files
DadaoTask/web/src/components/AttachmentPanel.tsx
T
dadao a24a493403 feat(rich): 备注/工单说明/知识正文支持 Markdown 与图片附件
写一件事的时候,光有纯文本不够:要贴截图、要分段、要标重点。
这次把「正文」这条路补全,思路是**存 Markdown 而不是 HTML**——
能导出、能全文检索、能直接喂给 LLM,也不必每次渲染都重新清洗一遍 HTML。

后端
- attachment 表 + RLS + 迁移 0005;本地磁盘存储,key 由内容 sha256 决定,
  换对象存储只改 services/storage.py 一个文件
- 上传在**读流时**卡 20MB,不先落盘再判断;磁盘名由服务端生成,用户文件名只进库和响应头
- 列表带 attachment_count、详情带 attachments,避免一屏 N 次请求
- raw 直链给 <img src> 用(图片带不了 Authorization 头),只允许本工作区成员,
  非成员一律 8001 不区分「不存在」与「没权限」
- 5 个用例:上传下载、超限、跨租户、删除确认与权限、raw 鉴权

前端
- 自研零依赖 Markdown 渲染器:先转义再上标签,链接协议白名单
- RichTextEditor:工具栏 + Ctrl+B/I + 预览,粘贴或拖入图片自动上传并插入引用
- AttachmentPanel:上传/下载/删除,下载走带 Authorization 的 fetch 转 blob
- 任务备注、工单说明与验收结论、知识正文、评论全部接入

顺手修的
- TaskDetail.hydrate 漏了 setAttachments,导致附件面板永远是空的(e2e 抓到的)

71 passed;web tsc 0 错、构建 255.07 kB │ gzip 80.85 kB;
e2e:richtext 9 项 + smoke 7 + tickets 8 + knowledge 7,全绿。
2026-10-29 01:53:46 +08:00

187 lines
6.0 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 { 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:
* 直接用 <a href> 会把令牌拼进 URL,留在浏览器历史里不值得。
*/
export function AttachmentPanel({
wsId,
itemId,
attachments,
onChange,
onError,
disabled = false,
}: Props) {
const fileRef = useRef<HTMLInputElement>(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<Attachment>(`/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 (
<div
onDragOver={(event) => {
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" : ""}
>
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1.5 text-2xs font-medium text-ink-500">
<Paperclip size={13} />
附件 {attachments.length > 0 && <span className="text-ink-400">· {attachments.length}</span>}
</span>
<button
type="button"
disabled={disabled || busy}
onClick={() => fileRef.current?.click()}
className="ml-auto inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-2xs text-ink-500 transition hover:bg-paper-200 hover:text-ink-800 disabled:opacity-40"
>
{busy ? <Spinner size={12} /> : <Plus size={12} />}
上传
</button>
</div>
<input
ref={fileRef}
type="file"
multiple
className="hidden"
onChange={(event) => {
void addFiles(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
{attachments.length === 0 ? (
<p className="mt-1.5 rounded-lg border border-dashed border-line-200 px-3 py-2 text-2xs text-ink-400">
可以把文件拖到这里,或点「上传」。
</p>
) : (
<ul className="mt-1.5 space-y-1">
{attachments.map((row) => (
<li
key={row.id}
className="group flex items-center gap-2 rounded-lg px-2 py-1.5 transition hover:bg-paper-200"
>
<span className="min-w-0 flex-1 truncate text-xs text-ink-700">{row.filename}</span>
<span className="shrink-0 text-2xs text-ink-400">{humanSize(row.size)}</span>
<button
type="button"
onClick={() => void download(row)}
title="下载"
aria-label={`下载 ${row.filename}`}
className="grid h-6 w-6 place-items-center rounded text-ink-400 opacity-0 transition hover:bg-paper-300 hover:text-ink-700 group-hover:opacity-100"
>
<Download size={13} />
</button>
{row.can_delete && !disabled && (
<button
type="button"
onClick={() => void remove(row)}
title="删除"
aria-label={`删除 ${row.filename}`}
className="grid h-6 w-6 place-items-center rounded text-ink-400 opacity-0 transition hover:bg-rust-50 hover:text-rust-600 group-hover:opacity-100"
>
<Trash size={13} />
</button>
)}
</li>
))}
</ul>
)}
</div>
);
}