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:
@@ -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})\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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user