feat(knowledge): 工单验收自动生成草稿,核对后发布,来源可追

- knowledge_doc 与 item 1:1,除 source_kind=manual 外必须有来源(库级约束)
- 状态机 draft/in_review/published/archived,发布要 owner/admin;非法动作回 allowed_from 与 allowed_actions
- 工单验收通过自动落 postmortem 草稿(generated_by=rule),同一来源只生成一次
- 接口:列表/详情/手记/改内容/状态动作/从事项沉淀;已发布禁改,改内容带乐观锁
- Web 知识页 + 详情抽屉;任务与工单详情里可「沉淀成知识」
- 8 个后端用例 + e2e 7 项断言(沉淀 → 待核对 → 发布)
This commit is contained in:
dadao
2026-10-28 14:02:35 +08:00
parent 3471b07ac1
commit e9addeed25
17 changed files with 1957 additions and 1 deletions
+2
View File
@@ -8,6 +8,7 @@ import { WorkspacePage } from "./pages/Workspace";
import { TasksPage } from "./pages/Tasks";
import { BoardPage } from "./pages/Board";
import { TicketsPage } from "./pages/Tickets";
import { KnowledgePage } from "./pages/Knowledge";
import { JoinPage } from "./pages/Join";
interface AuthState {
@@ -86,6 +87,7 @@ export function App() {
<Route path="/w/:wsId" element={<TasksPage />} />
<Route path="/w/:wsId/board" element={<BoardPage />} />
<Route path="/w/:wsId/tickets" element={<TicketsPage />} />
<Route path="/w/:wsId/knowledge" element={<KnowledgePage />} />
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
<Route path="/join/:wsId/:code" element={<JoinPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
+33
View File
@@ -201,6 +201,25 @@ export function TaskDetail({
}
}
/** 把这件事整理成知识草稿。草稿默认待核对,不直接发布。 */
async function toKnowledge() {
if (!item) return;
setBusy(true);
setError(null);
try {
await api.post(`${base}/knowledge?doc_type=${item.kind === "ticket" ? "postmortem" : "howto"}`);
setNotice("已生成知识草稿。它现在在「知识 → 待核对」里,核对无误后再发布,才会进入团队检索。");
} catch (err) {
if (err instanceof ApiError && err.code === 7002) {
setNotice("这件事已经有一份知识草稿了,去「知识」页找它。");
} else {
setError(err instanceof ApiError ? err.message : "生成失败");
}
} finally {
setBusy(false);
}
}
async function ticketAction(action: TicketAction, extra: Record<string, unknown> = {}) {
if (!item) return;
setBusy(true);
@@ -412,6 +431,20 @@ export function TaskDetail({
</div>
)}
{item.kind !== "knowledge" && item.status !== "canceled" && (
<div className="mt-4 rounded-lg border border-ink-100 p-3">
<p className="text-sm font-medium">这件事值得留下来?沉淀成知识</p>
<p className="mt-1 text-xs text-ink-500">
会把标题、备注、时间线和评论整理成一份草稿,默认进「待核对」,不会直接对外发布。
</p>
<div className="mt-2">
<button className="btn-ghost px-3 py-1 text-sm" disabled={busy} onClick={() => void toKnowledge()}>
{item.kind === "ticket" ? "沉淀成复盘" : "沉淀成知识"}
</button>
</div>
</div>
)}
{notice && <p className="mt-3 text-sm text-emerald-700">{notice}</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
{busy && <p className="mt-3 text-xs text-ink-500">处理中…</p>}
+1
View File
@@ -6,6 +6,7 @@ const TABS = [
{ path: "", label: "任务" },
{ path: "/board", label: "看板" },
{ path: "/tickets", label: "工单" },
{ path: "/knowledge", label: "知识" },
{ path: "/members", label: "成员" },
];
+28
View File
@@ -112,3 +112,31 @@ export interface Notification {
read_at: string | null;
created_at: string;
}
export type KnowledgeStatus = "draft" | "in_review" | "published" | "archived";
export interface KnowledgeInfo {
doc_type: string;
summary: string;
status_hint: KnowledgeStatus;
source_kind: "manual" | "task" | "ticket";
source_item_id: string | null;
generated_by: "human" | "rule";
published_at: string | null;
archived_at: string | null;
chunk_count: number;
author_id: string;
}
export interface KnowledgeSource {
id: string;
kind: string;
title: string;
status: string;
}
export interface Knowledge extends Item {
knowledge: KnowledgeInfo;
source: KnowledgeSource | null;
activities?: Activity[];
}
+390
View File
@@ -0,0 +1,390 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { Layout } from "../components/Layout";
import { WorkspaceNav } from "../components/WorkspaceNav";
import { ApiError, api } from "../lib/client";
import type { Knowledge, KnowledgeStatus } from "../lib/types";
const TABS = [
{ key: "draft", label: "待核对", statuses: ["draft", "in_review"] },
{ key: "published", label: "已发布", statuses: ["published"] },
{ key: "archived", label: "已归档", statuses: ["archived"] },
{ key: "all", label: "全部", statuses: [] },
] 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 ACTION_LABEL: Record<string, string> = {
submit_review: "提交审议",
withdraw: "撤回审议",
publish: "发布",
archive: "归档",
restore: "恢复成草稿",
};
export function KnowledgePage() {
const { wsId = "" } = useParams();
const [tab, setTab] = useState<(typeof TABS)[number]["key"]>("draft");
const [q, setQ] = useState("");
const [docs, setDocs] = useState<Knowledge[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
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 reqId = useRef(0);
const load = useCallback(
async (nextCursor: string | null) => {
// 切 tab / 改搜索会连发请求,慢的回来会盖掉新结果
const mine = ++reqId.current;
setLoading(true);
setError(null);
try {
const qs = new URLSearchParams();
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 (nextCursor) qs.set("cursor", nextCursor);
const page = await api.get<{
items: Knowledge[];
next_cursor: string | null;
has_more: boolean;
}>(`/workspaces/${wsId}/knowledge?${qs.toString()}`);
if (mine !== reqId.current) return;
setDocs((prev) => (nextCursor ? [...prev, ...page.items] : page.items));
setCursor(page.has_more ? page.next_cursor : null);
} catch (err) {
if (mine !== reqId.current) return;
setError(err instanceof ApiError ? err.message : "知识加载失败");
} finally {
if (mine === reqId.current) setLoading(false);
}
},
[wsId, tab, q],
);
useEffect(() => {
void load(null);
}, [load]);
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 : "保存失败");
}
}
return (
<Layout>
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-lg font-semibold">知识</h1>
<p className="mt-0.5 text-xs text-ink-500">
工单验收通过会自动整理一份草稿;核对无误再发布,发布后才会进入团队检索
</p>
</div>
<WorkspaceNav wsId={wsId} />
</div>
<div className="mb-3 flex flex-wrap items-center gap-2">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`rounded-full px-3 py-1 text-sm transition ${
tab === t.key ? "bg-ink-900 text-white" : "bg-ink-100 text-ink-700 hover:bg-ink-100/70"
}`}
>
{t.label}
</button>
))}
<input
className="input ml-auto w-56"
placeholder="搜标题或正文…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<button className="btn-primary px-3 py-1 text-sm" onClick={() => setAdding((v) => !v)}>
记一条
</button>
</div>
{adding && (
<div className="card mb-3">
<p className="mb-2 text-xs text-ink-500">
手记的知识没有来源,它是你自己的笔记;想沉淀某件事,去那件事的详情里点「沉淀成知识」。
</p>
<div className="flex gap-2">
<input
className="input flex-1"
placeholder="一句话标题,比如「报销单要贴发票原件」"
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void addNote();
}}
/>
<button className="btn-primary px-3 py-1 text-sm" onClick={() => void addNote()}>
保存
</button>
</div>
</div>
)}
{error && <p className="mb-3 rounded-lg bg-red-50 p-2 text-sm text-red-700">{error}</p>}
{docs.length === 0 && !loading && (
<div className="card text-center text-sm text-ink-500">
{tab === "draft"
? "还没有待核对的知识。把工单验收通过,或者在这里「记一条」。"
: "这里还空着。"}
</div>
)}
<ul className="space-y-2">
{docs.map((d) => (
<li key={d.id} className="card">
<div className="flex items-start justify-between gap-3">
<button className="min-w-0 flex-1 text-left" onClick={() => setOpenId(d.id)}>
<p className="truncate text-sm font-medium">{d.title}</p>
<p className="mt-1 flex flex-wrap items-center gap-2 text-xs text-ink-500">
<span className="rounded bg-ink-100 px-1.5 py-0.5">
{DOC_TYPE_LABEL[d.knowledge.doc_type] ?? d.knowledge.doc_type}
</span>
<span>{STATUS_LABEL[d.knowledge.status_hint]}</span>
<span>{SOURCE_LABEL[d.knowledge.source_kind] ?? d.knowledge.source_kind}</span>
{d.source && <span className="truncate">「{d.source.title}」</span>}
<span>{new Date(d.updated_at).toLocaleString("zh-CN")}</span>
</p>
</button>
<button className="btn-ghost shrink-0 px-2 py-1 text-xs" onClick={() => setOpenId(d.id)}>
打开
</button>
</div>
</li>
))}
</ul>
{cursor && (
<div className="mt-4 text-center">
<button className="btn-ghost" disabled={loading} onClick={() => void load(cursor)}>
{loading ? "加载中…" : "加载更多"}
</button>
</div>
)}
{openId && (
<KnowledgeDrawer
wsId={wsId}
itemId={openId}
onClose={() => setOpenId(null)}
onChanged={() => void load(null)}
/>
)}
</Layout>
);
}
/** 知识详情抽屉:能改、能提交审议、能发布/归档。 */
function KnowledgeDrawer({
wsId,
itemId,
onClose,
onChanged,
}: {
wsId: string;
itemId: string;
onClose: () => void;
onChanged: () => void;
}) {
const [doc, setDoc] = useState<Knowledge | null>(null);
const [error, setError] = useState<string | null>(null);
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
try {
const data = await api.get<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`);
setDoc(data);
setTitle(data.title);
setBody(data.body_md);
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
}
}, [wsId, itemId]);
useEffect(() => {
void load();
}, [load]);
async function save() {
if (!doc) return;
setBusy(true);
setError(null);
try {
const next = await api.patch<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`, {
title,
body_md: body,
version: doc.version,
});
setDoc(next);
onChanged();
} catch (err) {
if (err instanceof ApiError && err.code === 4009) {
setError("刚被别人改过,已刷新为最新内容");
await load();
} else {
setError(err instanceof ApiError ? err.message : "保存失败");
}
} finally {
setBusy(false);
}
}
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);
onChanged();
} 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";
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 (
<div className="fixed inset-0 z-20 flex justify-end bg-ink-900/20" onClick={onClose}>
<aside
className="flex h-full w-full max-w-xl flex-col overflow-y-auto bg-white p-6 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
{!doc ? (
<p className="text-sm text-ink-500">{error ?? "加载中…"}</p>
) : (
<>
<div className="mb-4 flex items-center justify-between text-xs text-ink-500">
<span>
{DOC_TYPE_LABEL[doc.knowledge.doc_type] ?? doc.knowledge.doc_type} ·{" "}
{STATUS_LABEL[doc.knowledge.status_hint]} · 版本 {doc.version}
</span>
<button className="btn-ghost px-2 py-1 text-xs" onClick={onClose}>
关闭
</button>
</div>
<div className="mb-4 rounded-lg border border-ink-100 bg-ink-50 p-3 text-xs text-ink-700">
{doc.source ? (
<p>
来源:{doc.source.kind === "ticket" ? "工单" : "任务"}「{doc.source.title}」
</p>
) : (
<p>来源:手记(没有关联的事项)</p>
)}
<p className="mt-1">
{doc.knowledge.generated_by === "rule"
? "这份草稿由系统整理,请核对后再发布。"
: "手动创建。"}
{doc.knowledge.published_at &&
` 发布于 ${new Date(doc.knowledge.published_at).toLocaleString("zh-CN")}。`}
</p>
</div>
<input
className="input mb-3"
value={title}
disabled={!canEdit}
onChange={(e) => setTitle(e.target.value)}
/>
<textarea
className="input mb-3 min-h-[280px] font-mono text-xs"
value={body}
disabled={!canEdit}
onChange={(e) => setBody(e.target.value)}
/>
{canEdit && (
<button className="btn-primary mb-4 self-start" disabled={busy} onClick={() => void save()}>
{busy ? "保存中…" : "保存"}
</button>
)}
<div className="mb-2 flex flex-wrap gap-2">
{actions
.filter((a) => a.show)
.map((a) => (
<button
key={a.key}
className={a.key === "publish" ? "btn-primary px-3 py-1 text-sm" : "btn-ghost px-3 py-1 text-sm"}
disabled={busy}
onClick={() => void act(a.key)}
>
{a.label}
</button>
))}
</div>
{error && <p className="text-sm text-red-700">{error}</p>}
</>
)}
</aside>
</div>
);
}