feat(knowledge): 知识能挂附件,新建时就能传
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
不兼容,本次未编译验证。
This commit is contained in:
Steven
2026-10-30 16:18:32 +08:00
parent ffae7e380f
commit 7c84ff3b8d
7 changed files with 250 additions and 14 deletions
+35 -1
View File
@@ -5,6 +5,7 @@
* 前面造数据走接口,界面部分点真实按钮。
*/
import { chromium } from "playwright-core";
import { mkdirSync, writeFileSync } from "node:fs";
const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
@@ -98,6 +99,28 @@ try {
(await composerBody.getAttribute("aria-label")) === "正文",
);
await page.screenshot({ path: "e2e/artifacts/knowledge-composer-editor.png" });
// ②½ 附件:新建面板里就能传——点「添加附件」先把这条存成草稿,再上传
mkdirSync("e2e/artifacts", { recursive: true });
const samplePath = "e2e/artifacts/附件样例.txt";
writeFileSync(samplePath, "命令行回滚手册:kubectl rollout undo deployment/api\n");
await page.getByRole("button", { name: "添加附件", exact: true }).click();
// 面板出现后再走真实的「上传」按钮 + filechooser:页面上不止一个 file input(编辑器也有)
await page.getByRole("button", { name: "上传", exact: true }).waitFor({ timeout: 15_000 });
const [chooser] = await Promise.all([
page.waitForEvent("filechooser", { timeout: 15_000 }),
page.getByRole("button", { name: "上传", exact: true }).click(),
]);
const [uploadResp] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes("/attachments") && r.request().method() === "POST",
{ timeout: 20_000 },
),
chooser.setFiles(samplePath),
]);
await page.getByText("附件样例.txt", { exact: false }).first().waitFor({ timeout: 10_000 });
check("新建面板里能上传附件(先懒建草稿)", uploadResp.ok(), `HTTP ${uploadResp.status()}`);
await page.screenshot({ path: "e2e/artifacts/knowledge-composer-attachment.png" });
await page.getByRole("button", { name: "保存并继续写", exact: true }).click();
// 保存后直接打开详情:标题、分类路径、标签都在
@@ -107,6 +130,7 @@ try {
"新建面板能把分类与标签一起存下来",
drawerText.includes("研发 / 部署") && drawerText.includes("发布"),
);
check("详情抽屉里能看到刚传的附件", drawerText.includes("附件样例.txt"));
await page.screenshot({ path: "e2e/artifacts/knowledge-composer-saved.png", fullPage: false });
// ③ 列表卡片上能看到分类路径与标签(不用点开)
@@ -188,7 +212,17 @@ try {
doc?.category?.full_path === "研发 / 部署",
`tags=${JSON.stringify((doc?.tags ?? []).map((t) => t.name))} category=${doc?.category?.full_path}`,
);
check("正文按 Markdown 存下来了", String(doc?.body_md ?? "").includes("停掉灰度流量"));
check(
"正文按 Markdown 存下来了",
String(doc?.body_md ?? "").includes("停掉灰度流量"),
);
// 列表只带 attachment_count(省一次 N+1),文件清单要问详情
const detail = await call("GET", `/workspaces/${ws.id}/knowledge/${doc.id}`, undefined, token);
check(
"附件挂在这条知识上(详情回读得到)",
(detail?.attachments ?? []).some((a) => a.filename === "附件样例.txt"),
`attachments=${JSON.stringify((detail?.attachments ?? []).map((a) => a.filename))}`,
);
// ⑥ 分类重名与三级上限:接口层面确认约束还在
const dup = await fetch(
+26 -7
View File
@@ -20,6 +20,11 @@ interface Props {
wsId: string;
/** 新建场景还没有条目 id:正文照样能写,只是暂时不能贴图(贴图前必须先存草稿) */
itemId?: string;
/**
* 新建场景的兜底:要贴图时先老老实实把草稿存下来,拿到条目 id 再传。
* 返回 null 表示没存成(由调用方自己提示),编辑器不会继续上传。
*/
ensureItemId?: () => Promise<string | null>;
placeholder?: string;
disabled?: boolean;
/** 编辑器初始高度(px) */
@@ -46,6 +51,7 @@ export function RichTextEditor({
onChange,
wsId,
itemId,
ensureItemId,
placeholder = "写点什么…支持粘贴/拖入图片,也可以用工具栏排版",
disabled = false,
minHeight = 240,
@@ -62,6 +68,7 @@ export function RichTextEditor({
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);
@@ -70,6 +77,7 @@ export function RichTextEditor({
onErrorRef.current = onError;
resourcesRef.current = resources;
onUploadedRef.current = onUploaded;
ensureItemIdRef.current = ensureItemId;
const disabledRef = useRef(disabled);
disabledRef.current = disabled;
@@ -113,8 +121,8 @@ export function RichTextEditor({
"line",
"code",
"inline-code",
// 没有条目 id 就没有上传落点,索性不摆这个按钮,免得点了才报错
...(itemId ? ["upload"] : []),
// 没有落点就不摆这个按钮,免得点了才报错;新建时靠 ensureItemId 先存草稿
...(itemId || ensureItemId ? ["upload"] : []),
"table",
"|",
"undo",
@@ -131,9 +139,18 @@ export function RichTextEditor({
accept: "image/*",
multiple: true,
handler: async (files: File[]) => {
if (!itemId) {
onErrorRef.current?.("先保存这条内容,再回来贴图或传附件");
return null;
// 新建时还没有条目 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) {
@@ -145,7 +162,7 @@ export function RichTextEditor({
try {
for (const file of images) {
const created = await uploadFile<Attachment>(
`/workspaces/${wsId}/items/${itemId}/attachments`,
`/workspaces/${wsId}/items/${targetId}/attachments`,
file,
);
// 自己插入,不用 Vditor 代劳:它对 handler 的返回值只会
@@ -219,7 +236,9 @@ export function RichTextEditor({
? "正在上传图片…"
: itemId
? "支持直接粘贴或拖入图片;内容按 Markdown 保存,模型和导出都读得懂"
: "内容按 Markdown 保存;存下来之后就能直接贴图、传附件"}
: ensureItemId
? "支持直接粘贴或拖入图片:会先自动存成草稿,再传上去"
: "内容按 Markdown 保存;存下来之后就能直接贴图、传附件"}
</p>
</div>
);
+77 -5
View File
@@ -3,7 +3,7 @@ import { useParams } from "react-router-dom";
import { AppShell } from "../components/AppShell";
import { AttachmentPanel } from "../components/AttachmentPanel";
import { RichTextEditor } from "../components/RichTextEditor";
import { Link2, Plus, Search, Sparkle } from "../components/icons";
import { Link2, Paperclip, Plus, Search, Sparkle } from "../components/icons";
import {
Button,
EmptyState,
@@ -566,6 +566,16 @@ function KnowledgeComposer({
const [newCat, setNewCat] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
/**
* 附件与图片都要落在具体条目上,可新建时条目还不存在。
* 解法是懒建草稿:第一次传附件/贴图时才 POST 一条草稿,之后一律 PATCH。
* 没传过附件的人,取消后什么都不会留在库里。
*/
const [draftId, setDraftId] = useState<string | null>(null);
const [draftVersion, setDraftVersion] = useState(0);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const draftingRef = useRef<Promise<string | null> | null>(null);
const parentDepth = categories.find((c) => c.id === categoryId)?.depth ?? 0;
const canAddCategory = parentDepth < 3 && Boolean(newCat.trim());
@@ -581,17 +591,50 @@ function KnowledgeComposer({
}
}
/** 拿条目 id:已有就直接返回,没有就现存一条草稿;并发调用共用同一个请求。 */
async function ensureDraft(): Promise<string | null> {
if (draftId) return draftId;
if (draftingRef.current) return draftingRef.current;
const pending = (async () => {
try {
const doc = await api.post<Knowledge>(`/workspaces/${wsId}/knowledge`, {
title: title.trim() || "未命名知识",
body_md: body,
doc_type: docType,
tag_names: tags.split(/[\s,,]+/).filter(Boolean),
category_id: categoryId || null,
});
setDraftId(doc.id);
setDraftVersion(doc.version);
setAttachments(doc.attachments ?? []);
setTitle(doc.title);
setNotice("已存成草稿,现在可以继续写、贴图、传附件");
return doc.id;
} catch (err) {
setError(err instanceof ApiError ? err.message : "保存草稿失败");
return null;
} finally {
draftingRef.current = null;
}
})();
draftingRef.current = pending;
return pending;
}
async function save() {
if (!canSave) return;
const itemId = await ensureDraft();
if (!itemId) return;
setBusy(true);
setError(null);
try {
const doc = await api.post<Knowledge>(`/workspaces/${wsId}/knowledge`, {
const doc = await api.patch<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`, {
version: draftVersion,
title: title.trim(),
body_md: body,
doc_type: docType,
tag_names: tags.split(/[\s,,]+/).filter(Boolean),
category_id: categoryId || null,
...(categoryId ? { category_id: categoryId } : { clear_category: true }),
});
onCreated(doc);
} catch (err) {
@@ -607,7 +650,7 @@ function KnowledgeComposer({
onClose={onClose}
size="xl"
title="记一条知识"
subtitle="标题、正文、标签、分类一次填完;保存后可以继续贴图、传附件。"
subtitle="标题、正文、标签、分类一次填完;附件和贴图会先把这条存成草稿。"
footer={
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" disabled={!canSave} onClick={() => void save()}>
@@ -617,7 +660,9 @@ function KnowledgeComposer({
取消
</Button>
<span className="ml-auto text-2xs text-ink-400">
先存成草稿,核对发布后才进团队检索
{draftId
? "已经是草稿了:直接关掉会留在「待核对」里"
: "先存成草稿,核对发布后才进团队检索"}
</span>
</div>
}
@@ -717,12 +762,39 @@ function KnowledgeComposer({
value={body}
onChange={setBody}
minHeight={320}
ensureItemId={ensureDraft}
onError={setError}
placeholder={"写下要点、步骤、结论…\n\n标题、类型、分类、标签都在上面,正文就是这张备忘的主体。"}
/>
</div>
</div>
<div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2">
{draftId ? (
<AttachmentPanel
wsId={wsId}
itemId={draftId}
attachments={attachments}
onChange={setAttachments}
onError={setError}
/>
) : (
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1.5 text-2xs font-medium text-ink-500">
<Paperclip size={13} />
附件
</span>
<Button variant="ghost" disabled={busy} onClick={() => void ensureDraft()}>
添加附件
</Button>
<span className="text-2xs text-ink-400">
附件要挂在条目上:点一下先把这条存成草稿,然后就能上传、下载
</span>
</div>
)}
</div>
{notice && <p className="text-2xs text-primary-700">{notice}</p>}
{error && <InlineAlert>{error}</InlineAlert>}
</div>
</Sheet>