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,全绿。
This commit is contained in:
dadao
2026-10-29 01:53:46 +08:00
parent 0a3b0f3ca0
commit a24a493403
24 changed files with 1843 additions and 25 deletions
+186
View File
@@ -0,0 +1,186 @@
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>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { useMemo } from "react";
import { renderMarkdown } from "../lib/markdown";
interface Props {
source: string;
/** 列表里只给个开头时用它压小字号 */
compact?: boolean;
className?: string;
}
/**
* 只读的 Markdown 渲染。
*
* renderMarkdown 内部已经把原文全部 HTML 转义过再套标签,
* 这里放心用 dangerouslySetInnerHTML(这是本仓库唯一允许用的地方)。
*/
export function Markdown({ source, compact = false, className = "" }: Props) {
const html = useMemo(() => renderMarkdown(source ?? ""), [source]);
if (!source?.trim()) {
return <p className={`text-sm text-ink-400 ${className}`}>还没有内容。</p>;
}
return (
<div
className={[
"prose-dadao text-sm leading-6 text-ink-800",
compact ? "prose-compact" : "",
className,
].join(" ")}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
+247
View File
@@ -0,0 +1,247 @@
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { attachmentUrl, uploadFile } from "../lib/client";
import { Markdown } from "./Markdown";
import { Bold, Code, Eye, Image as ImageIcon, Italic, Link2, ListIcon, Pencil, Quote } from "./icons";
interface Attachment {
id: string;
filename: string;
}
interface Props {
value: string;
onChange: (next: string) => void;
/** 图片要落到具体条目下,所以这两个是必填 */
wsId: string;
itemId: string;
placeholder?: string;
disabled?: boolean;
rows?: number;
ariaLabel: string;
onError?: (message: string) => void;
}
/**
* 富文本编辑器。
*
* 存储格式是 Markdown——为什么不做 contenteditable 的富文本:
* 那样存进去的是 HTML,导出、检索、喂给 LLM 都要再清洗一遍;而 Markdown 天生
* 既是纯文本又能表达排版,模型读得懂、git diff 也看得懂。
*
* 写作时不用记语法:工具栏按钮、Ctrl+B/I、粘贴/拖拽图片,都会就地改写文本。
*/
export function RichTextEditor({
value,
onChange,
wsId,
itemId,
placeholder = "写点什么…支持 Markdown:**粗体**、- 列表、```代码```",
disabled = false,
rows = 6,
ariaLabel,
onError,
}: Props) {
const ref = useRef<HTMLTextAreaElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const [preview, setPreview] = useState(false);
const [dragging, setDragging] = useState(false);
const [uploading, setUploading] = useState(false);
const helpId = useId();
/** 在光标处插入文本;有选区就包裹它,插完把光标放到合适位置。 */
const surround = useCallback(
(before: string, after = "", placeholderText = "") => {
const el = ref.current;
if (!el) return;
const { selectionStart: start, selectionEnd: end } = el;
const picked = value.slice(start, end);
const selected = picked || placeholderText;
const next = value.slice(0, start) + before + selected + after + value.slice(end);
onChange(next);
// 等 React 把新值写回 textarea 再摆光标,否则会被旧值覆盖
requestAnimationFrame(() => {
el.focus();
if (picked) {
// 有选区:保持包裹后的内容仍被选中,方便继续加别的样式
el.setSelectionRange(start + before.length, start + before.length + selected.length);
} else {
const cursor = start + before.length + selected.length;
el.setSelectionRange(cursor, cursor);
}
});
},
[onChange, value],
);
const prefixLines = useCallback(
(prefix: string) => {
const el = ref.current;
if (!el) return;
const start = value.lastIndexOf("\n", el.selectionStart - 1) + 1;
const endIdx = value.indexOf("\n", el.selectionEnd);
const end = endIdx === -1 ? value.length : endIdx;
const block = value.slice(start, end) || "列表项";
const nextBlock = block
.split("\n")
.map((line) => `${prefix}${line.replace(/^(\s*)([-*+]|\d+[.)])\s+/, "$1")}`)
.join("\n");
onChange(value.slice(0, start) + nextBlock + value.slice(end));
requestAnimationFrame(() => el.focus());
},
[onChange, value],
);
const insertImages = useCallback(
async (files: File[]) => {
const images = files.filter((file) => file.type.startsWith("image/"));
if (images.length === 0) return;
setUploading(true);
try {
let next = value;
for (const file of images) {
const created = await uploadFile<Attachment>(
`/workspaces/${wsId}/items/${itemId}/attachments`,
file,
);
const snippet = `\n![${created.filename}](${attachmentUrl(wsId, created.id)})\n`;
next += snippet;
}
onChange(next);
} catch (err) {
onError?.(err instanceof Error ? err.message : "图片上传失败");
} finally {
setUploading(false);
}
},
[itemId, onError, onChange, value, wsId],
);
// 用 document 级别的粘贴监听兜住"焦点在 textarea 但剪贴板里是图片"的情况
useEffect(() => {
const el = ref.current;
if (!el || disabled) return;
const onPaste = (event: ClipboardEvent) => {
const files = Array.from(event.clipboardData?.files ?? []);
if (files.some((file) => file.type.startsWith("image/"))) {
event.preventDefault();
void insertImages(files);
}
};
el.addEventListener("paste", onPaste);
return () => el.removeEventListener("paste", onPaste);
}, [disabled, insertImages]);
const tools = [
{ label: "粗体", icon: <Bold size={15} />, run: () => surround("**", "**", "粗体") },
{ label: "斜体", icon: <Italic size={15} />, run: () => surround("*", "*", "斜体") },
{ label: "标题", icon: <span className="text-[13px] font-semibold">H</span>, run: () => prefixLines("## ") },
{ label: "列表", icon: <ListIcon size={15} />, run: () => prefixLines("- ") },
{ label: "引用", icon: <Quote size={15} />, run: () => prefixLines("> ") },
{ label: "代码", icon: <Code size={15} />, run: () => surround("`", "`", "code") },
{ label: "链接", icon: <Link2 size={15} />, run: () => surround("[", "](https://)", "链接文字") },
];
return (
<div className={dragging ? "rounded-xl ring-2 ring-primary-500/40" : ""}>
<div className="flex flex-wrap items-center gap-0.5 border-b border-line-100 pb-1.5">
{tools.map((tool) => (
<button
key={tool.label}
type="button"
title={tool.label}
aria-label={tool.label}
disabled={disabled || preview}
onClick={tool.run}
className="grid h-7 w-7 place-items-center rounded-md text-ink-500 transition hover:bg-paper-200 hover:text-ink-800 disabled:opacity-40"
>
{tool.icon}
</button>
))}
<button
type="button"
title="插入图片"
aria-label="插入图片"
disabled={disabled || preview || uploading}
onClick={() => fileRef.current?.click()}
className="grid h-7 w-7 place-items-center rounded-md text-ink-500 transition hover:bg-paper-200 hover:text-ink-800 disabled:opacity-40"
>
<ImageIcon size={15} />
</button>
<span className="mx-1 h-4 w-px bg-line-100" />
<button
type="button"
onClick={() => setPreview((prev) => !prev)}
className="ml-auto inline-flex items-center gap-1 rounded-md px-2 py-1 text-2xs font-medium text-ink-500 transition hover:bg-paper-200 hover:text-ink-800"
aria-pressed={preview}
>
{preview ? <Pencil size={13} /> : <Eye size={13} />}
{preview ? "继续编辑" : "预览"}
</button>
</div>
<input
ref={fileRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => {
void insertImages(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
{preview ? (
<div className="min-h-[96px] rounded-lg border border-line-100 bg-paper-50 px-3 py-2.5">
{value.trim() ? <Markdown source={value} /> : <p className="text-sm text-ink-400">还没有内容。</p>}
</div>
) : (
<textarea
ref={ref}
aria-label={ariaLabel}
aria-describedby={helpId}
className="input min-h-[96px] resize-y border-0 bg-transparent px-0 py-2 font-mono text-[13px] leading-6 focus:ring-0"
rows={rows}
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
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 insertImages(files);
}
}}
onKeyDown={(event) => {
if (!(event.ctrlKey || event.metaKey)) return;
const key = event.key.toLowerCase();
if (key === "b") {
event.preventDefault();
surround("**", "**", "粗体");
} else if (key === "i") {
event.preventDefault();
surround("*", "*", "斜体");
}
}}
/>
)}
<p id={helpId} className="mt-1 text-2xs text-ink-400">
{uploading ? "正在上传图片…" : "可直接粘贴或拖入图片;Ctrl+B 粗体、Ctrl+I 斜体"}
</p>
</div>
);
}
+53 -12
View File
@@ -2,7 +2,16 @@ import { useCallback, useEffect, useState } from "react";
import { useAuth } from "../App";
import { ApiError, api } from "../lib/client";
import { PRIORITY_LABEL, STATUS_LABEL } from "../lib/parse";
import type { Activity, Item, ItemDetail, Member, Page, Role, TicketInfo } from "../lib/types";
import type {
Activity,
Attachment,
Item,
ItemDetail,
Member,
Page,
Role,
TicketInfo,
} from "../lib/types";
import {
Book,
Clock,
@@ -14,6 +23,9 @@ import {
Trash,
Users,
} from "./icons";
import { AttachmentPanel } from "./AttachmentPanel";
import { Markdown } from "./Markdown";
import { RichTextEditor } from "./RichTextEditor";
import { Button } from "./ui";
import { Sheet } from "./ui";
@@ -75,6 +87,7 @@ export function TaskDetail({
const [tags, setTags] = useState("");
const [due, setDue] = useState("");
const [comments, setComments] = useState<Comment[]>([]);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
@@ -94,6 +107,8 @@ export function TaskDetail({
setBody(next.body_md);
setTags(next.tags.map((t) => t.name).join(" "));
setDue(toLocalInput(next.due_at));
// 附件可能为空数组,兜一下:接口在某些分支(如 PATCH 回包)不带这个字段
setAttachments(next.attachments ?? []);
}, []);
useEffect(() => {
@@ -386,7 +401,9 @@ export function TaskDetail({
{ticket.resolution_md ? (
<div className="mt-3 rounded-lg border border-line-200 bg-white p-3">
<p className="text-2xs text-ink-400">验收结论</p>
<p className="mt-1 whitespace-pre-wrap text-sm text-ink-800">{ticket.resolution_md}</p>
<div className="mt-1">
<Markdown source={ticket.resolution_md} compact />
</div>
</div>
) : null}
@@ -471,17 +488,41 @@ export function TaskDetail({
) : null}
</label>
<label className="block">
<div>
<span className="label">备注</span>
<textarea
className="input min-h-32 resize-y leading-relaxed"
aria-label="备注"
value={body}
onChange={(e) => setBody(e.target.value)}
onBlur={() => void saveBody()}
placeholder="背景、约束、链接…写下来,将来才想得起来"
<div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2">
<RichTextEditor
ariaLabel="备注"
wsId={wsId}
itemId={item.id}
value={body}
onChange={setBody}
onError={(message) => setError(message)}
rows={8}
disabled={busy}
/>
</div>
<div className="mt-1 flex items-center gap-2">
{body !== item.body_md ? (
<Button size="sm" variant="subtle" disabled={busy} onClick={() => void saveBody()}>
保存备注
</Button>
) : (
<span className="text-2xs text-ink-400">修改后自动提示保存</span>
)}
</div>
</div>
<div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2.5">
<AttachmentPanel
wsId={wsId}
itemId={item.id}
attachments={attachments}
onChange={setAttachments}
onError={(message) => setError(message)}
disabled={busy}
/>
</label>
</div>
{item.kind === "task" && !["done", "canceled"].includes(item.status) ? (
<section className="rounded-xl border border-clay-200 bg-clay-50/50 p-4">
@@ -565,7 +606,7 @@ export function TaskDetail({
)}
</span>
<div className="min-w-0 flex-1 rounded-xl rounded-tl-sm bg-paper-100 px-3 py-2">
<p className="whitespace-pre-wrap text-sm text-ink-800">{c.body_md}</p>
<Markdown source={c.body_md} />
<p className="mt-0.5 text-2xs text-ink-400">
{new Date(c.created_at).toLocaleString("zh-CN")}
</p>
+63
View File
@@ -23,6 +23,69 @@ export const Check = (p: IconProps) => (
</svg>
);
export const Bold = (p: IconProps) => (
<svg {...base(p)}>
<path d="M7 5h6a3.5 3.5 0 0 1 0 7H7zM7 12h7a3.5 3.5 0 0 1 0 7H7z" />
</svg>
);
export const Italic = (p: IconProps) => (
<svg {...base(p)}>
<path d="M15 5h-5M14 19H9M14.5 5 10 19" />
</svg>
);
export const Quote = (p: IconProps) => (
<svg {...base(p)}>
<path d="M7 7h4v4c0 2.2-1.3 3.7-3.5 4.5M15 7h4v4c0 2.2-1.3 3.7-3.5 4.5" />
</svg>
);
export const Code = (p: IconProps) => (
<svg {...base(p)}>
<path d="m9 8-4 4 4 4M15 8l4 4-4 4" />
</svg>
);
export const ListIcon = (p: IconProps) => (
<svg {...base(p)}>
<path d="M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01" />
</svg>
);
export const Image = (p: IconProps) => (
<svg {...base(p)}>
<rect x="3.5" y="4.5" width="17" height="15" rx="2.5" />
<circle cx="9" cy="10" r="1.6" />
<path d="m4 17 4.5-4.5 3.5 3.5 3-3L20 17" />
</svg>
);
export const Eye = (p: IconProps) => (
<svg {...base(p)}>
<path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z" />
<circle cx="12" cy="12" r="2.6" />
</svg>
);
export const Pencil = (p: IconProps) => (
<svg {...base(p)}>
<path d="M4 20h4l10.5-10.5a2.1 2.1 0 0 0-3-3L5 17z" />
</svg>
);
export const Paperclip = (p: IconProps) => (
<svg {...base(p)}>
<path d="M20 11.5 12.3 19a4.6 4.6 0 0 1-6.5-6.5l7.4-7.4a3.1 3.1 0 1 1 4.4 4.4l-7.4 7.4a1.5 1.5 0 1 1-2.2-2.2l6.6-6.6" />
</svg>
);
export const Download = (p: IconProps) => (
<svg {...base(p)}>
<path d="M12 4v11M7.5 10.5 12 15l4.5-4.5M4.5 19h15" />
</svg>
);
export const CheckCircle = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="12" cy="12" r="9" />