diff --git a/2.Design/ROADMAP-M0-M1.md b/2.Design/ROADMAP-M0-M1.md
index 4ff32ad..6e38cb1 100644
--- a/2.Design/ROADMAP-M0-M1.md
+++ b/2.Design/ROADMAP-M0-M1.md
@@ -660,3 +660,46 @@
- 生产 env **不带** `EMAIL_CODE_DEV_ECHO`:码只发到邮箱,页面不回显。
- 踩到的坑:dev 库要先 `alembic upgrade head` 到 0011,否则注册接口报
`column "email_verified_at" of relation "app_user" does not exist` 直接 500——迁移写好不等于跑过。
+
+## 11. 知识的阅读与编辑分开(2026-11-03)
+
+用户原话:"知识页,浏览内容时是打开抽屉页然后展示,这不符合人类的阅读习惯,因为这样的内容完全偏右,
+长时间浏览很难受。抽屉效果只能用于编辑,因为这是短时间的,也不会经常编辑同一篇文章。"
+
+### 11.1 决定
+
+- **阅读走整页**:`/w/:wsId/knowledge/:itemId`。抽屉在屏幕右侧,正文永远偏在右边,
+ 长文读十几屏眼睛一直在右半边;整页可以把正文列限到 40rem 并居中,行宽回到四十来个汉字。
+- **抽屉只留给编辑**:编辑是短时动作,改完就走,抽屉"压在当前列表上"反而合适,
+ 也省掉从编辑页退回列表时的滚动位置丢失。
+- 阅读页复用列表页那套文案常量,避免两处各写一份以后对不上(`lib/knowledge.ts`)。
+
+### 11.2 Web
+
+| 位置 | 内容 |
+|---|---|
+| `lib/knowledge.ts`(新) | `DOC_TYPE_LABEL` / `STATUS_LABEL` / `SOURCE_LABEL` / `ACTION_LABEL` / `CATEGORY_PALETTE` / `categoryColor()`,从列表页搬出来 |
+| `pages/KnowledgeDoc.tsx`(新) | 阅读页:面包屑(返回知识库 / 分类路径)、类型+状态+分类 chip、28px 标题、`更新于 / 发布于 / 检索切片 N 段`、标签、动作行(编辑/发布/归档/恢复)、`Markdown` 渲染、来源卡片、附件(只读) |
+| `pages/Knowledge.tsx` | 卡片与「打开原文」改成 `navigate("/w//knowledge/")`;删掉 `openId` 状态与抽屉渲染;`KnowledgeDrawer` 加 `initialEditing`(进来就是编辑器,保存或取消都直接关) |
+| `App.tsx` | 新增 `/w/:wsId/knowledge/:itemId` 路由 |
+
+正文列 `max-w-[40rem]` 居中、卡片 `max-w-[46rem]`;换一篇自动回顶。
+编辑仍然是抽屉:`initialEditing` 时没有「看」这一态,点「取消」直接关掉,不留半截改动。
+
+### 11.3 验证
+
+- e2e `web/e2e/knowledge-read.mjs`(新,8 项):点卡片进独立页、`h1` 是标题、正文是渲染后的 `.prose-dadao`
+ 且 `.vditor-ir` 计数为 0、正文列 ≤ 760px、正文列在 `main` 里左右留白差 < 40px、点「编辑」才出现编辑器、
+ 取消后编辑器 detach、控制台零报错。
+- 回归:`knowledge-notebook.mjs`(17 项,含"侧栏分类按树排"与"抽屉默认渲染样式")、`knowledge.mjs`(9 项)、
+ `knowledge-paging.mjs`(5 项)全绿。
+- `tsc --noEmit` 0、`vite build` 通过。
+- 截图:`2.Design/screens/knowledge-read-page.png`(长文整页)、`knowledge-edit-drawer.png`(抽屉编辑态)。
+
+### 11.4 顺带修掉的脚本坑
+
+- `knowledge-notebook.mjs` 里"关掉抽屉"那一步在新版里没有按钮可点(保存后直接进阅读页),
+ 改成等 URL 落到 `/knowledge/` 再点「返回知识库」。
+- 侧栏分类行里的色点是按钮但没文字,`allInnerTexts()` 会多出一个空串,比较树顺序前先 `.filter(Boolean)`。
+- `knowledge-paging.mjs` 原来靠短信发码登录,生产短信已关;改成优先用 `PASSWORD`
+ (或 `.local/e2e-account-password.txt`)走密码登录,没密码时才回落到短信。
diff --git a/2.Design/screens/knowledge-edit-drawer.png b/2.Design/screens/knowledge-edit-drawer.png
new file mode 100644
index 0000000..be042b3
Binary files /dev/null and b/2.Design/screens/knowledge-edit-drawer.png differ
diff --git a/2.Design/screens/knowledge-read-page.png b/2.Design/screens/knowledge-read-page.png
new file mode 100644
index 0000000..da8e6b8
Binary files /dev/null and b/2.Design/screens/knowledge-read-page.png differ
diff --git a/web/e2e/knowledge-notebook.mjs b/web/e2e/knowledge-notebook.mjs
index 99a2f61..9038a15 100644
--- a/web/e2e/knowledge-notebook.mjs
+++ b/web/e2e/knowledge-notebook.mjs
@@ -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()) ?? "";
diff --git a/web/e2e/knowledge-paging.mjs b/web/e2e/knowledge-paging.mjs
index 59d0e3f..dcc33eb 100644
--- a/web/e2e/knowledge-paging.mjs
+++ b/web/e2e/knowledge-paging.mjs
@@ -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];
diff --git a/web/e2e/knowledge-read.mjs b/web/e2e/knowledge-read.mjs
new file mode 100644
index 0000000..12f1496
--- /dev/null
+++ b/web/e2e/knowledge-read.mjs
@@ -0,0 +1,157 @@
+/**
+ * 知识阅读页的验收:点开一条知识应该离开列表、进整页阅读,正文落在视线中间。
+ *
+ * 断言的是用户提的那件事,而不是"有个 drawer":
+ * ① 点卡片后 URL 变成 /w//knowledge/,列表页的元素不在了
+ * ② 正文渲染成样式(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);
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 967d0ab..7929b43 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -12,6 +12,7 @@ import { TasksPage } from "./pages/Tasks";
import { BoardPage } from "./pages/Board";
import { TicketsPage } from "./pages/Tickets";
import { KnowledgePage } from "./pages/Knowledge";
+import { KnowledgeDocPage } from "./pages/KnowledgeDoc";
import { NotificationsPage } from "./pages/Notifications";
import { JoinPage } from "./pages/Join";
import { AccountPage } from "./pages/Account";
@@ -98,6 +99,8 @@ export function App() {
} />
} />
} />
+ {/* 阅读是整页:抽屉只用来编辑,长文在抽屉里读会一直偏在屏幕右侧 */}
+ } />
} />
} />
} />
diff --git a/web/src/lib/knowledge.ts b/web/src/lib/knowledge.ts
new file mode 100644
index 0000000..2481a84
--- /dev/null
+++ b/web/src/lib/knowledge.ts
@@ -0,0 +1,69 @@
+/**
+ * 知识库的共用常量与显示规则。
+ *
+ * 列表页(pages/Knowledge.tsx)和阅读页(pages/KnowledgeDoc.tsx)都要用同一套文案:
+ * 类型叫什么、状态叫什么、分类该是什么颜色。放在这里,只为了两处不许各说各的。
+ */
+import type { KnowledgeStatus } from "./types";
+
+export const DOC_TYPE_LABEL: Record = {
+ note: "笔记",
+ howto: "怎么做",
+ faq: "常见问题",
+ postmortem: "复盘",
+ decision: "决策",
+};
+
+export const STATUS_LABEL: Record = {
+ draft: "草稿",
+ in_review: "待审议",
+ published: "已发布",
+ archived: "已归档",
+};
+
+export const SOURCE_LABEL: Record = {
+ manual: "手记",
+ task: "来自任务",
+ ticket: "来自工单",
+};
+
+export const ACTION_LABEL: Record = {
+ submit_review: "提交审议",
+ withdraw: "撤回审议",
+ publish: "发布",
+ archive: "归档",
+ restore: "恢复成草稿",
+};
+
+/**
+ * 分类调色板。给的是"彼此能分开"的一组色,不是渐变——分类色的用途是让人
+ * 在卡片墙上一眼分辨归属,颜色之间越差得开越好用。调色板没有的色可以手填。
+ */
+export const CATEGORY_PALETTE = [
+ "#4F46E5",
+ "#0EA5E9",
+ "#10B981",
+ "#F59E0B",
+ "#EF4444",
+ "#EC4899",
+ "#8B5CF6",
+ "#14B8A6",
+ "#64748B",
+ "#84CC16",
+];
+
+export const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/;
+
+/**
+ * 分类实际用的颜色:设了就用设的,没设就按 id 稳定地挑一个调色板色。
+ *
+ * 为什么没设也要给色:老分类(加颜色列之前建的)全是空值,如果空值一律画灰边,
+ * 刚上的"分类色"看起来就像没生效。按 id 取模是确定性的:同一条分类刷新前后同色,
+ * 也不会因为新增一条分类而让别的分类换色。
+ */
+export function categoryColor(cat: { id: string; color: string | null }): string {
+ if (cat.color) return cat.color;
+ let hash = 0;
+ for (let i = 0; i < cat.id.length; i += 1) hash = (hash * 31 + cat.id.charCodeAt(i)) % 9973;
+ return CATEGORY_PALETTE[hash % CATEGORY_PALETTE.length];
+}
diff --git a/web/src/pages/Knowledge.tsx b/web/src/pages/Knowledge.tsx
index 57e2a32..ac9af81 100644
--- a/web/src/pages/Knowledge.tsx
+++ b/web/src/pages/Knowledge.tsx
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { useParams } from "react-router-dom";
+import { useNavigate, useParams } from "react-router-dom";
import { AppShell } from "../components/AppShell";
import { AttachmentPanel } from "../components/AttachmentPanel";
import { Markdown } from "../components/Markdown";
@@ -15,11 +15,19 @@ import {
StatusPill,
} from "../components/ui";
import { ApiError, api } from "../lib/client";
+import {
+ ACTION_LABEL,
+ CATEGORY_PALETTE,
+ DOC_TYPE_LABEL,
+ HEX_COLOR_RE,
+ SOURCE_LABEL,
+ STATUS_LABEL,
+ categoryColor,
+} from "../lib/knowledge";
import type {
Attachment,
Knowledge,
KnowledgeCategoryNode,
- KnowledgeStatus,
SearchHit,
} from "../lib/types";
@@ -31,68 +39,6 @@ const TABS = [
{ key: "archived", label: "已归档", statuses: ["archived"] },
] as const;
-const DOC_TYPE_LABEL: Record = {
- note: "笔记",
- howto: "怎么做",
- faq: "常见问题",
- postmortem: "复盘",
- decision: "决策",
-};
-
-const STATUS_LABEL: Record = {
- draft: "草稿",
- in_review: "待审议",
- published: "已发布",
- archived: "已归档",
-};
-
-const SOURCE_LABEL: Record = {
- manual: "手记",
- task: "来自任务",
- ticket: "来自工单",
-};
-
-/**
- * 分类调色板。给的是“彼此能分开”的一组色,不是渐变——分类色的用途是让人
- * 在卡片墙上一眼分辨归属,颜色之间越差得开越好用。调色板没有的色可以手填。
- */
-const CATEGORY_PALETTE = [
- "#4F46E5",
- "#0EA5E9",
- "#10B981",
- "#F59E0B",
- "#EF4444",
- "#EC4899",
- "#8B5CF6",
- "#14B8A6",
- "#64748B",
- "#84CC16",
-];
-
-const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/;
-
-/**
- * 分类实际用的颜色:设了就用设的,没设就按 id 稳定地挑一个调色板色。
- *
- * 为什么没设也要给色:老分类(加颜色列之前建的)全是空值,如果空值一律画灰边,
- * 刚上的“分类色”看起来就像没生效。按 id 取模是确定性的:同一条分类刷新前后同色,
- * 也不会因为新增一条分类而让别的分类换色。
- */
-function categoryColor(cat: { id: string; color: string | null }): string {
- if (cat.color) return cat.color;
- let hash = 0;
- for (let i = 0; i < cat.id.length; i += 1) hash = (hash * 31 + cat.id.charCodeAt(i)) % 9973;
- return CATEGORY_PALETTE[hash % CATEGORY_PALETTE.length];
-}
-
-const ACTION_LABEL: Record = {
- submit_review: "提交审议",
- withdraw: "撤回审议",
- publish: "发布",
- archive: "归档",
- restore: "恢复成草稿",
-};
-
/**
* 正文编辑区的高度跟着窗口走。
* 知识是长文,编辑器默认 240px 只露出几行,写一段就得滚一次;
@@ -115,13 +61,15 @@ function useEditorHeight(reserve: number, floor = 360, ceiling = 960) {
export function KnowledgePage() {
const { wsId = "" } = useParams();
+ // 阅读走独立页面:抽屉只用来编辑(短时间、单次动作),长文阅读要整屏宽度
+ const navigate = useNavigate();
+ const openDoc = (id: string) => navigate(`/w/${wsId}/knowledge/${id}`);
const [tab, setTab] = useState<(typeof TABS)[number]["key"]>("all");
const [q, setQ] = useState("");
const [docs, setDocs] = useState([]);
const [cursor, setCursor] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
- const [openId, setOpenId] = useState(null);
const [composing, setComposing] = useState(false);
const [hits, setHits] = useState(null);
const [searching, setSearching] = useState(false);
@@ -291,7 +239,7 @@ export function KnowledgePage() {
setComposing(false);
setTab("draft");
setCatFilter(null);
- setOpenId(doc.id);
+ openDoc(doc.id);
void load(null);
void loadCategories();
}
@@ -444,7 +392,7 @@ export function KnowledgePage() {
{hit.content}
-
@@ -488,7 +436,7 @@ export function KnowledgePage() {
}
className="card group flex items-start justify-between gap-3 p-4 transition hover:border-line-300 hover:shadow-pop"
>
- setOpenId(d.id)}>
+ openDoc(d.id)}>
{DOC_TYPE_LABEL[d.knowledge.doc_type] ?? d.knowledge.doc_type}
@@ -538,7 +486,7 @@ export function KnowledgePage() {
) : null}
- setOpenId(d.id)}>
+ openDoc(d.id)}>
打开
@@ -567,18 +515,6 @@ export function KnowledgePage() {
/>
)}
- {openId && (
- setOpenId(null)}
- onChanged={() => {
- void load(null);
- void loadCategories();
- }}
- />
- )}
);
}
@@ -1264,16 +1200,24 @@ function KnowledgeComposer({
}
/** 知识详情抽屉:能改、能提交审议、能发布/归档。 */
-function KnowledgeDrawer({
+/**
+ * 编辑抽屉。阅读不走这里——阅读有独立页面(pages/KnowledgeDoc.tsx)。
+ *
+ * `initialEditing` 为真时它就是"纯编辑器":进来直接是编辑态,保存或取消都关掉,
+ * 不在这里再看一遍正文(阅读页就在它背后,没必要叠一层相同的排版)。
+ */
+export function KnowledgeDrawer({
wsId,
itemId,
categories,
+ initialEditing = false,
onClose,
onChanged,
}: {
wsId: string;
itemId: string;
categories: KnowledgeCategoryNode[];
+ initialEditing?: boolean;
onClose: () => void;
onChanged: () => void;
}) {
@@ -1296,7 +1240,7 @@ function KnowledgeDrawer({
}, [attachments]);
const [busy, setBusy] = useState(false);
/** 打开是「读」还是「写」:读的时候渲染样式,只有点「编辑」才进编辑器。 */
- const [editing, setEditing] = useState(false);
+ const [editing, setEditing] = useState(initialEditing);
/** 已经载入完成的是哪一条知识。重复的 effect / 慢响应不该再覆盖用户正在敲的内容。 */
const loadedFor = useRef(null);
@@ -1311,13 +1255,13 @@ function KnowledgeDrawer({
setTags(data.tags.map((t) => t.name).join(" "));
setCategoryId(data.category?.id ?? "");
setAttachments(data.attachments ?? []);
- setEditing(false);
+ setEditing(initialEditing);
loadedFor.current = itemId;
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
}
},
- [wsId, itemId],
+ [wsId, itemId, initialEditing],
);
useEffect(() => {
@@ -1337,8 +1281,12 @@ function KnowledgeDrawer({
version: doc.version,
});
setDoc(next);
- setEditing(false);
onChanged();
+ if (initialEditing) {
+ onClose();
+ } else {
+ setEditing(false);
+ }
} catch (err) {
if (err instanceof ApiError && err.code === 4009) {
setError("刚被别人改过,已刷新为最新内容");
@@ -1410,6 +1358,10 @@ function KnowledgeDrawer({
variant="ghost"
disabled={busy}
onClick={() => {
+ if (initialEditing) {
+ onClose();
+ return;
+ }
setEditing(false);
void load(true);
}}
diff --git a/web/src/pages/KnowledgeDoc.tsx b/web/src/pages/KnowledgeDoc.tsx
new file mode 100644
index 0000000..004c1be
--- /dev/null
+++ b/web/src/pages/KnowledgeDoc.tsx
@@ -0,0 +1,271 @@
+/**
+ * 知识阅读页(`/w/:wsId/knowledge/:itemId`)。
+ *
+ * 为什么阅读要独立成页而不是抽屉:抽屉贴在右边、最大也就 1150px 的六成宽,
+ * 长文在这个位置读十几屏,视线一直偏在屏幕右侧,很累。阅读本来就该是
+ * 一整页、正文落在视线中间的一列(这里限宽 40rem,约 40 个汉字一行)。
+ * 抽屉留给"改一下"——那是短时间的动作,也不常对同一篇反复做。
+ */
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { Link, useNavigate, useParams } from "react-router-dom";
+import { AppShell } from "../components/AppShell";
+import { AttachmentPanel } from "../components/AttachmentPanel";
+import { Markdown } from "../components/Markdown";
+import { ChevronLeft, Link2, Pencil } from "../components/icons";
+import { Button, InlineAlert, SkeletonRows, StatusPill } from "../components/ui";
+import { ApiError, api } from "../lib/client";
+import { ACTION_LABEL, DOC_TYPE_LABEL, STATUS_LABEL, categoryColor } from "../lib/knowledge";
+import type { Attachment, Knowledge, KnowledgeCategoryNode } from "../lib/types";
+import { KnowledgeDrawer } from "./Knowledge";
+
+export function KnowledgeDocPage() {
+ const { wsId = "", itemId = "" } = useParams();
+ const navigate = useNavigate();
+ const [doc, setDoc] = useState(null);
+ const [categories, setCategories] = useState([]);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [editing, setEditing] = useState(false);
+ const [busy, setBusy] = useState(false);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const data = await api.get(`/workspaces/${wsId}/knowledge/${itemId}`);
+ setDoc(data);
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "知识加载失败");
+ } finally {
+ setLoading(false);
+ }
+ }, [wsId, itemId]);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ // 换一篇就回到顶部:否则从长文点进短文,会停在上一页的滚动位置
+ useEffect(() => {
+ window.scrollTo({ top: 0 });
+ }, [itemId]);
+
+ useEffect(() => {
+ void (async () => {
+ try {
+ const page = await api.get<{ items: KnowledgeCategoryNode[] }>(
+ `/workspaces/${wsId}/knowledge/categories`,
+ );
+ setCategories(page.items);
+ } catch {
+ // 分类只是编辑时的下拉选项,取不到不影响阅读
+ }
+ })();
+ }, [wsId]);
+
+ /** 附件直链:正文里的图片按 attachment id 换成带签名的 raw_url */
+ const resources = useMemo(() => {
+ const map: Record = {};
+ for (const row of (doc?.attachments ?? []) as Attachment[]) {
+ if (row.raw_url) map[row.id] = row.raw_url;
+ }
+ return map;
+ }, [doc]);
+
+ async function act(action: string) {
+ if (!doc) return;
+ setBusy(true);
+ setError(null);
+ try {
+ const next = await api.post(`/workspaces/${wsId}/knowledge/${itemId}/status`, {
+ action,
+ version: doc.version,
+ });
+ setDoc(next);
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "操作失败");
+ await load();
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ const status = doc?.knowledge.status_hint;
+ const canEdit = status === "draft" || status === "in_review" || status === "published";
+ const actions: { key: string; label: string; show: boolean }[] = [
+ { key: "submit_review", label: ACTION_LABEL.submit_review, show: status === "draft" },
+ { key: "withdraw", label: ACTION_LABEL.withdraw, show: status === "in_review" },
+ { key: "publish", label: ACTION_LABEL.publish, show: status === "draft" || status === "in_review" },
+ {
+ key: "archive",
+ label: ACTION_LABEL.archive,
+ show: status === "draft" || status === "in_review" || status === "published",
+ },
+ { key: "restore", label: ACTION_LABEL.restore, show: status === "archived" },
+ ];
+
+ return (
+
+
+
+
+
+ 返回知识库
+
+ /
+
+ {doc?.category?.full_path ?? "未分类"}
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {loading && !doc ? (
+
+
+
+ ) : !doc ? (
+
+
这条知识不在了,或者你没有权限看它。
+
+ navigate(`/w/${wsId}/knowledge`)}>
+ 回到知识库
+
+
+
+ ) : (
+
+
+
+
+ {DOC_TYPE_LABEL[doc.knowledge.doc_type] ?? doc.knowledge.doc_type}
+
+
+ {doc.category ? (
+
+
+ {doc.category.full_path}
+
+ ) : null}
+
+
+
+ {doc.title}
+
+
+
+ 更新于 {new Date(doc.updated_at).toLocaleString("zh-CN")}
+ {doc.knowledge.published_at ? (
+ 发布于 {new Date(doc.knowledge.published_at).toLocaleString("zh-CN")}
+ ) : (
+ 尚未发布,只有工作区里的人能看到
+ )}
+ {doc.knowledge.chunk_count > 0 ? (
+ 检索切片 {doc.knowledge.chunk_count} 段
+ ) : null}
+
+
+ {doc.tags.length > 0 ? (
+
+ {doc.tags.map((t) => (
+
+ #{t.name}
+
+ ))}
+
+ ) : null}
+
+
+ {canEdit ? (
+
setEditing(true)}>
+
+ 编辑
+
+ ) : null}
+ {actions
+ .filter((a) => a.show)
+ .map((a) => (
+
void act(a.key)}
+ >
+ {a.label}
+
+ ))}
+
+
+
+
+
+
+ {doc.source ? (
+
+
+
+
+ 来源:{doc.source.kind === "ticket" ? "工单" : "任务"}「
+
+ {doc.source.title}
+
+ 」
+
+
+ {doc.knowledge.generated_by === "rule" ? (
+
这份草稿由系统整理,请核对后再发布。
+ ) : null}
+
+ ) : null}
+
+ {(doc.attachments?.length ?? 0) > 0 ? (
+
+
void load()}
+ onError={(message) => setError(message)}
+ disabled
+ />
+
+ ) : null}
+
+
+ )}
+
+
+ {editing && doc ? (
+ setEditing(false)}
+ onChanged={() => void load()}
+ />
+ ) : null}
+
+ );
+}