CI / backend (push) Canceled after 0s
知识是备忘录,备忘录不能只有字:手册、日志、导出表格都得能挂上去。
附件本来就落在 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
不兼容,本次未编译验证。
246 lines
8.9 KiB
TypeScript
246 lines
8.9 KiB
TypeScript
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\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>
|
||
);
|
||
}
|