知识原来一律在抽屉里打开,正文贴着屏幕右半边,长文读十几屏眼睛一直在右边。 阅读是「坐下来读一会儿」,编辑是「改两句就走」,两件事不该共用一个容器。 - 新增阅读页 /w/:wsId/knowledge/:itemId:正文列限到 40rem 居中,28px 标题, 更新/发布/切片行,标签与来源,动作行放编辑与发布/归档/恢复 - 列表页与搜索结果改成跳阅读页,不再弹抽屉 - KnowledgeDrawer 加 initialEditing:进来就是编辑器,保存或取消直接关 - 文案常量搬到 lib/knowledge.ts,列表页与阅读页共用一份 顺带修三个 e2e 脚本:新建后进阅读页没有「关闭」按钮可点、侧栏色点按钮让树 顺序断言多出空串、翻页脚本还在用已关闭的短信登录(改密码登录)。
This commit is contained in:
@@ -660,3 +660,46 @@
|
|||||||
- 生产 env **不带** `EMAIL_CODE_DEV_ECHO`:码只发到邮箱,页面不回显。
|
- 生产 env **不带** `EMAIL_CODE_DEV_ECHO`:码只发到邮箱,页面不回显。
|
||||||
- 踩到的坑:dev 库要先 `alembic upgrade head` 到 0011,否则注册接口报
|
- 踩到的坑:dev 库要先 `alembic upgrade head` 到 0011,否则注册接口报
|
||||||
`column "email_verified_at" of relation "app_user" does not exist` 直接 500——迁移写好不等于跑过。
|
`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/<ws>/knowledge/<id>")`;删掉 `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/<id>` 再点「返回知识库」。
|
||||||
|
- 侧栏分类行里的色点是按钮但没文字,`allInnerTexts()` 会多出一个空串,比较树顺序前先 `.filter(Boolean)`。
|
||||||
|
- `knowledge-paging.mjs` 原来靠短信发码登录,生产短信已关;改成优先用 `PASSWORD`
|
||||||
|
(或 `.local/e2e-account-password.txt`)走密码登录,没密码时才回落到短信。
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
@@ -79,7 +79,10 @@ try {
|
|||||||
|
|
||||||
// 树要按「父在前、子紧跟」排:部署必须紧挨在研发后面,而不是堆在别的根分类后面
|
// 树要按「父在前、子紧跟」排:部署必须紧挨在研发后面,而不是堆在别的根分类后面
|
||||||
const navTexts = await page.locator("aside nav button").allInnerTexts();
|
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(
|
check(
|
||||||
"侧栏分类按树的顺序排(子紧跟在父后面)",
|
"侧栏分类按树的顺序排(子紧跟在父后面)",
|
||||||
navNames.indexOf("部署") === navNames.indexOf("研发") + 1,
|
navNames.indexOf("部署") === navNames.indexOf("研发") + 1,
|
||||||
@@ -142,8 +145,12 @@ try {
|
|||||||
check("详情抽屉里能看到刚传的附件", drawerText.includes("附件样例.txt"));
|
check("详情抽屉里能看到刚传的附件", drawerText.includes("附件样例.txt"));
|
||||||
await page.screenshot({ path: "e2e/artifacts/knowledge-composer-saved.png", fullPage: false });
|
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();
|
const card = page.locator("li").filter({ hasText: "回滚前先停灰度" }).first();
|
||||||
await card.waitFor({ timeout: 10_000 });
|
await card.waitFor({ timeout: 10_000 });
|
||||||
const cardText = (await card.textContent()) ?? "";
|
const cardText = (await card.textContent()) ?? "";
|
||||||
|
|||||||
@@ -8,12 +8,12 @@
|
|||||||
* node web/e2e/knowledge-paging.mjs # 默认打生产
|
* node web/e2e/knowledge-paging.mjs # 默认打生产
|
||||||
* WEB=http://127.0.0.1:5180 PHONE=13900000001 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";
|
import { chromium } from "playwright-core";
|
||||||
|
|
||||||
const WEB = process.env.WEB ?? "http://192.168.8.188:8200";
|
const WEB = process.env.WEB ?? "http://192.168.8.188:8200";
|
||||||
const API = process.env.API ?? `${WEB}/api/v1`;
|
const API = process.env.API ?? `${WEB}/api/v1`;
|
||||||
const PHONE = process.env.PHONE ?? "13605513683";
|
const PHONE = process.env.PHONE ?? "13605513683";
|
||||||
const CODE = process.env.CODE ?? "123456";
|
|
||||||
const CODE_PIN = process.env.CODE_PIN ?? "123456"; // 生产 pin 就是验证码本身
|
const CODE_PIN = process.env.CODE_PIN ?? "123456"; // 生产 pin 就是验证码本身
|
||||||
const CHROME =
|
const CHROME =
|
||||||
process.env.CHROME ??
|
process.env.CHROME ??
|
||||||
@@ -31,14 +31,29 @@ function check(name, ok, extra = "") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录:默认用密码(生产的短信登录已经关了),密码从 PASSWORD 拿,
|
||||||
|
* 或者从 .local/e2e-account-password.txt 读(dev 13900000001 的密码存在那儿)。
|
||||||
|
* 真没密码时才回落到短信验证码——那种环境下短信登录得开着。
|
||||||
|
*/
|
||||||
async function login() {
|
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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ phone: PHONE }),
|
body: JSON.stringify({ phone: PHONE }),
|
||||||
});
|
}).then((r) => r.json());
|
||||||
const sent = await r.json();
|
if (sent.code !== 0) throw new Error(`发码失败:${sent.message}(配个 PASSWORD 或写下密码文件就能走密码登录)`);
|
||||||
if (sent.code !== 0) throw new Error(`发码失败:${sent.message}`);
|
|
||||||
const login = await fetch(`${API}/auth/login`, {
|
const login = await fetch(`${API}/auth/login`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -49,6 +64,17 @@ async function login() {
|
|||||||
return { tokens: body.data.tokens, workspaces: await workspaces(body.data.tokens) };
|
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) {
|
async function workspaces(tokens) {
|
||||||
const r = await fetch(`${API}/workspaces`, {
|
const r = await fetch(`${API}/workspaces`, {
|
||||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||||
@@ -58,7 +84,6 @@ async function workspaces(tokens) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
void CODE;
|
|
||||||
const { tokens, workspaces: spaces } = await login();
|
const { tokens, workspaces: spaces } = await login();
|
||||||
if (!spaces.length) throw new Error("这个账号没有工作区");
|
if (!spaces.length) throw new Error("这个账号没有工作区");
|
||||||
const ws = spaces[0];
|
const ws = spaces[0];
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -12,6 +12,7 @@ import { TasksPage } from "./pages/Tasks";
|
|||||||
import { BoardPage } from "./pages/Board";
|
import { BoardPage } from "./pages/Board";
|
||||||
import { TicketsPage } from "./pages/Tickets";
|
import { TicketsPage } from "./pages/Tickets";
|
||||||
import { KnowledgePage } from "./pages/Knowledge";
|
import { KnowledgePage } from "./pages/Knowledge";
|
||||||
|
import { KnowledgeDocPage } from "./pages/KnowledgeDoc";
|
||||||
import { NotificationsPage } from "./pages/Notifications";
|
import { NotificationsPage } from "./pages/Notifications";
|
||||||
import { JoinPage } from "./pages/Join";
|
import { JoinPage } from "./pages/Join";
|
||||||
import { AccountPage } from "./pages/Account";
|
import { AccountPage } from "./pages/Account";
|
||||||
@@ -98,6 +99,8 @@ export function App() {
|
|||||||
<Route path="/w/:wsId/board" element={<BoardPage />} />
|
<Route path="/w/:wsId/board" element={<BoardPage />} />
|
||||||
<Route path="/w/:wsId/tickets" element={<TicketsPage />} />
|
<Route path="/w/:wsId/tickets" element={<TicketsPage />} />
|
||||||
<Route path="/w/:wsId/knowledge" element={<KnowledgePage />} />
|
<Route path="/w/:wsId/knowledge" element={<KnowledgePage />} />
|
||||||
|
{/* 阅读是整页:抽屉只用来编辑,长文在抽屉里读会一直偏在屏幕右侧 */}
|
||||||
|
<Route path="/w/:wsId/knowledge/:itemId" element={<KnowledgeDocPage />} />
|
||||||
<Route path="/w/:wsId/notifications" element={<NotificationsPage />} />
|
<Route path="/w/:wsId/notifications" element={<NotificationsPage />} />
|
||||||
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
|
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
|
||||||
<Route path="/join/:wsId/:code" element={<JoinPage />} />
|
<Route path="/join/:wsId/:code" element={<JoinPage />} />
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* 知识库的共用常量与显示规则。
|
||||||
|
*
|
||||||
|
* 列表页(pages/Knowledge.tsx)和阅读页(pages/KnowledgeDoc.tsx)都要用同一套文案:
|
||||||
|
* 类型叫什么、状态叫什么、分类该是什么颜色。放在这里,只为了两处不许各说各的。
|
||||||
|
*/
|
||||||
|
import type { KnowledgeStatus } from "./types";
|
||||||
|
|
||||||
|
export const DOC_TYPE_LABEL: Record<string, string> = {
|
||||||
|
note: "笔记",
|
||||||
|
howto: "怎么做",
|
||||||
|
faq: "常见问题",
|
||||||
|
postmortem: "复盘",
|
||||||
|
decision: "决策",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_LABEL: Record<KnowledgeStatus, string> = {
|
||||||
|
draft: "草稿",
|
||||||
|
in_review: "待审议",
|
||||||
|
published: "已发布",
|
||||||
|
archived: "已归档",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SOURCE_LABEL: Record<string, string> = {
|
||||||
|
manual: "手记",
|
||||||
|
task: "来自任务",
|
||||||
|
ticket: "来自工单",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ACTION_LABEL: Record<string, string> = {
|
||||||
|
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];
|
||||||
|
}
|
||||||
+38
-86
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
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 { AppShell } from "../components/AppShell";
|
||||||
import { AttachmentPanel } from "../components/AttachmentPanel";
|
import { AttachmentPanel } from "../components/AttachmentPanel";
|
||||||
import { Markdown } from "../components/Markdown";
|
import { Markdown } from "../components/Markdown";
|
||||||
@@ -15,11 +15,19 @@ import {
|
|||||||
StatusPill,
|
StatusPill,
|
||||||
} from "../components/ui";
|
} from "../components/ui";
|
||||||
import { ApiError, api } from "../lib/client";
|
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 {
|
import type {
|
||||||
Attachment,
|
Attachment,
|
||||||
Knowledge,
|
Knowledge,
|
||||||
KnowledgeCategoryNode,
|
KnowledgeCategoryNode,
|
||||||
KnowledgeStatus,
|
|
||||||
SearchHit,
|
SearchHit,
|
||||||
} from "../lib/types";
|
} from "../lib/types";
|
||||||
|
|
||||||
@@ -31,68 +39,6 @@ const TABS = [
|
|||||||
{ key: "archived", label: "已归档", statuses: ["archived"] },
|
{ key: "archived", label: "已归档", statuses: ["archived"] },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const DOC_TYPE_LABEL: Record<string, string> = {
|
|
||||||
note: "笔记",
|
|
||||||
howto: "怎么做",
|
|
||||||
faq: "常见问题",
|
|
||||||
postmortem: "复盘",
|
|
||||||
decision: "决策",
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_LABEL: Record<KnowledgeStatus, string> = {
|
|
||||||
draft: "草稿",
|
|
||||||
in_review: "待审议",
|
|
||||||
published: "已发布",
|
|
||||||
archived: "已归档",
|
|
||||||
};
|
|
||||||
|
|
||||||
const SOURCE_LABEL: Record<string, string> = {
|
|
||||||
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<string, string> = {
|
|
||||||
submit_review: "提交审议",
|
|
||||||
withdraw: "撤回审议",
|
|
||||||
publish: "发布",
|
|
||||||
archive: "归档",
|
|
||||||
restore: "恢复成草稿",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 正文编辑区的高度跟着窗口走。
|
* 正文编辑区的高度跟着窗口走。
|
||||||
* 知识是长文,编辑器默认 240px 只露出几行,写一段就得滚一次;
|
* 知识是长文,编辑器默认 240px 只露出几行,写一段就得滚一次;
|
||||||
@@ -115,13 +61,15 @@ function useEditorHeight(reserve: number, floor = 360, ceiling = 960) {
|
|||||||
|
|
||||||
export function KnowledgePage() {
|
export function KnowledgePage() {
|
||||||
const { wsId = "" } = useParams();
|
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 [tab, setTab] = useState<(typeof TABS)[number]["key"]>("all");
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [docs, setDocs] = useState<Knowledge[]>([]);
|
const [docs, setDocs] = useState<Knowledge[]>([]);
|
||||||
const [cursor, setCursor] = useState<string | null>(null);
|
const [cursor, setCursor] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [openId, setOpenId] = useState<string | null>(null);
|
|
||||||
const [composing, setComposing] = useState(false);
|
const [composing, setComposing] = useState(false);
|
||||||
const [hits, setHits] = useState<SearchHit[] | null>(null);
|
const [hits, setHits] = useState<SearchHit[] | null>(null);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
@@ -291,7 +239,7 @@ export function KnowledgePage() {
|
|||||||
setComposing(false);
|
setComposing(false);
|
||||||
setTab("draft");
|
setTab("draft");
|
||||||
setCatFilter(null);
|
setCatFilter(null);
|
||||||
setOpenId(doc.id);
|
openDoc(doc.id);
|
||||||
void load(null);
|
void load(null);
|
||||||
void loadCategories();
|
void loadCategories();
|
||||||
}
|
}
|
||||||
@@ -444,7 +392,7 @@ export function KnowledgePage() {
|
|||||||
{hit.content}
|
{hit.content}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 text-right">
|
<div className="mt-2 text-right">
|
||||||
<Button variant="ghost" size="sm" onClick={() => setOpenId(hit.item_id)}>
|
<Button variant="ghost" size="sm" onClick={() => openDoc(hit.item_id)}>
|
||||||
打开原文
|
打开原文
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -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"
|
className="card group flex items-start justify-between gap-3 p-4 transition hover:border-line-300 hover:shadow-pop"
|
||||||
>
|
>
|
||||||
<button className="min-w-0 flex-1 text-left" onClick={() => setOpenId(d.id)}>
|
<button className="min-w-0 flex-1 text-left" onClick={() => openDoc(d.id)}>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="chip bg-primary-50 text-primary-700">
|
<span className="chip bg-primary-50 text-primary-700">
|
||||||
{DOC_TYPE_LABEL[d.knowledge.doc_type] ?? d.knowledge.doc_type}
|
{DOC_TYPE_LABEL[d.knowledge.doc_type] ?? d.knowledge.doc_type}
|
||||||
@@ -538,7 +486,7 @@ export function KnowledgePage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
<Button variant="ghost" size="sm" className="shrink-0" onClick={() => setOpenId(d.id)}>
|
<Button variant="ghost" size="sm" className="shrink-0" onClick={() => openDoc(d.id)}>
|
||||||
打开
|
打开
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
@@ -567,18 +515,6 @@ export function KnowledgePage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{openId && (
|
|
||||||
<KnowledgeDrawer
|
|
||||||
wsId={wsId}
|
|
||||||
itemId={openId}
|
|
||||||
categories={categories}
|
|
||||||
onClose={() => setOpenId(null)}
|
|
||||||
onChanged={() => {
|
|
||||||
void load(null);
|
|
||||||
void loadCategories();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1264,16 +1200,24 @@ function KnowledgeComposer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 知识详情抽屉:能改、能提交审议、能发布/归档。 */
|
/** 知识详情抽屉:能改、能提交审议、能发布/归档。 */
|
||||||
function KnowledgeDrawer({
|
/**
|
||||||
|
* 编辑抽屉。阅读不走这里——阅读有独立页面(pages/KnowledgeDoc.tsx)。
|
||||||
|
*
|
||||||
|
* `initialEditing` 为真时它就是"纯编辑器":进来直接是编辑态,保存或取消都关掉,
|
||||||
|
* 不在这里再看一遍正文(阅读页就在它背后,没必要叠一层相同的排版)。
|
||||||
|
*/
|
||||||
|
export function KnowledgeDrawer({
|
||||||
wsId,
|
wsId,
|
||||||
itemId,
|
itemId,
|
||||||
categories,
|
categories,
|
||||||
|
initialEditing = false,
|
||||||
onClose,
|
onClose,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
wsId: string;
|
wsId: string;
|
||||||
itemId: string;
|
itemId: string;
|
||||||
categories: KnowledgeCategoryNode[];
|
categories: KnowledgeCategoryNode[];
|
||||||
|
initialEditing?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -1296,7 +1240,7 @@ function KnowledgeDrawer({
|
|||||||
}, [attachments]);
|
}, [attachments]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
/** 打开是「读」还是「写」:读的时候渲染样式,只有点「编辑」才进编辑器。 */
|
/** 打开是「读」还是「写」:读的时候渲染样式,只有点「编辑」才进编辑器。 */
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(initialEditing);
|
||||||
/** 已经载入完成的是哪一条知识。重复的 effect / 慢响应不该再覆盖用户正在敲的内容。 */
|
/** 已经载入完成的是哪一条知识。重复的 effect / 慢响应不该再覆盖用户正在敲的内容。 */
|
||||||
const loadedFor = useRef<string | null>(null);
|
const loadedFor = useRef<string | null>(null);
|
||||||
|
|
||||||
@@ -1311,13 +1255,13 @@ function KnowledgeDrawer({
|
|||||||
setTags(data.tags.map((t) => t.name).join(" "));
|
setTags(data.tags.map((t) => t.name).join(" "));
|
||||||
setCategoryId(data.category?.id ?? "");
|
setCategoryId(data.category?.id ?? "");
|
||||||
setAttachments(data.attachments ?? []);
|
setAttachments(data.attachments ?? []);
|
||||||
setEditing(false);
|
setEditing(initialEditing);
|
||||||
loadedFor.current = itemId;
|
loadedFor.current = itemId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : "加载失败");
|
setError(err instanceof ApiError ? err.message : "加载失败");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[wsId, itemId],
|
[wsId, itemId, initialEditing],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1337,8 +1281,12 @@ function KnowledgeDrawer({
|
|||||||
version: doc.version,
|
version: doc.version,
|
||||||
});
|
});
|
||||||
setDoc(next);
|
setDoc(next);
|
||||||
setEditing(false);
|
|
||||||
onChanged();
|
onChanged();
|
||||||
|
if (initialEditing) {
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError && err.code === 4009) {
|
if (err instanceof ApiError && err.code === 4009) {
|
||||||
setError("刚被别人改过,已刷新为最新内容");
|
setError("刚被别人改过,已刷新为最新内容");
|
||||||
@@ -1410,6 +1358,10 @@ function KnowledgeDrawer({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (initialEditing) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
void load(true);
|
void load(true);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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<Knowledge | null>(null);
|
||||||
|
const [categories, setCategories] = useState<KnowledgeCategoryNode[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(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<Knowledge>(`/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<string, string> = {};
|
||||||
|
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<Knowledge>(`/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 (
|
||||||
|
<AppShell wsId={wsId} active="knowledge">
|
||||||
|
<div className="mx-auto w-full max-w-[46rem]">
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
|
<Link
|
||||||
|
to={`/w/${wsId}/knowledge`}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-ink-500 transition hover:text-ink-800"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={13} />
|
||||||
|
返回知识库
|
||||||
|
</Link>
|
||||||
|
<span className="text-2xs text-ink-300">/</span>
|
||||||
|
<span className="max-w-[18rem] truncate text-2xs text-ink-400">
|
||||||
|
{doc?.category?.full_path ?? "未分类"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<InlineAlert>{error}</InlineAlert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && !doc ? (
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<SkeletonRows rows={4} />
|
||||||
|
</div>
|
||||||
|
) : !doc ? (
|
||||||
|
<div className="card p-6 text-center">
|
||||||
|
<p className="text-sm text-ink-600">这条知识不在了,或者你没有权限看它。</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button variant="ghost" onClick={() => navigate(`/w/${wsId}/knowledge`)}>
|
||||||
|
回到知识库
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<article className="card px-5 py-6 sm:px-8 sm:py-8">
|
||||||
|
<div className="mx-auto w-full max-w-[40rem]">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="chip bg-primary-50 text-primary-700">
|
||||||
|
{DOC_TYPE_LABEL[doc.knowledge.doc_type] ?? doc.knowledge.doc_type}
|
||||||
|
</span>
|
||||||
|
<StatusPill
|
||||||
|
status={doc.knowledge.status_hint}
|
||||||
|
label={STATUS_LABEL[doc.knowledge.status_hint]}
|
||||||
|
/>
|
||||||
|
{doc.category ? (
|
||||||
|
<span className="chip max-w-[18rem] truncate bg-paper-200 text-ink-600">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="mr-1 inline-block h-1.5 w-1.5 shrink-0 rounded-full align-middle"
|
||||||
|
style={{ background: categoryColor(doc.category) }}
|
||||||
|
/>
|
||||||
|
{doc.category.full_path}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="mt-3 text-2xl font-semibold leading-snug text-ink-900 sm:text-[28px]">
|
||||||
|
{doc.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-2xs text-ink-400">
|
||||||
|
<span>更新于 {new Date(doc.updated_at).toLocaleString("zh-CN")}</span>
|
||||||
|
{doc.knowledge.published_at ? (
|
||||||
|
<span>发布于 {new Date(doc.knowledge.published_at).toLocaleString("zh-CN")}</span>
|
||||||
|
) : (
|
||||||
|
<span>尚未发布,只有工作区里的人能看到</span>
|
||||||
|
)}
|
||||||
|
{doc.knowledge.chunk_count > 0 ? (
|
||||||
|
<span>检索切片 {doc.knowledge.chunk_count} 段</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{doc.tags.length > 0 ? (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1">
|
||||||
|
{doc.tags.map((t) => (
|
||||||
|
<span key={t.id} className="chip bg-primary-50 text-primary-700">
|
||||||
|
#{t.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-2 border-b border-line-200 pb-4">
|
||||||
|
{canEdit ? (
|
||||||
|
<Button variant="primary" onClick={() => setEditing(true)}>
|
||||||
|
<Pencil size={13} />
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{actions
|
||||||
|
.filter((a) => a.show)
|
||||||
|
.map((a) => (
|
||||||
|
<Button
|
||||||
|
key={a.key}
|
||||||
|
variant={a.key === "publish" ? "clay" : "ghost"}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void act(a.key)}
|
||||||
|
>
|
||||||
|
{a.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<Markdown source={doc.body_md} resources={resources} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{doc.source ? (
|
||||||
|
<div className="mt-8 rounded-xl border border-line-200 bg-paper-50 p-3.5 text-xs leading-relaxed text-ink-600">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Link2 size={12} className="text-ink-400" />
|
||||||
|
<span>
|
||||||
|
来源:{doc.source.kind === "ticket" ? "工单" : "任务"}「
|
||||||
|
<Link
|
||||||
|
to={
|
||||||
|
doc.source.kind === "ticket"
|
||||||
|
? `/w/${wsId}/tickets?open=${doc.source.id}`
|
||||||
|
: `/w/${wsId}?open=${doc.source.id}`
|
||||||
|
}
|
||||||
|
className="link"
|
||||||
|
>
|
||||||
|
{doc.source.title}
|
||||||
|
</Link>
|
||||||
|
」
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{doc.knowledge.generated_by === "rule" ? (
|
||||||
|
<p className="mt-1 text-ink-400">这份草稿由系统整理,请核对后再发布。</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{(doc.attachments?.length ?? 0) > 0 ? (
|
||||||
|
<div className="mt-4 rounded-xl border border-line-200 bg-paper-50 px-3 py-2.5">
|
||||||
|
<AttachmentPanel
|
||||||
|
wsId={wsId}
|
||||||
|
itemId={doc.id}
|
||||||
|
attachments={(doc.attachments ?? []) as Attachment[]}
|
||||||
|
onChange={() => void load()}
|
||||||
|
onError={(message) => setError(message)}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && doc ? (
|
||||||
|
<KnowledgeDrawer
|
||||||
|
wsId={wsId}
|
||||||
|
itemId={doc.id}
|
||||||
|
categories={categories}
|
||||||
|
initialEditing
|
||||||
|
onClose={() => setEditing(false)}
|
||||||
|
onChanged={() => void load()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user