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,180 @@
|
||||
/**
|
||||
* 极简 Markdown 渲染器。
|
||||
*
|
||||
* 为什么不用 react-markdown:我们的编辑场景只需要"能读懂的一小撮语法",
|
||||
* 引一个大依赖还得处理它的插件体系;而 XSS 这类问题,自己渲染反倒更容易
|
||||
* 把"先转义、再套标签"这条铁律钉死在一个文件里。
|
||||
*
|
||||
* 支持的语法刻意很小:标题、粗体/斜体/删除线、行内代码、代码块、有序/无序
|
||||
* 列表、引用、分隔线、链接、图片、(空行分隔的)段落。
|
||||
* 不支持的(表格、脚注、HTML 片段)会原样当文本显示 —— 显示不出来比显示一半安全。
|
||||
*/
|
||||
|
||||
const ESCAPE_MAP: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
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
|
||||
//  与 [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;
|
||||
}
|
||||
Reference in New Issue
Block a user