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:
@@ -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 />} />
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -6,6 +6,7 @@ const TABS = [
|
||||
{ path: "", label: "任务" },
|
||||
{ path: "/board", label: "看板" },
|
||||
{ path: "/tickets", label: "工单" },
|
||||
{ path: "/knowledge", label: "知识" },
|
||||
{ path: "/members", label: "成员" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user