Files
DadaoTask/web/src/components/RichTextEditor.tsx
T
Steven 7c84ff3b8d
CI / backend (push) Canceled after 0s
feat(knowledge): 知识能挂附件,新建时就能传
知识是备忘录,备忘录不能只有字:手册、日志、导出表格都得能挂上去。
附件本来就落在 item 上(`ALLOWED_KINDS` 早含 knowledge),缺的是"新建时
还没有条目 id"这一段。

- 懒建草稿:`KnowledgeComposer` 第一次传附件(或往正文贴图)时先 `POST /knowledge`
  存一条草稿拿到 id,`draftingRef` 去重防止贴图与上传同时打出两条草稿;
  之后的保存走 `PATCH /knowledge/{id}`。没传过附件就取消,库里不留空条目。
- `RichTextEditor` 增 `ensureItemId`:没有 itemId 时先把草稿存下来再传图,
  于是"新建时不能贴图"这条限制也没了;工具栏上传按钮按是否有落点显隐。
- 新建面板加了附件区:有草稿 id 就是正常的 `AttachmentPanel`(上传/下载/删),
  没有就先给一个「添加附件」按钮,点一下建草稿。
- 安卓知识详情加附件区:列表(文件名 + 大小)、`OpenDocument` 上传任意文件、
  点「打开」用浏览器看签名直链。编辑器原本只能插图,现在手册/日志也能传。
- 文档:API.md 说明附件三类事项共用同一套接口,以及懒建草稿的先后顺序;
  ROADMAP 加 3.13。

验证:后端 pytest 94 passed、ruff 全绿;Web `tsc --noEmit` 通过、`vite build` 通过;
`knowledge-notebook.mjs` 15 项全绿(新增「新建面板里能上传附件」「详情抽屉里能看到
刚传的附件」「附件挂在知识上,详情回读得到」)。安卓端仍是 JDK 25 + Gradle 8.7
不兼容,本次未编译验证。
2026-10-30 16:18:32 +08:00

246 lines
8.9 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 { useEffect, useRef, useState } from "react";
import type { IVditorInstance } from "vditor/dist/index.js";
import { applyAttachmentUrls, stripAttachmentTokens, type UrlResources } from "../lib/attachmentUrls";
import { canonicalAttachmentPath, uploadFile } from "../lib/client";
interface Attachment {
id: string;
filename: string;
/** 后端签发的直链(带短期签名),用来在编辑器里直接显示刚传的图 */
raw_url?: string;
}
interface Props {
/** 进出的都是"干净"的 Markdown:不含附件令牌,令牌只在编辑器内部临时存在 */
value: string;
onChange: (next: string) => void;
/** 图片要落到具体条目下,所以这两个是必填 */
wsId: string;
/** 新建场景还没有条目 id:正文照样能写,只是暂时不能贴图(贴图前必须先存草稿) */
itemId?: string;
/**
* 新建场景的兜底:要贴图时先老老实实把草稿存下来,拿到条目 id 再传。
* 返回 null 表示没存成(由调用方自己提示),编辑器不会继续上传。
*/
ensureItemId?: () => Promise<string | null>;
placeholder?: string;
disabled?: boolean;
/** 编辑器初始高度(px) */
minHeight?: number;
ariaLabel: string;
onError?: (message: string) => void;
/** 附件 id → 可访问地址;编辑器渲染图片时优先用它(签名比令牌安全且会轮换) */
resources?: UrlResources;
/** 编辑器里贴图上传成功后通知外层,好让附件面板立刻多一条 */
onUploaded?: (attachment: Attachment) => void;
}
/**
* 富文本编辑器:Vditor(MIT,中文优先,Markdown 原生进出)。
*
* 为什么不是自研:我上一版用 textarea + 正则做工具栏,看着能用,但一到
* 粘贴图片、中文输入法、撤销栈、表格就露馅——这些正是"成熟组件"积累的部分。
*
* 为什么存储仍用 Markdown:Vditor 的 `ir` 模式给出"边写边看"的观感,
* 但它落盘的是 Markdown 而不是 HTML,导出、检索、喂给 LLM 都还是纯文本。
*/
export function RichTextEditor({
value,
onChange,
wsId,
itemId,
ensureItemId,
placeholder = "写点什么…支持粘贴/拖入图片,也可以用工具栏排版",
disabled = false,
minHeight = 240,
ariaLabel,
onError,
resources,
onUploaded,
}: Props) {
const hostRef = useRef<HTMLDivElement | null>(null);
const instRef = useRef<IVditorInstance | null>(null);
/** 外部最新值(已剥令牌),用来判断"这次变化是不是自己打出来的" */
const valueRef = useRef(value);
const onChangeRef = useRef(onChange);
const onErrorRef = useRef(onError);
const resourcesRef = useRef(resources);
const onUploadedRef = useRef(onUploaded);
const ensureItemIdRef = useRef(ensureItemId);
const [ready, setReady] = useState(false);
const [uploading, setUploading] = useState(false);
valueRef.current = value;
onChangeRef.current = onChange;
onErrorRef.current = onError;
resourcesRef.current = resources;
onUploadedRef.current = onUploaded;
ensureItemIdRef.current = ensureItemId;
const disabledRef = useRef(disabled);
disabledRef.current = disabled;
// 每个条目一个编辑器实例:切条目必须重建,否则撤销栈、高度、图片上传路径都会串
useEffect(() => {
const host = hostRef.current;
if (!host) return;
let disposed = false;
void (async () => {
const [{ default: Vditor }] = await Promise.all([
import("vditor/dist/index.js"),
import("vditor/dist/index.css"),
]);
if (disposed) return;
const instance = new Vditor(host, {
cdn: "/vditor", // 本地托管 lute/i18n/icon,不走公网 CDN(见 web/public/vditor)
mode: "ir",
value: applyAttachmentUrls(valueRef.current, resourcesRef.current),
height: minHeight,
placeholder,
theme: "classic",
icon: "ant",
lang: "zh_CN",
cache: { enable: false }, // 草稿由我们自己的「保存」按钮负责,不用它的 localStorage
counter: { enable: false },
outline: { enable: false },
toolbarConfig: { pin: true },
toolbar: [
"headings",
"bold",
"italic",
"strike",
"link",
"|",
"list",
"ordered-list",
"check",
"quote",
"line",
"code",
"inline-code",
// 没有落点就不摆这个按钮,免得点了才报错;新建时靠 ensureItemId 先存草稿
...(itemId || ensureItemId ? ["upload"] : []),
"table",
"|",
"undo",
"redo",
"fullscreen",
],
preview: {
// 关掉代码高亮与公式:它们要额外从 CDN 拉三端脚本,内网/离线会白等
hljs: { enable: false, lineNumber: false },
math: { engine: "" },
markdown: { toc: false, mark: false, footnotes: false },
},
upload: {
accept: "image/*",
multiple: true,
handler: async (files: File[]) => {
// 新建时还没有条目 id:先让调用方把草稿存下来,拿到落点再传
let targetId = itemId;
if (!targetId) {
const ensured = await ensureItemIdRef.current?.();
if (!ensured) {
// 存草稿失败的原因由 ensureItemId 自己报,这里不叠一层噪音
if (!ensureItemIdRef.current) {
onErrorRef.current?.("先保存这条内容,再回来贴图或传附件");
}
return null;
}
targetId = ensured;
}
const images = files.filter((file) => file.type.startsWith("image/"));
if (images.length === 0) {
onErrorRef.current?.("只能插入图片");
return null;
}
setUploading(true);
try {
for (const file of images) {
const created = await uploadFile<Attachment>(
`/workspaces/${wsId}/items/${targetId}/attachments`,
file,
);
// 自己插入,不用 Vditor 代劳:它对 handler 的返回值只会
// tip.show(字符串) 当成提示语显示,并不会把内容插进正文。
const src =
created.raw_url ?? canonicalAttachmentPath(wsId, created.id);
instRef.current?.insertMD(`\n![](${src})\n`);
onUploadedRef.current?.(created);
}
return null;
} catch (error) {
onErrorRef.current?.(error instanceof Error ? error.message : "图片上传失败");
return null;
} finally {
setUploading(false);
}
},
},
input: (next: string) => {
// 往外吐干净版本;比较用同一口径,避免 setValue/sync 互相打架
onChangeRef.current(stripAttachmentTokens(next));
},
after: () => {
if (disposed) return;
// 让屏幕阅读器和 e2e 都能按名字找到这块编辑区
host.querySelector(".vditor-ir [contenteditable]")?.setAttribute("aria-label", ariaLabel);
if (disabledRef.current) instRef.current?.disabled();
setReady(true);
},
});
instRef.current = instance as unknown as IVditorInstance;
})();
return () => {
disposed = true;
setReady(false);
try {
instRef.current?.destroy();
} catch {
// 销毁失败不该影响切页面
}
instRef.current = null;
};
// 只跟条目走;其余参数通过 ref 读最新值,避免重建丢焦点
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemId, wsId]);
// 外部值变化(例如抽屉刚 hydrate 完)→ 写回编辑器,但别打断正在输入的人
useEffect(() => {
const inst = instRef.current;
if (!inst) return;
const current = stripAttachmentTokens(inst.getValue());
if (current !== value) {
inst.setValue(applyAttachmentUrls(value, resourcesRef.current), true);
}
}, [value, ready]);
useEffect(() => {
const inst = instRef.current;
if (!inst) return;
if (disabled) inst.disabled();
else inst.enable();
}, [disabled, ready]);
return (
<div className="rich-text">
<div ref={hostRef} />
<p className="mt-1 text-2xs text-ink-400">
{uploading
? "正在上传图片…"
: itemId
? "支持直接粘贴或拖入图片;内容按 Markdown 保存,模型和导出都读得懂"
: ensureItemId
? "支持直接粘贴或拖入图片:会先自动存成草稿,再传上去"
: "内容按 Markdown 保存;存下来之后就能直接贴图、传附件"}
</p>
</div>
);
}