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
+180
View File
@@ -0,0 +1,180 @@
/**
* 极简 Markdown 渲染器。
*
* 为什么不用 react-markdown:我们的编辑场景只需要"能读懂的一小撮语法",
* 引一个大依赖还得处理它的插件体系;而 XSS 这类问题,自己渲染反倒更容易
* 把"先转义、再套标签"这条铁律钉死在一个文件里。
*
* 支持的语法刻意很小:标题、粗体/斜体/删除线、行内代码、代码块、有序/无序
* 列表、引用、分隔线、链接、图片、(空行分隔的)段落。
* 不支持的(表格、脚注、HTML 片段)会原样当文本显示 —— 显示不出来比显示一半安全。
*/
const ESCAPE_MAP: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
export function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (ch) => ESCAPE_MAP[ch]);
}
/** 只放行 http/https/mailto 与站内相对地址,挡掉 javascript: 这类伪协议。 */
function safeUrl(url: string): string | null {
const trimmed = url.trim();
if (!trimmed) return null;
if (/^(https?:\/\/|mailto:|\/)/i.test(trimmed)) return trimmed;
return null;
}
/** 行内语法。先转义,再按"从最不可能被误伤"的顺序套标签。 */
function inline(raw: string): string {
let text = escapeHtml(raw);
// 行内代码先摘出来,避免里面的 * 和 _ 被当成强调
const codes: string[] = [];
text = text.replace(/`([^`]+)`/g, (_m, code: string) => {
codes.push(code);
return `\u0000CODE${codes.length - 1}\u0000`;
});
text = text
// ![alt](url) 与 [text](url)
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (match, alt: string, url: string) => {
const href = safeUrl(url);
if (!href) return match;
return `<img src="${href}" alt="${alt}" loading="lazy" />`;
})
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (match, label: string, url: string) => {
const href = safeUrl(url);
if (!href) return match;
const external = /^https?:\/\//i.test(href);
const rel = external ? ' target="_blank" rel="noreferrer noopener"' : "";
return `<a href="${href}"${rel}>${label}</a>`;
})
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/(?:^|\s)__([^_]+)__(?=\s|$)/g, " <em>$1</em>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
.replace(/(?:^|\s)_([^_]+)_(?=\s|$)/g, " <em>$1</em>")
.replace(/~~([^~]+)~~/g, "<del>$1</del>");
// 还原行内代码(内容已在上面转义过,这里只负责套标签)
return text.replace(/\u0000CODE(\d+)\u0000/g, (_m, idx: string) => `<code>${codes[Number(idx)]}</code>`);
}
/** 渲染成可直接塞进 dangerouslySetInnerHTML 的字符串。 */
export function renderMarkdown(source: string): string {
const lines = source.replace(/\r\n?/g, "\n").split("\n");
const out: string[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// 代码块:内容原样保留,只做转义,不做任何行内解析
const fence = line.match(/^\s*```(\w*)\s*$/);
if (fence) {
const lang = fence[1];
const body: string[] = [];
i += 1;
while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) {
body.push(lines[i]);
i += 1;
}
i += 1; // 跳过结束围栏(文件末尾没闭合也能渲染)
const cls = lang ? ` class="language-${escapeHtml(lang)}"` : "";
out.push(`<pre><code${cls}>${escapeHtml(body.join("\n"))}</code></pre>`);
continue;
}
if (/^\s*$/.test(line)) {
i += 1;
continue;
}
const heading = line.match(/^(#{1,4})\s+(.*)$/);
if (heading) {
const level = heading[1].length;
out.push(`<h${level}>${inline(heading[2])}</h${level}>`);
i += 1;
continue;
}
if (/^\s*([-*_])\s*\1\s*\1[\s-*_]*$/.test(line)) {
out.push("<hr />");
i += 1;
continue;
}
// 引用:连续以 > 开头的行合并成一个 blockquote
if (/^\s*>\s?/.test(line)) {
const body: string[] = [];
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
body.push(lines[i].replace(/^\s*>\s?/, ""));
i += 1;
}
out.push(`<blockquote>${inline(body.join(" "))}</blockquote>`);
continue;
}
// 列表:同类型的连续行归一组,嵌套只支持一层(够用,且不会渲染错)
const bullet = line.match(/^(\s*)[-*+]\s+(.*)$/);
const ordered = line.match(/^(\s*)\d+[.)]\s+(.*)$/);
if (bullet || ordered) {
const isOrdered = Boolean(ordered);
const items: string[] = [];
const pattern = isOrdered ? /^(\s*)\d+[.)]\s+(.*)$/ : /^(\s*)[-*+]\s+(.*)$/;
while (i < lines.length) {
const m = lines[i].match(pattern);
if (!m) break;
const [, padding, content] = m;
// 缩进两格以上视为子项,用一段加粗缩进表示(不生成嵌套 ol/ul,避免打错标签)
items.push(
padding.length >= 2
? `<li class="ml-4">${inline(content)}</li>`
: `<li>${inline(content)}</li>`,
);
i += 1;
}
const tag = isOrdered ? "ol" : "ul";
out.push(`<${tag}>${items.join("")}</${tag}>`);
continue;
}
// 段落:吃到下一个空行或下一个块级语法为止
const paragraph: string[] = [];
while (i < lines.length) {
const current = lines[i];
if (
/^\s*$/.test(current) ||
/^\s*```/.test(current) ||
/^(#{1,4})\s+/.test(current) ||
/^\s*>\s?/.test(current) ||
/^\s*[-*+]\s+/.test(current) ||
/^\s*\d+[.)]\s+/.test(current)
) {
break;
}
paragraph.push(current);
i += 1;
}
out.push(`<p>${inline(paragraph.join("\n")).replace(/\n/g, "<br />")}</p>`);
}
return out.join("\n");
}
/** 纯文本摘要,用于列表页显示(去掉语法符号)。 */
export function plainSummary(source: string, limit = 90): string {
const text = source
.replace(/```[\s\S]*?```/g, " ")
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/[#>*_~`-]/g, " ")
.replace(/\s+/g, " ")
.trim();
return text.length > limit ? `${text.slice(0, limit)}…` : text;
}