知识以前只能"填个标题就完成",正文、标签、归类都要点进去再补。现在新建 就是一张完整的备忘录:标题、类型、分类、标签、正文一次写完,存完直接打开 详情继续编辑;知识页左侧多了工作区三级分类树,可按分类子树 / 未分类 / 标签 筛选。凡是填信息的抽屉都加宽了,还能一键展开整屏。 后端 - 新增 `knowledge_category`(迁移 0007):`workspace_id NOT NULL` + 自引用 `parent_id`、`depth` 1..3 检查约束、同层重名唯一索引(lower(name))、 RLS 策略与 `dadao_app` 授权;`knowledge_doc.category_id` 外键 ON DELETE SET NULL。 - 新增 `/knowledge/categories` 增删改查:超三级 `7007`、同层重名 `7008`、 跨租户或不存在 `7006`;`move=true` 才改父级,成环与移动后超过三级的子树 一律拒绝,删除有知识的分类要 `force=true`(知识变未分类)。 - 知识列表支持 `category_id`(含子树)、`uncategorized`、`tag`;输出带 `category`(含 `path` / `full_path`)。 - PATCH 只改分类或标签也会推进 `version`,避免"只动标签"之后下次保存撞 乐观锁 `4009`。 Web - `Sheet` 增 `size="md/lg/xl"` 与展开整屏按钮;任务、工单、知识抽屉统一 加宽(`TaskDetail` / `Tickets` / `Knowledge`)。 - 知识页:左侧三级分类树(新建、按子树筛选、未分类)、`KnowledgeComposer` 一次写全新的备忘录、列表卡片露出分类路径与标签、详情抽屉里可直接改分类 与标签。 - 抽屉详情只拉一次:重复的 effect 或迟到的响应不再覆盖用户正在敲的内容。 安卓 - `Api.kt`:分类模型与接口、新建/更新带 `tag_names`/`category_id`、 列表带分类与标签筛选。 - 知识页重做:新建改为近全屏大对话框(标题/类型/分类/标签/正文),列表 顶部按分类筛选,详情里能改分类与标签后单独保存。 文档与测试 - `2.Design/API.md`:5.1/5.3/5.4 补分类与筛选参数,新增 5.5 分类接口章节, 错误码补 `7006`/`7007`/`7008`。 - `2.Design/ROADMAP-M0-M1.md`:新增 3.11(三级分类)、3.12(备忘录形态)。 - 新增 `backend/tests/test_knowledge_categories.py`(8 例):备忘形态落库、 三级到底、移动越界与成环、重名、按子树/未分类/标签/关键词筛选、删分类需 `force`、跨租户隔离。 - 新增 `web/e2e/knowledge-notebook.mjs`(10 项,全绿):建两级分类 → 一次 写全 → 卡片与筛选 → 抽屉改标签/分类 → 正文落库 → 重名 7008。 验证:`pytest` 93 passed、`ruff check` / `ruff format --check` 全过、 `tsc --noEmit` 通过、`vite build` 通过、`knowledge-notebook.mjs` 10 项全绿。 安卓端本机只有 JDK 25,Gradle 8.7 / AGP 8.7.3 不支持,本次未做编译验证。
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 知识备忘录形态 e2e:分类树 + 新建时一次填全(标题/类型/分类/标签/正文)+ 按分类筛。
|
||||
*
|
||||
* 与 knowledge.mjs 的分工:那个脚本验"沉淀 → 发布"闭环,这个只验"知识是最像备忘录的形态"。
|
||||
* 前面造数据走接口,界面部分点真实按钮。
|
||||
*/
|
||||
import { chromium } from "playwright-core";
|
||||
|
||||
const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
|
||||
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
|
||||
const stamp = String(Date.now()).slice(-7);
|
||||
const phone = `137${stamp}0`;
|
||||
|
||||
let failures = 0;
|
||||
function check(name, ok, extra = "") {
|
||||
console.log(`${ok ? "PASS" : "FAIL"} ${name}${extra ? ` — ${extra}` : ""}`);
|
||||
if (!ok) failures += 1;
|
||||
}
|
||||
|
||||
async function call(method, path, body, token) {
|
||||
const res = await fetch(`${API}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
...(method === "GET" ? {} : { "idempotency-key": `e2e-${Math.random()}` }),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
const json = text ? JSON.parse(text) : {};
|
||||
if (json.code !== 0) throw new Error(`${method} ${path} -> ${json.code} ${json.message}`);
|
||||
return json.data;
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({
|
||||
executablePath:
|
||||
process.env.CHROME ??
|
||||
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`,
|
||||
});
|
||||
const consoleErrors = [];
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error") consoleErrors.push(m.text());
|
||||
});
|
||||
page.on("response", (r) => {
|
||||
if (r.status() >= 400) consoleErrors.push(`${r.status()} ${r.url()}`);
|
||||
});
|
||||
|
||||
try {
|
||||
await call("POST", "/auth/sms/send", { phone });
|
||||
const login = await call("POST", "/auth/login", { phone, code: "123456" });
|
||||
const token = login.tokens.access_token;
|
||||
const ws = await call("POST", "/workspaces", { name: `知识备忘 ${stamp}` }, token);
|
||||
|
||||
await page.addInitScript(
|
||||
([access, refresh]) => {
|
||||
localStorage.setItem("dadao.access", access);
|
||||
localStorage.setItem("dadao.refresh", refresh);
|
||||
},
|
||||
[token, login.tokens.refresh_token],
|
||||
);
|
||||
await page.goto(`${WEB}/w/${ws.id}/knowledge`, { waitUntil: "networkidle" });
|
||||
await page.getByRole("heading", { name: "知识" }).waitFor({ timeout: 10_000 });
|
||||
|
||||
// ① 建两级分类:研发 / 部署
|
||||
await page.getByRole("button", { name: "+ 新建", exact: true }).click();
|
||||
await page.getByLabel("新分类名").fill("研发");
|
||||
await page.getByRole("button", { name: "创建", exact: true }).click();
|
||||
await page.getByRole("button", { name: /^研发/, exact: false }).first().waitFor();
|
||||
await page.getByRole("button", { name: "+ 新建", exact: true }).click();
|
||||
await page.getByLabel("新分类名").fill("部署");
|
||||
await page.getByLabel("父分类").selectOption({ label: "研发 下" });
|
||||
await page.getByRole("button", { name: "创建", exact: true }).click();
|
||||
await page.getByRole("button", { name: /^部署/, exact: false }).first().waitFor();
|
||||
check("能在侧栏建出「研发 / 部署」两级分类", true);
|
||||
|
||||
// ② 记一条:一个面板里填完标题/类型/分类/标签/正文
|
||||
await page.getByRole("button", { name: "记一条", exact: true }).click();
|
||||
await page.getByLabel("标题").fill("回滚前先停灰度");
|
||||
await page.getByLabel("类型").selectOption("howto");
|
||||
await page.getByLabel("分类", { exact: true }).selectOption({ label: "研发 / 部署" });
|
||||
await page.getByLabel("标签").fill("发布 事故");
|
||||
await page
|
||||
.getByLabel("正文")
|
||||
.fill("## 步骤\n1. 停掉灰度流量\n2. 回滚镜像到上一个 tag\n3. 验证关键接口");
|
||||
await page.getByRole("button", { name: "保存并继续写", exact: true }).click();
|
||||
|
||||
// 保存后直接打开详情:标题、分类路径、标签都在
|
||||
await page.getByText("回滚前先停灰度", { exact: false }).first().waitFor({ timeout: 10_000 });
|
||||
const drawerText = (await page.textContent("body")) ?? "";
|
||||
check(
|
||||
"新建面板能把分类与标签一起存下来",
|
||||
drawerText.includes("研发 / 部署") && drawerText.includes("发布"),
|
||||
);
|
||||
await page.screenshot({ path: "e2e/artifacts/knowledge-composer-saved.png", fullPage: false });
|
||||
|
||||
// ③ 列表卡片上能看到分类路径与标签(不用点开)
|
||||
await page.getByRole("button", { name: "关闭", exact: true }).click();
|
||||
const card = page.locator("li").filter({ hasText: "回滚前先停灰度" }).first();
|
||||
await card.waitFor({ timeout: 10_000 });
|
||||
const cardText = (await card.textContent()) ?? "";
|
||||
check("列表卡片带分类路径与标签", cardText.includes("研发 / 部署") && cardText.includes("#发布"));
|
||||
|
||||
// ④ 侧栏按分类筛:部署里有,未分类里没有
|
||||
await page.getByRole("button", { name: /^部署/, exact: false }).first().click();
|
||||
await page.waitForTimeout(400);
|
||||
check(
|
||||
"点「部署」能看到归属该分类的知识",
|
||||
await page.getByText("回滚前先停灰度", { exact: false }).first().isVisible(),
|
||||
);
|
||||
await page.getByRole("button", { name: "未分类", exact: true }).click();
|
||||
await page.waitForTimeout(400);
|
||||
const uncategorizedHasCard = await page
|
||||
.getByText("回滚前先停灰度", { exact: false })
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
check("「未分类」里不出现已归类的知识", !uncategorizedHasCard);
|
||||
await page.screenshot({ path: "e2e/artifacts/knowledge-category-filter.png", fullPage: false });
|
||||
|
||||
// ⑤ 抽屉里能改标签与分类,改完接口里就是新的
|
||||
await page.getByRole("button", { name: "全部知识", exact: true }).click();
|
||||
// 先等抽屉自己把详情拉回来再填:GET 比手快一步的话,刚填的字会被它覆盖掉
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes("/knowledge/") &&
|
||||
r.request().method() === "GET" &&
|
||||
!r.url().endsWith("/knowledge/categories") &&
|
||||
r.status() === 200,
|
||||
),
|
||||
page.getByRole("button", { name: "打开", exact: true }).first().click(),
|
||||
]);
|
||||
const tagInput = page.getByLabel("标签", { exact: true }).last();
|
||||
await tagInput.waitFor({ timeout: 10_000 });
|
||||
// 先等抽屉把自己拉回来的旧标签填上——那一刻之后才没有"迟到的响应"来覆盖输入
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const el = document.querySelector('input[aria-label="标签"]');
|
||||
return !!el && el.value.includes("事故");
|
||||
},
|
||||
null,
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.catch(() => {});
|
||||
// 用真实键盘输入而不是 fill:fill 走原生 setter,React 的受控 input 有时收不到这次变更
|
||||
await tagInput.click();
|
||||
await tagInput.press("Control+A");
|
||||
await page.keyboard.type("发布 回滚", { delay: 20 });
|
||||
check("抽屉里的标签输入框拿到新值", (await tagInput.inputValue()) === "发布 回滚");
|
||||
const [patchResp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes("/knowledge/") &&
|
||||
r.request().method() === "PATCH" &&
|
||||
r.status() === 200,
|
||||
),
|
||||
page.getByRole("button", { name: "保存", exact: true }).last().click(),
|
||||
]);
|
||||
check("抽屉里保存标签/分类成功", patchResp.ok());
|
||||
|
||||
const list = await call(
|
||||
"GET",
|
||||
`/workspaces/${ws.id}/knowledge?status=draft&status=in_review`,
|
||||
undefined,
|
||||
token,
|
||||
);
|
||||
const doc = list.items.find((d) => d.title === "回滚前先停灰度");
|
||||
check(
|
||||
"接口里标签已更新、分类是「研发 / 部署」",
|
||||
String((doc?.tags ?? []).map((t) => t.name).join(" ")).includes("回滚") &&
|
||||
doc?.category?.full_path === "研发 / 部署",
|
||||
`tags=${JSON.stringify((doc?.tags ?? []).map((t) => t.name))} category=${doc?.category?.full_path}`,
|
||||
);
|
||||
check("正文按 Markdown 存下来了", String(doc?.body_md ?? "").includes("停掉灰度流量"));
|
||||
|
||||
// ⑥ 分类重名与三级上限:接口层面确认约束还在
|
||||
const dup = await fetch(
|
||||
`${API}/workspaces/${ws.id}/knowledge/categories`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ name: "部署", parent_id: doc.category.parent_id }),
|
||||
},
|
||||
).then((r) => r.json());
|
||||
check("同层重名被拒(7008)", dup.code === 7008, `code=${dup.code}`);
|
||||
|
||||
check("浏览器控制台与网络无报错", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | "));
|
||||
} catch (err) {
|
||||
failures += 1;
|
||||
console.log(`FAIL 脚本异常 — ${err.message}`);
|
||||
await page.screenshot({ path: "e2e/artifacts/knowledge-notebook-failure.png" }).catch(() => {});
|
||||
console.log((await page.textContent("body"))?.slice(0, 600));
|
||||
} finally {
|
||||
await browser.close();
|
||||
console.log(failures === 0 ? "\nALL PASS" : `\n${failures} FAILED`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
@@ -353,7 +353,7 @@ export function TaskDetail({
|
||||
<Sheet
|
||||
open
|
||||
onClose={onClose}
|
||||
width="max-w-2xl"
|
||||
size="xl"
|
||||
title={item ? item.title : "打开中…"}
|
||||
subtitle={meta}
|
||||
footer={
|
||||
|
||||
@@ -234,6 +234,19 @@ export const Link2 = (p: IconProps) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 放大到整屏 / 收回原来的宽度:长文和图片需要有地方放 */
|
||||
export const Expand = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Collapse = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M4 9h5V4M20 9h-5V4M4 15h5v5M20 15h-5v5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const More = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<circle cx="6" cy="12" r="1.2" fill="currentColor" stroke="none" />
|
||||
|
||||
@@ -8,7 +8,17 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Check, X } from "./icons";
|
||||
import { Check, Collapse, Expand, X } from "./icons";
|
||||
|
||||
/**
|
||||
* 抽屉宽度档位。以前的抽屉默认 `max-w-xl`(576px),写长文像在门缝里写字;
|
||||
* 知识、工单这类要贴图的内容统一走 xl,上面还有"展开整屏"。
|
||||
*/
|
||||
const SHEET_SIZE = {
|
||||
md: "max-w-2xl",
|
||||
lg: "max-w-4xl",
|
||||
xl: "max-w-6xl",
|
||||
} as const;
|
||||
|
||||
/* ——————————————————————————— 按钮 ——————————————————————————— */
|
||||
|
||||
@@ -267,7 +277,8 @@ export function Sheet({
|
||||
subtitle,
|
||||
children,
|
||||
footer,
|
||||
width = "max-w-xl",
|
||||
size = "lg",
|
||||
width,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -275,8 +286,14 @@ export function Sheet({
|
||||
subtitle?: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
/** 宽度档位;长文/多图用 xl,右上方还能一键放满整屏 */
|
||||
size?: "md" | "lg" | "xl";
|
||||
/** 兜底:直接给 Tailwind 宽度类 */
|
||||
width?: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const sizeClass = width ?? SHEET_SIZE[size];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -298,17 +315,31 @@ export function Sheet({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={typeof title === "string" ? title : "详情"}
|
||||
className={`relative flex h-full w-full ${width} animate-slide-in-right flex-col border-l border-line-200 bg-white shadow-pop`}
|
||||
className={`relative flex h-full w-full ${
|
||||
expanded ? "max-w-none" : sizeClass
|
||||
} animate-slide-in-right flex-col border-l border-line-200 bg-white shadow-pop`}
|
||||
>
|
||||
<header className="flex items-start justify-between gap-3 border-b border-line-100 px-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-semibold text-ink-900">{title}</div>
|
||||
{subtitle ? <div className="mt-0.5 text-xs text-ink-400">{subtitle}</div> : null}
|
||||
</div>
|
||||
<Button variant="subtle" size="sm" onClick={onClose} aria-label="关闭">
|
||||
<X size={15} />
|
||||
关闭
|
||||
</Button>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
aria-label={expanded ? "收起到侧栏" : "展开整屏"}
|
||||
title={expanded ? "收起到侧栏" : "展开整屏"}
|
||||
>
|
||||
{expanded ? <Collapse size={15} /> : <Expand size={15} />}
|
||||
<span className="hidden sm:inline">{expanded ? "收起" : "展开"}</span>
|
||||
</Button>
|
||||
<Button variant="subtle" size="sm" onClick={onClose} aria-label="关闭">
|
||||
<X size={15} />
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">{children}</div>
|
||||
{footer ? (
|
||||
|
||||
@@ -150,8 +150,25 @@ export interface KnowledgeSource {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeCategory {
|
||||
id: string;
|
||||
parent_id: string | null;
|
||||
name: string;
|
||||
depth: number;
|
||||
sort_order: number;
|
||||
version: number;
|
||||
/** 从根到自己的名字链,后端拼好了,前端不用自己爬树 */
|
||||
path: string[];
|
||||
full_path: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeCategoryNode extends KnowledgeCategory {
|
||||
doc_count: number;
|
||||
}
|
||||
|
||||
export interface Knowledge extends Item {
|
||||
knowledge: KnowledgeInfo;
|
||||
category: KnowledgeCategory | null;
|
||||
source: KnowledgeSource | null;
|
||||
attachments?: Attachment[];
|
||||
attachment_count?: number;
|
||||
|
||||
+471
-68
@@ -3,7 +3,7 @@ import { useParams } from "react-router-dom";
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { AttachmentPanel } from "../components/AttachmentPanel";
|
||||
import { RichTextEditor } from "../components/RichTextEditor";
|
||||
import { Book, Link2, Plus, Search, Sparkle } from "../components/icons";
|
||||
import { Link2, Plus, Search, Sparkle } from "../components/icons";
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
@@ -14,7 +14,13 @@ import {
|
||||
StatusPill,
|
||||
} from "../components/ui";
|
||||
import { ApiError, api } from "../lib/client";
|
||||
import type { Attachment, Knowledge, KnowledgeStatus, SearchHit } from "../lib/types";
|
||||
import type {
|
||||
Attachment,
|
||||
Knowledge,
|
||||
KnowledgeCategoryNode,
|
||||
KnowledgeStatus,
|
||||
SearchHit,
|
||||
} from "../lib/types";
|
||||
|
||||
const TABS = [
|
||||
{ key: "draft", label: "待核对", statuses: ["draft", "in_review"] },
|
||||
@@ -61,12 +67,48 @@ export function KnowledgePage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [draftTitle, setDraftTitle] = useState("");
|
||||
const [composing, setComposing] = useState(false);
|
||||
const [hits, setHits] = useState<SearchHit[] | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
/** null = 不按分类筛(全部);"none" = 未分类;其它是分类 id(含子分类) */
|
||||
const [catFilter, setCatFilter] = useState<string | null>(null);
|
||||
const [categories, setCategories] = useState<KnowledgeCategoryNode[]>([]);
|
||||
const [catBusy, setCatBusy] = useState(false);
|
||||
const reqId = useRef(0);
|
||||
|
||||
const loadCategories = useCallback(async () => {
|
||||
try {
|
||||
const page = await api.get<{ items: KnowledgeCategoryNode[] }>(
|
||||
`/workspaces/${wsId}/knowledge/categories`,
|
||||
);
|
||||
setCategories(page.items);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "分类加载失败");
|
||||
}
|
||||
}, [wsId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCategories();
|
||||
}, [loadCategories]);
|
||||
|
||||
async function createCategory(name: string, parentId: string | null) {
|
||||
setCatBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await api.post<KnowledgeCategoryNode>(
|
||||
`/workspaces/${wsId}/knowledge/categories`,
|
||||
{ name, parent_id: parentId },
|
||||
);
|
||||
await loadCategories();
|
||||
return created;
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "分类创建失败");
|
||||
return null;
|
||||
} finally {
|
||||
setCatBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const load = useCallback(
|
||||
async (nextCursor: string | null) => {
|
||||
// 切 tab / 改搜索会连发请求,慢的回来会盖掉新结果
|
||||
@@ -78,6 +120,8 @@ export function KnowledgePage() {
|
||||
const statuses = TABS.find((t) => t.key === tab)!.statuses;
|
||||
for (const s of statuses) qs.append("status", s);
|
||||
if (q.trim()) qs.set("q", q.trim());
|
||||
if (catFilter === "none") qs.set("uncategorized", "true");
|
||||
else if (catFilter) qs.set("category_id", catFilter);
|
||||
if (nextCursor) qs.set("cursor", nextCursor);
|
||||
const page = await api.get<{
|
||||
items: Knowledge[];
|
||||
@@ -94,7 +138,7 @@ export function KnowledgePage() {
|
||||
if (mine === reqId.current) setLoading(false);
|
||||
}
|
||||
},
|
||||
[wsId, tab, q],
|
||||
[wsId, tab, q, catFilter],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -127,22 +171,13 @@ export function KnowledgePage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function addNote() {
|
||||
const title = draftTitle.trim();
|
||||
if (!title) return;
|
||||
try {
|
||||
const doc = await api.post<Knowledge>(`/workspaces/${wsId}/knowledge`, {
|
||||
title,
|
||||
doc_type: "note",
|
||||
});
|
||||
setDraftTitle("");
|
||||
setAdding(false);
|
||||
setTab("draft");
|
||||
setOpenId(doc.id);
|
||||
void load(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "保存失败");
|
||||
}
|
||||
function onCreated(doc: Knowledge) {
|
||||
setComposing(false);
|
||||
setTab("draft");
|
||||
setCatFilter(null);
|
||||
setOpenId(doc.id);
|
||||
void load(null);
|
||||
void loadCategories();
|
||||
}
|
||||
|
||||
const pendingCount = docs.filter((d) => d.knowledge.status_hint !== "published").length;
|
||||
@@ -176,7 +211,7 @@ export function KnowledgePage() {
|
||||
<Search size={14} />
|
||||
{searching ? "检索中…" : "检索"}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => setAdding((v) => !v)}>
|
||||
<Button variant="primary" onClick={() => setComposing(true)}>
|
||||
<Plus size={14} />
|
||||
记一条
|
||||
</Button>
|
||||
@@ -184,6 +219,18 @@ export function KnowledgePage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<CategorySidebar
|
||||
categories={categories}
|
||||
selected={catFilter}
|
||||
onSelect={(value) => {
|
||||
setCatFilter(value);
|
||||
setHits(null);
|
||||
}}
|
||||
busy={catBusy}
|
||||
onCreate={(name, parentId) => void createCategory(name, parentId)}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-1.5">
|
||||
{TABS.map((t) => {
|
||||
const active = tab === t.key;
|
||||
@@ -208,34 +255,6 @@ export function KnowledgePage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{adding && (
|
||||
<div className="card mb-4 p-4">
|
||||
<div className="flex items-start gap-2 text-xs text-ink-500">
|
||||
<span className="mt-0.5 text-clay-500">
|
||||
<Book size={14} />
|
||||
</span>
|
||||
<p className="leading-relaxed">
|
||||
手记的知识没有来源,它是你自己的笔记;想沉淀某件事,去那件事的详情里点「沉淀成知识」。
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
placeholder="一句话标题,比如「报销单要贴发票原件」"
|
||||
value={draftTitle}
|
||||
autoFocus
|
||||
onChange={(e) => setDraftTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void addNote();
|
||||
}}
|
||||
/>
|
||||
<Button variant="primary" disabled={!draftTitle.trim()} onClick={() => void addNote()}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4">
|
||||
<InlineAlert>{error}</InlineAlert>
|
||||
@@ -327,6 +346,11 @@ export function KnowledgePage() {
|
||||
自动整理
|
||||
</span>
|
||||
) : null}
|
||||
{d.category ? (
|
||||
<span className="chip max-w-[16rem] truncate bg-paper-200 text-ink-500">
|
||||
{d.category.full_path}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1.5 truncate text-sm font-medium text-ink-900 group-hover:text-primary-700">
|
||||
{d.title}
|
||||
@@ -339,6 +363,16 @@ export function KnowledgePage() {
|
||||
{d.source ? <span className="max-w-[22rem] truncate">「{d.source.title}」</span> : null}
|
||||
<span>{new Date(d.updated_at).toLocaleString("zh-CN")}</span>
|
||||
</p>
|
||||
{/* 标签是备忘录的检索入口,卡片上就露出来,不用点开才知道归在哪 */}
|
||||
{d.tags.length > 0 ? (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{d.tags.slice(0, 5).map((t) => (
|
||||
<span key={t.id} className="chip bg-primary-50 text-primary-700">
|
||||
#{t.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="shrink-0" onClick={() => setOpenId(d.id)}>
|
||||
打开
|
||||
@@ -355,28 +389,353 @@ export function KnowledgePage() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{composing && (
|
||||
<KnowledgeComposer
|
||||
wsId={wsId}
|
||||
categories={categories}
|
||||
catBusy={catBusy}
|
||||
onCreateCategory={createCategory}
|
||||
onClose={() => setComposing(false)}
|
||||
onCreated={onCreated}
|
||||
/>
|
||||
)}
|
||||
|
||||
{openId && (
|
||||
<KnowledgeDrawer
|
||||
wsId={wsId}
|
||||
itemId={openId}
|
||||
categories={categories}
|
||||
onClose={() => setOpenId(null)}
|
||||
onChanged={() => void load(null)}
|
||||
onChanged={() => {
|
||||
void load(null);
|
||||
void loadCategories();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** 左侧分类树:一层一层缩进,点一下就把列表收窄到该分类(含子分类)。 */
|
||||
function CategorySidebar({
|
||||
categories,
|
||||
selected,
|
||||
onSelect,
|
||||
busy,
|
||||
onCreate,
|
||||
}: {
|
||||
categories: KnowledgeCategoryNode[];
|
||||
selected: string | null;
|
||||
onSelect: (value: string | null) => void;
|
||||
busy: boolean;
|
||||
onCreate: (name: string, parentId: string | null) => void;
|
||||
}) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [parentId, setParentId] = useState("");
|
||||
|
||||
return (
|
||||
<aside className="card h-fit p-3 lg:sticky lg:top-4">
|
||||
<div className="flex items-center justify-between px-1 py-1">
|
||||
<span className="text-2xs font-medium uppercase tracking-wide text-ink-400">分类</span>
|
||||
<button className="link text-2xs" onClick={() => setAdding((v) => !v)}>
|
||||
{adding ? "取消" : "+ 新建"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{adding && (
|
||||
<form
|
||||
className="mt-2 space-y-1.5 rounded-lg border border-line-200 bg-paper-50 p-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const value = name.trim();
|
||||
if (!value) return;
|
||||
onCreate(value, parentId || null);
|
||||
setName("");
|
||||
setAdding(false);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="input h-8 text-sm"
|
||||
placeholder="分类名,例如「部署」"
|
||||
value={name}
|
||||
autoFocus
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
aria-label="新分类名"
|
||||
/>
|
||||
<select
|
||||
className="input h-8 text-sm"
|
||||
value={parentId}
|
||||
onChange={(e) => setParentId(e.target.value)}
|
||||
aria-label="父分类"
|
||||
>
|
||||
<option value="">放在最外层</option>
|
||||
{categories
|
||||
.filter((c) => c.depth < 3)
|
||||
.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.full_path} 下
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button variant="primary" size="sm" type="submit" disabled={busy || !name.trim()}>
|
||||
创建
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<nav className="mt-2 space-y-0.5">
|
||||
<CategoryButton active={selected === null} onClick={() => onSelect(null)}>
|
||||
全部知识
|
||||
</CategoryButton>
|
||||
<CategoryButton active={selected === "none"} onClick={() => onSelect("none")}>
|
||||
未分类
|
||||
</CategoryButton>
|
||||
{categories.map((c) => (
|
||||
<CategoryButton
|
||||
key={c.id}
|
||||
active={selected === c.id}
|
||||
indent={c.depth - 1}
|
||||
onClick={() => onSelect(c.id)}
|
||||
>
|
||||
<span className="truncate">{c.name}</span>
|
||||
{c.doc_count > 0 ? <span className="text-2xs text-ink-400">{c.doc_count}</span> : null}
|
||||
</CategoryButton>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{categories.length === 0 ? (
|
||||
<p className="mt-2 px-1 text-2xs leading-relaxed text-ink-400">
|
||||
还没有分类。先建一层「研发 / 部署」这样的目录,知识才有地方落脚。
|
||||
</p>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryButton({
|
||||
active,
|
||||
indent = 0,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
indent?: number;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{ paddingLeft: `${8 + indent * 14}px` }}
|
||||
className={`flex w-full items-center justify-between gap-2 rounded-lg py-1.5 pr-2 text-sm transition-colors ${
|
||||
active
|
||||
? "bg-white text-ink-900 shadow-sm ring-1 ring-line-200"
|
||||
: "text-ink-600 hover:bg-white/60 hover:text-ink-800"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 新建知识:标题、类型、分类、标签、正文一起填,不再是「输入标题就算完」。 */
|
||||
function KnowledgeComposer({
|
||||
wsId,
|
||||
categories,
|
||||
catBusy,
|
||||
onCreateCategory,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
wsId: string;
|
||||
categories: KnowledgeCategoryNode[];
|
||||
catBusy: boolean;
|
||||
onCreateCategory: (name: string, parentId: string | null) => Promise<KnowledgeCategoryNode | null>;
|
||||
onClose: () => void;
|
||||
onCreated: (doc: Knowledge) => void;
|
||||
}) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [docType, setDocType] = useState("note");
|
||||
const [body, setBody] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
const [newCat, setNewCat] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const parentDepth = categories.find((c) => c.id === categoryId)?.depth ?? 0;
|
||||
const canAddCategory = parentDepth < 3 && Boolean(newCat.trim());
|
||||
const canSave = Boolean(title.trim()) && !busy;
|
||||
|
||||
async function addCategory() {
|
||||
const name = newCat.trim();
|
||||
if (!name || !canAddCategory) return;
|
||||
const created = await onCreateCategory(name, categoryId || null);
|
||||
if (created) {
|
||||
setCategoryId(created.id);
|
||||
setNewCat("");
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!canSave) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const doc = await api.post<Knowledge>(`/workspaces/${wsId}/knowledge`, {
|
||||
title: title.trim(),
|
||||
body_md: body,
|
||||
doc_type: docType,
|
||||
tag_names: tags.split(/[\s,,]+/).filter(Boolean),
|
||||
category_id: categoryId || null,
|
||||
});
|
||||
onCreated(doc);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "保存失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
title="记一条知识"
|
||||
subtitle="标题、正文、标签、分类一次填完;保存后可以继续贴图、传附件。"
|
||||
footer={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="primary" disabled={!canSave} onClick={() => void save()}>
|
||||
{busy ? "保存中…" : "保存并继续写"}
|
||||
</Button>
|
||||
<Button variant="ghost" disabled={busy} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<span className="ml-auto text-2xs text-ink-400">
|
||||
先存成草稿,核对发布后才进团队检索
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="label">标题</span>
|
||||
<input
|
||||
className="input text-base font-medium"
|
||||
placeholder="一句话说清这条知识是什么,比如「回滚前先停掉灰度」"
|
||||
value={title}
|
||||
autoFocus
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
aria-label="标题"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="label">类型</span>
|
||||
<select
|
||||
className="input"
|
||||
value={docType}
|
||||
onChange={(e) => setDocType(e.target.value)}
|
||||
aria-label="类型"
|
||||
>
|
||||
{Object.entries(DOC_TYPE_LABEL).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="label">分类</span>
|
||||
<select
|
||||
className="input"
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
aria-label="分类"
|
||||
>
|
||||
<option value="">未分类</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.full_path}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-line-200 bg-paper-50 p-2.5">
|
||||
<span className="label">在当前分类下新建子分类(最多三级)</span>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input h-9 flex-1 text-sm"
|
||||
placeholder={parentDepth >= 3 ? "已经到第三级了" : "子分类名"}
|
||||
value={newCat}
|
||||
disabled={parentDepth >= 3}
|
||||
onChange={(e) => setNewCat(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void addCategory();
|
||||
}
|
||||
}}
|
||||
aria-label="新建子分类"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={!canAddCategory || catBusy}
|
||||
onClick={() => void addCategory()}
|
||||
>
|
||||
新建
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="label">标签(空格或逗号分隔)</span>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="例如:发布 事故"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
aria-label="标签"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="label">正文</span>
|
||||
<textarea
|
||||
className="input min-h-64 resize-y font-mono text-sm leading-relaxed"
|
||||
rows={14}
|
||||
placeholder={"可以直接写 Markdown:\n\n## 步骤\n1. 停灰度\n2. 回滚镜像\n3. 验证关键接口"}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
aria-label="正文"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <InlineAlert>{error}</InlineAlert>}
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** 知识详情抽屉:能改、能提交审议、能发布/归档。 */
|
||||
function KnowledgeDrawer({
|
||||
wsId,
|
||||
itemId,
|
||||
categories,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
wsId: string;
|
||||
itemId: string;
|
||||
categories: KnowledgeCategoryNode[];
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
@@ -384,6 +743,8 @@ function KnowledgeDrawer({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
/** 附件 id → 带签名的直链(见 lib/attachmentUrls.ts) */
|
||||
const resources = useMemo(() => {
|
||||
@@ -394,18 +755,27 @@ function KnowledgeDrawer({
|
||||
return map;
|
||||
}, [attachments]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
/** 已经载入完成的是哪一条知识。重复的 effect / 慢响应不该再覆盖用户正在敲的内容。 */
|
||||
const loadedFor = useRef<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`);
|
||||
setDoc(data);
|
||||
setTitle(data.title);
|
||||
setBody(data.body_md);
|
||||
setAttachments(data.attachments ?? []);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "加载失败");
|
||||
}
|
||||
}, [wsId, itemId]);
|
||||
const load = useCallback(
|
||||
async (force = false) => {
|
||||
if (!force && loadedFor.current === itemId) return;
|
||||
try {
|
||||
const data = await api.get<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`);
|
||||
setDoc(data);
|
||||
setTitle(data.title);
|
||||
setBody(data.body_md);
|
||||
setTags(data.tags.map((t) => t.name).join(" "));
|
||||
setCategoryId(data.category?.id ?? "");
|
||||
setAttachments(data.attachments ?? []);
|
||||
loadedFor.current = itemId;
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "加载失败");
|
||||
}
|
||||
},
|
||||
[wsId, itemId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -419,6 +789,8 @@ function KnowledgeDrawer({
|
||||
const next = await api.patch<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`, {
|
||||
title,
|
||||
body_md: body,
|
||||
tag_names: tags.split(/[\s,,]+/).filter(Boolean),
|
||||
...(categoryId ? { category_id: categoryId } : { clear_category: true }),
|
||||
version: doc.version,
|
||||
});
|
||||
setDoc(next);
|
||||
@@ -426,7 +798,7 @@ function KnowledgeDrawer({
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.code === 4009) {
|
||||
setError("刚被别人改过,已刷新为最新内容");
|
||||
await load();
|
||||
await load(true);
|
||||
} else {
|
||||
setError(err instanceof ApiError ? err.message : "保存失败");
|
||||
}
|
||||
@@ -448,7 +820,7 @@ function KnowledgeDrawer({
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "操作失败");
|
||||
await load();
|
||||
await load(true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -472,13 +844,13 @@ function KnowledgeDrawer({
|
||||
<Sheet
|
||||
open
|
||||
onClose={onClose}
|
||||
width="max-w-2xl"
|
||||
size="xl"
|
||||
title={doc ? doc.title : "知识"}
|
||||
subtitle={
|
||||
doc
|
||||
? `${DOC_TYPE_LABEL[doc.knowledge.doc_type] ?? doc.knowledge.doc_type} · ${
|
||||
STATUS_LABEL[doc.knowledge.status_hint]
|
||||
} · 版本 ${doc.version}`
|
||||
? [doc.category?.full_path, DOC_TYPE_LABEL[doc.knowledge.doc_type] ?? doc.knowledge.doc_type]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: "加载中…"
|
||||
}
|
||||
footer={
|
||||
@@ -543,6 +915,37 @@ function KnowledgeDrawer({
|
||||
aria-label="标题"
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="label">分类</span>
|
||||
<select
|
||||
className="input"
|
||||
value={categoryId}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
aria-label="分类"
|
||||
>
|
||||
<option value="">未分类</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.full_path}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="label">标签(空格或逗号分隔)</span>
|
||||
<input
|
||||
className="input"
|
||||
value={tags}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="例如:发布 事故"
|
||||
aria-label="标签"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2">
|
||||
<RichTextEditor
|
||||
ariaLabel="正文"
|
||||
|
||||
@@ -302,6 +302,7 @@ export function TicketsPage() {
|
||||
<Sheet
|
||||
open={creating}
|
||||
onClose={() => setCreating(false)}
|
||||
size="xl"
|
||||
title="新建工单"
|
||||
subtitle="不是从任务升级来的也可以——写清楚要办什么,指定一个人。"
|
||||
footer={
|
||||
|
||||
Reference in New Issue
Block a user