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" > - - @@ -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 ? ( +
+

这条知识不在了,或者你没有权限看它。

+
+ +
+
+ ) : ( +
+
+
+ + {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 ? ( + + ) : null} + {actions + .filter((a) => a.show) + .map((a) => ( + + ))} +
+ +
+ +
+ + {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} +
+ ); +}