知识是备忘录,备忘录不能只有字:手册、日志、导出表格都得能挂上去。
附件本来就落在 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:
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user