feat(knowledge): 知识改成备忘录形态,一次写全 + 三级分类,抽屉加宽
CI / backend (push) Canceled after 0s

知识以前只能"填个标题就完成",正文、标签、归类都要点进去再补。现在新建
就是一张完整的备忘录:标题、类型、分类、标签、正文一次写完,存完直接打开
详情继续编辑;知识页左侧多了工作区三级分类树,可按分类子树 / 未分类 / 标签
筛选。凡是填信息的抽屉都加宽了,还能一键展开整屏。

后端
- 新增 `knowledge_category`(迁移 0007):`workspace_id NOT NULL` + 自引用
  `parent_id`、`depth` 1..3 检查约束、同层重名唯一索引(lower(name))、
  RLS 策略与 `dadao_app` 授权;`knowledge_doc.category_id` 外键 ON DELETE
  SET NULL。
- 新增 `/knowledge/categories` 增删改查:超三级 `7007`、同层重名 `7008`、
  跨租户或不存在 `7006`;`move=true` 才改父级,成环与移动后超过三级的子树
  一律拒绝,删除有知识的分类要 `force=true`(知识变未分类)。
- 知识列表支持 `category_id`(含子树)、`uncategorized`、`tag`;输出带
  `category`(含 `path` / `full_path`)。
- PATCH 只改分类或标签也会推进 `version`,避免"只动标签"之后下次保存撞
  乐观锁 `4009`。

Web
- `Sheet` 增 `size="md/lg/xl"` 与展开整屏按钮;任务、工单、知识抽屉统一
  加宽(`TaskDetail` / `Tickets` / `Knowledge`)。
- 知识页:左侧三级分类树(新建、按子树筛选、未分类)、`KnowledgeComposer`
  一次写全新的备忘录、列表卡片露出分类路径与标签、详情抽屉里可直接改分类
  与标签。
- 抽屉详情只拉一次:重复的 effect 或迟到的响应不再覆盖用户正在敲的内容。

安卓
- `Api.kt`:分类模型与接口、新建/更新带 `tag_names`/`category_id`、
  列表带分类与标签筛选。
- 知识页重做:新建改为近全屏大对话框(标题/类型/分类/标签/正文),列表
  顶部按分类筛选,详情里能改分类与标签后单独保存。

文档与测试
- `2.Design/API.md`:5.1/5.3/5.4 补分类与筛选参数,新增 5.5 分类接口章节,
  错误码补 `7006`/`7007`/`7008`。
- `2.Design/ROADMAP-M0-M1.md`:新增 3.11(三级分类)、3.12(备忘录形态)。
- 新增 `backend/tests/test_knowledge_categories.py`(8 例):备忘形态落库、
  三级到底、移动越界与成环、重名、按子树/未分类/标签/关键词筛选、删分类需
  `force`、跨租户隔离。
- 新增 `web/e2e/knowledge-notebook.mjs`(10 项,全绿):建两级分类 → 一次
  写全 → 卡片与筛选 → 抽屉改标签/分类 → 正文落库 → 重名 7008。

验证:`pytest` 93 passed、`ruff check` / `ruff format --check` 全过、
`tsc --noEmit` 通过、`vite build` 通过、`knowledge-notebook.mjs` 10 项全绿。
安卓端本机只有 JDK 25,Gradle 8.7 / AGP 8.7.3 不支持,本次未做编译验证。
This commit is contained in:
Steven
2026-10-30 03:51:43 +08:00
parent 91b3dde670
commit ed31af3f46
18 changed files with 1932 additions and 164 deletions
+201
View File
@@ -0,0 +1,201 @@
/**
* 知识备忘录形态 e2e:分类树 + 新建时一次填全(标题/类型/分类/标签/正文)+ 按分类筛。
*
* 与 knowledge.mjs 的分工:那个脚本验"沉淀 → 发布"闭环,这个只验"知识是最像备忘录的形态"。
* 前面造数据走接口,界面部分点真实按钮。
*/
import { chromium } from "playwright-core";
const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
const stamp = String(Date.now()).slice(-7);
const phone = `137${stamp}0`;
let failures = 0;
function check(name, ok, extra = "") {
console.log(`${ok ? "PASS" : "FAIL"} ${name}${extra ? ` — ${extra}` : ""}`);
if (!ok) failures += 1;
}
async function call(method, path, body, token) {
const res = await fetch(`${API}${path}`, {
method,
headers: {
"content-type": "application/json",
...(token ? { authorization: `Bearer ${token}` } : {}),
...(method === "GET" ? {} : { "idempotency-key": `e2e-${Math.random()}` }),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
const json = text ? JSON.parse(text) : {};
if (json.code !== 0) throw new Error(`${method} ${path} -> ${json.code} ${json.message}`);
return json.data;
}
const browser = await chromium.launch({
executablePath:
process.env.CHROME ??
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`,
});
const consoleErrors = [];
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
page.on("console", (m) => {
if (m.type() === "error") consoleErrors.push(m.text());
});
page.on("response", (r) => {
if (r.status() >= 400) consoleErrors.push(`${r.status()} ${r.url()}`);
});
try {
await call("POST", "/auth/sms/send", { phone });
const login = await call("POST", "/auth/login", { phone, code: "123456" });
const token = login.tokens.access_token;
const ws = await call("POST", "/workspaces", { name: `知识备忘 ${stamp}` }, token);
await page.addInitScript(
([access, refresh]) => {
localStorage.setItem("dadao.access", access);
localStorage.setItem("dadao.refresh", refresh);
},
[token, login.tokens.refresh_token],
);
await page.goto(`${WEB}/w/${ws.id}/knowledge`, { waitUntil: "networkidle" });
await page.getByRole("heading", { name: "知识" }).waitFor({ timeout: 10_000 });
// ① 建两级分类:研发 / 部署
await page.getByRole("button", { name: "+ 新建", exact: true }).click();
await page.getByLabel("新分类名").fill("研发");
await page.getByRole("button", { name: "创建", exact: true }).click();
await page.getByRole("button", { name: /^研发/, exact: false }).first().waitFor();
await page.getByRole("button", { name: "+ 新建", exact: true }).click();
await page.getByLabel("新分类名").fill("部署");
await page.getByLabel("父分类").selectOption({ label: "研发 下" });
await page.getByRole("button", { name: "创建", exact: true }).click();
await page.getByRole("button", { name: /^部署/, exact: false }).first().waitFor();
check("能在侧栏建出「研发 / 部署」两级分类", true);
// ② 记一条:一个面板里填完标题/类型/分类/标签/正文
await page.getByRole("button", { name: "记一条", exact: true }).click();
await page.getByLabel("标题").fill("回滚前先停灰度");
await page.getByLabel("类型").selectOption("howto");
await page.getByLabel("分类", { exact: true }).selectOption({ label: "研发 / 部署" });
await page.getByLabel("标签").fill("发布 事故");
await page
.getByLabel("正文")
.fill("## 步骤\n1. 停掉灰度流量\n2. 回滚镜像到上一个 tag\n3. 验证关键接口");
await page.getByRole("button", { name: "保存并继续写", exact: true }).click();
// 保存后直接打开详情:标题、分类路径、标签都在
await page.getByText("回滚前先停灰度", { exact: false }).first().waitFor({ timeout: 10_000 });
const drawerText = (await page.textContent("body")) ?? "";
check(
"新建面板能把分类与标签一起存下来",
drawerText.includes("研发 / 部署") && drawerText.includes("发布"),
);
await page.screenshot({ path: "e2e/artifacts/knowledge-composer-saved.png", fullPage: false });
// ③ 列表卡片上能看到分类路径与标签(不用点开)
await page.getByRole("button", { name: "关闭", exact: true }).click();
const card = page.locator("li").filter({ hasText: "回滚前先停灰度" }).first();
await card.waitFor({ timeout: 10_000 });
const cardText = (await card.textContent()) ?? "";
check("列表卡片带分类路径与标签", cardText.includes("研发 / 部署") && cardText.includes("#发布"));
// ④ 侧栏按分类筛:部署里有,未分类里没有
await page.getByRole("button", { name: /^部署/, exact: false }).first().click();
await page.waitForTimeout(400);
check(
"点「部署」能看到归属该分类的知识",
await page.getByText("回滚前先停灰度", { exact: false }).first().isVisible(),
);
await page.getByRole("button", { name: "未分类", exact: true }).click();
await page.waitForTimeout(400);
const uncategorizedHasCard = await page
.getByText("回滚前先停灰度", { exact: false })
.first()
.isVisible()
.catch(() => false);
check("「未分类」里不出现已归类的知识", !uncategorizedHasCard);
await page.screenshot({ path: "e2e/artifacts/knowledge-category-filter.png", fullPage: false });
// ⑤ 抽屉里能改标签与分类,改完接口里就是新的
await page.getByRole("button", { name: "全部知识", exact: true }).click();
// 先等抽屉自己把详情拉回来再填:GET 比手快一步的话,刚填的字会被它覆盖掉
await Promise.all([
page.waitForResponse(
(r) =>
r.url().includes("/knowledge/") &&
r.request().method() === "GET" &&
!r.url().endsWith("/knowledge/categories") &&
r.status() === 200,
),
page.getByRole("button", { name: "打开", exact: true }).first().click(),
]);
const tagInput = page.getByLabel("标签", { exact: true }).last();
await tagInput.waitFor({ timeout: 10_000 });
// 先等抽屉把自己拉回来的旧标签填上——那一刻之后才没有"迟到的响应"来覆盖输入
await page
.waitForFunction(
() => {
const el = document.querySelector('input[aria-label="标签"]');
return !!el && el.value.includes("事故");
},
null,
{ timeout: 10_000 },
)
.catch(() => {});
// 用真实键盘输入而不是 fill:fill 走原生 setter,React 的受控 input 有时收不到这次变更
await tagInput.click();
await tagInput.press("Control+A");
await page.keyboard.type("发布 回滚", { delay: 20 });
check("抽屉里的标签输入框拿到新值", (await tagInput.inputValue()) === "发布 回滚");
const [patchResp] = await Promise.all([
page.waitForResponse(
(r) =>
r.url().includes("/knowledge/") &&
r.request().method() === "PATCH" &&
r.status() === 200,
),
page.getByRole("button", { name: "保存", exact: true }).last().click(),
]);
check("抽屉里保存标签/分类成功", patchResp.ok());
const list = await call(
"GET",
`/workspaces/${ws.id}/knowledge?status=draft&status=in_review`,
undefined,
token,
);
const doc = list.items.find((d) => d.title === "回滚前先停灰度");
check(
"接口里标签已更新、分类是「研发 / 部署」",
String((doc?.tags ?? []).map((t) => t.name).join(" ")).includes("回滚") &&
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("停掉灰度流量"));
// ⑥ 分类重名与三级上限:接口层面确认约束还在
const dup = await fetch(
`${API}/workspaces/${ws.id}/knowledge/categories`,
{
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ name: "部署", parent_id: doc.category.parent_id }),
},
).then((r) => r.json());
check("同层重名被拒(7008)", dup.code === 7008, `code=${dup.code}`);
check("浏览器控制台与网络无报错", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | "));
} catch (err) {
failures += 1;
console.log(`FAIL 脚本异常 — ${err.message}`);
await page.screenshot({ path: "e2e/artifacts/knowledge-notebook-failure.png" }).catch(() => {});
console.log((await page.textContent("body"))?.slice(0, 600));
} finally {
await browser.close();
console.log(failures === 0 ? "\nALL PASS" : `\n${failures} FAILED`);
process.exit(failures === 0 ? 0 : 1);
}