feat(knowledge): 阅读改成独立整页,抽屉只用来编辑
CI / backend (push) Canceled after 0s

知识原来一律在抽屉里打开,正文贴着屏幕右半边,长文读十几屏眼睛一直在右边。
阅读是「坐下来读一会儿」,编辑是「改两句就走」,两件事不该共用一个容器。

- 新增阅读页 /w/:wsId/knowledge/:itemId:正文列限到 40rem 居中,28px 标题,
  更新/发布/切片行,标签与来源,动作行放编辑与发布/归档/恢复
- 列表页与搜索结果改成跳阅读页,不再弹抽屉
- KnowledgeDrawer 加 initialEditing:进来就是编辑器,保存或取消直接关
- 文案常量搬到 lib/knowledge.ts,列表页与阅读页共用一份

顺带修三个 e2e 脚本:新建后进阅读页没有「关闭」按钮可点、侧栏色点按钮让树
顺序断言多出空串、翻页脚本还在用已关闭的短信登录(改密码登录)。
This commit is contained in:
Steven
2026-11-03 12:12:51 +08:00
parent 127303b7aa
commit d34a6ac69e
10 changed files with 621 additions and 94 deletions
+9 -2
View File
@@ -79,7 +79,10 @@ try {
// 树要按「父在前、子紧跟」排:部署必须紧挨在研发后面,而不是堆在别的根分类后面
const navTexts = await page.locator("aside nav button").allInnerTexts();
const navNames = navTexts.map((t) => t.replace(/[·\d]/g, "").trim());
// 分类行里的色点是按钮但没有文字,剔除掉空串再比顺序
const navNames = navTexts
.map((t) => t.replace(/[·\d]/g, "").trim())
.filter(Boolean);
check(
"侧栏分类按树的顺序排(子紧跟在父后面)",
navNames.indexOf("部署") === navNames.indexOf("研发") + 1,
@@ -142,8 +145,12 @@ try {
check("详情抽屉里能看到刚传的附件", drawerText.includes("附件样例.txt"));
await page.screenshot({ path: "e2e/artifacts/knowledge-composer-saved.png", fullPage: false });
// 保存之后直接进阅读页:退回列表,回来验卡片
await page.waitForURL(/\/knowledge\/[0-9a-f-]{8,}$/, { timeout: 10_000 });
await page.getByRole("link", { name: "返回知识库" }).click();
await page.waitForURL(/\/knowledge$/, { timeout: 10_000 });
// ③ 列表卡片上能看到分类路径与标签(不用点开)
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()) ?? "";
+31 -6
View File
@@ -8,12 +8,12 @@
* node web/e2e/knowledge-paging.mjs # 默认打生产
* WEB=http://127.0.0.1:5180 PHONE=13900000001 node web/e2e/knowledge-paging.mjs
*/
import { readFile } from "node:fs/promises";
import { chromium } from "playwright-core";
const WEB = process.env.WEB ?? "http://192.168.8.188:8200";
const API = process.env.API ?? `${WEB}/api/v1`;
const PHONE = process.env.PHONE ?? "13605513683";
const CODE = process.env.CODE ?? "123456";
const CODE_PIN = process.env.CODE_PIN ?? "123456"; // 生产 pin 就是验证码本身
const CHROME =
process.env.CHROME ??
@@ -31,14 +31,29 @@ function check(name, ok, extra = "") {
}
}
/**
* 登录:默认用密码(生产的短信登录已经关了),密码从 PASSWORD 拿,
* 或者从 .local/e2e-account-password.txt 读(dev 13900000001 的密码存在那儿)。
* 真没密码时才回落到短信验证码——那种环境下短信登录得开着。
*/
async function login() {
const r = await fetch(`${API}/auth/sms/send`, {
const password = await readPassword();
if (password) {
const r = await fetch(`${API}/auth/login/password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone: PHONE, password }),
});
const body = await r.json();
if (body.code !== 0) throw new Error(`登录失败:${body.message}`);
return { tokens: body.data.tokens, workspaces: await workspaces(body.data.tokens) };
}
const sent = await fetch(`${API}/auth/sms/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone: PHONE }),
});
const sent = await r.json();
if (sent.code !== 0) throw new Error(`发码失败:${sent.message}`);
}).then((r) => r.json());
if (sent.code !== 0) throw new Error(`发码失败:${sent.message}(配个 PASSWORD 或写下密码文件就能走密码登录)`);
const login = await fetch(`${API}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -49,6 +64,17 @@ async function login() {
return { tokens: body.data.tokens, workspaces: await workspaces(body.data.tokens) };
}
async function readPassword() {
if (process.env.PASSWORD) return process.env.PASSWORD;
try {
const file = new URL("../../.local/e2e-account-password.txt", import.meta.url);
const text = (await readFile(file, "utf8")).trim();
return text || null;
} catch {
return null;
}
}
async function workspaces(tokens) {
const r = await fetch(`${API}/workspaces`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
@@ -58,7 +84,6 @@ async function workspaces(tokens) {
}
async function main() {
void CODE;
const { tokens, workspaces: spaces } = await login();
if (!spaces.length) throw new Error("这个账号没有工作区");
const ws = spaces[0];
+157
View File
@@ -0,0 +1,157 @@
/**
* 知识阅读页的验收:点开一条知识应该离开列表、进整页阅读,正文落在视线中间。
*
* 断言的是用户提的那件事,而不是"有个 drawer":
* ① 点卡片后 URL 变成 /w/<ws>/knowledge/<id>,列表页的元素不在了
* ② 正文渲染成样式(h2 存在),不是编辑器
* ③ 正文列的宽度 ≤ 46rem 且在内容区里居中(左右留白差不多)
* ④ 点「编辑」才出现编辑器(抽屉),保存/取消都收回去
*/
import { chromium } from "playwright-core";
import { mkdirSync, readFileSync } 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";
const PHONE = process.env.PHONE ?? "13900000001";
const OUT = "e2e/artifacts";
const PWD_FILE = new URL("../../.local/e2e-account-password.txt", import.meta.url);
let pass = 0;
let fail = 0;
function check(name, ok, extra = "") {
if (ok) {
pass += 1;
console.log(`PASS ${name}`);
} else {
fail += 1;
console.log(`FAIL ${name}${extra ? ` — ${extra}` : ""}`);
}
}
const password = (() => {
try {
return readFileSync(PWD_FILE, "utf8").trim();
} catch {
return "";
}
})();
const browser = await chromium.launch({
executablePath:
process.env.CHROME ??
`${process.env.LOCALAPPDATA}/ms-playwright/chromium-1223/chrome-win64/chrome.exe`,
});
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
const errors = [];
page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
page.on("pageerror", (e) => errors.push(String(e)));
mkdirSync(OUT, { recursive: true });
try {
const r = await fetch(`${API}/auth/login/password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone: PHONE, password }),
});
const login = await r.json();
if (login.code !== 0) throw new Error(`登录失败:${JSON.stringify(login)}`);
const ws = login.data.user ? null : null;
void ws;
const token = login.data.tokens.access_token;
const refresh = login.data.tokens.refresh_token;
// 找一个有正文的知识
const listResp = await fetch(`${API}/workspaces`, {
headers: { Authorization: `Bearer ${token}` },
}).then((x) => x.json());
const workspaces = listResp.data.items ?? listResp.data.workspaces ?? listResp.data;
const workspace = workspaces.find((w) => w.name?.includes("备忘")) ?? workspaces[0];
const wsId = workspace.id;
const docsResp = await fetch(`${API}/workspaces/${wsId}/knowledge?limit=50`, {
headers: { Authorization: `Bearer ${token}` },
}).then((x) => x.json());
const docs = docsResp.data.items.filter((d) => (d.body_md ?? "").length > 200);
if (docs.length === 0) throw new Error("没有正文够长的知识可用来验收");
const target = docs[0];
await page.addInitScript(
([access, refreshToken]) => {
localStorage.setItem("dadao.access", access);
localStorage.setItem("dadao.refresh", refreshToken);
},
[token, refresh],
);
await page.goto(`${WEB}/w/${wsId}/knowledge`);
await page.waitForSelector("li button");
await page
.locator("li")
.filter({ hasText: target.title.slice(0, 8) })
.first()
.getByRole("button")
.first()
.click();
const onDoc = await page
.waitForURL(new RegExp(`/w/${wsId}/knowledge/${target.id}$`), { timeout: 10000 })
.then(() => true)
.catch(() => false);
check("点卡片进的是独立阅读页(不是抽屉)", onDoc, page.url());
await page.waitForSelector("h1");
const title = await page.locator("h1").first().innerText();
check("整页标题就是这条知识的标题", title.trim() === target.title.trim(), title);
// 正文渲染成样式:markdown 里的标题/列表应该变成真元素
const hasProse = (await page.locator(".prose-dadao").count()) > 0;
const hasEditor = (await page.locator(".vditor-ir").count()) > 0;
check("阅读时是渲染后的正文,不是编辑器", hasProse && !hasEditor, `prose=${hasProse} editor=${hasEditor}`);
// 正文列在内容区里居中、宽度适合阅读
const box = await page.locator(".prose-dadao").first().boundingBox();
const main = await page.locator("main").first().boundingBox();
if (box && main) {
const leftGap = box.x - main.x;
const rightGap = main.x + main.width - (box.x + box.width);
const width = box.width;
check(
"正文列限宽到可读宽度(≤46rem≈736px)",
width <= 760,
`width=${Math.round(width)}`,
);
check(
"正文列在内容区里居中",
Math.abs(leftGap - rightGap) < 40,
`left=${Math.round(leftGap)} right=${Math.round(rightGap)}`,
);
} else {
check("正文列在内容区里居中", false, "拿不到布局尺寸");
}
await page.screenshot({ path: `${OUT}/knowledge-read-page.png` });
// 编辑才是抽屉
await page.getByRole("button", { name: "编辑", exact: true }).click();
await page.waitForSelector(".vditor-ir", { timeout: 10000 });
check("点「编辑」才出现编辑器(抽屉)", (await page.locator(".vditor-ir").count()) > 0);
await page.screenshot({ path: `${OUT}/knowledge-edit-drawer.png` });
await page.getByRole("button", { name: "取消", exact: true }).click();
const closed = await page
.waitForSelector(".vditor-ir", { state: "detached", timeout: 10000 })
.then(() => true)
.catch(() => false);
check("取消编辑后回到阅读页", closed);
check("控制台无报错", errors.length === 0, errors.slice(0, 2).join(" | "));
} catch (err) {
fail += 1;
console.log(`FAIL 脚本异常 — ${err}`);
} finally {
await browser.close();
}
console.log(`\n${fail === 0 ? "ALL PASS" : "FAILED"} — pass ${pass}, fail ${fail}`);
process.exit(fail === 0 ? 0 : 1);