import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useParams } from "react-router-dom"; import { AppShell } from "../components/AppShell"; import { AttachmentPanel } from "../components/AttachmentPanel"; import { RichTextEditor } from "../components/RichTextEditor"; import { Link2, Plus, Search, Sparkle } from "../components/icons"; import { Button, EmptyState, InlineAlert, PageHeader, Sheet, SkeletonRows, StatusPill, } from "../components/ui"; import { ApiError, api } from "../lib/client"; import type { Attachment, Knowledge, KnowledgeCategoryNode, KnowledgeStatus, SearchHit, } 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 = { note: "笔记", howto: "怎么做", faq: "常见问题", postmortem: "复盘", decision: "决策", }; const STATUS_LABEL: Record = { draft: "草稿", in_review: "待审议", published: "已发布", archived: "已归档", }; const SOURCE_LABEL: Record = { manual: "手记", task: "来自任务", ticket: "来自工单", }; const ACTION_LABEL: Record = { 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([]); const [cursor, setCursor] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [openId, setOpenId] = useState(null); const [composing, setComposing] = useState(false); const [hits, setHits] = useState(null); const [searching, setSearching] = useState(false); /** null = 不按分类筛(全部);"none" = 未分类;其它是分类 id(含子分类) */ const [catFilter, setCatFilter] = useState(null); const [categories, setCategories] = useState([]); 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( `/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 / 改搜索会连发请求,慢的回来会盖掉新结果 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 (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[]; 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, catFilter], ); useEffect(() => { void load(null); }, [load]); /** * 语义检索走的是切片接口,和上面的列表筛选不是一回事: * 列表按标题/正文做子串匹配,检索按向量 + trigram 打分,只覆盖已发布的知识。 * 所以不把 q 直接接到列表上——两套结果混在一起会让人搞不清命中在哪。 */ async function runSearch() { const keyword = q.trim(); if (!keyword) { setHits(null); return; } setSearching(true); setError(null); try { const query = new URLSearchParams({ q: keyword, limit: "12" }); const page = await api.get<{ items: SearchHit[] }>( `/workspaces/${wsId}/knowledge/search?${query.toString()}`, ); setHits(page.items); } catch (err) { setError(err instanceof ApiError ? err.message : "检索失败"); } finally { setSearching(false); } } 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; return ( 沉淀下来的东西,半年后还认得出来吗} actions={ <>
{ setQ(e.target.value); if (!e.target.value.trim()) setHits(null); }} onKeyDown={(e) => { if (e.key === "Enter") void runSearch(); }} />
} />
{ setCatFilter(value); setHits(null); }} busy={catBusy} onCreate={(name, parentId) => void createCategory(name, parentId)} />
{TABS.map((t) => { const active = tab === t.key; return ( ); })} {tab === "draft" && pendingCount > 0 ? ( {pendingCount} 份草稿等你核对 ) : null}
{error && (
{error}
)} {hits !== null ? (

在已发布的知识里检索「{q.trim()}」,命中 {hits.length} 段

{hits.length === 0 ? (
} title="没有检索到相关内容" hint="只检索已发布的知识。换成更具体的说法,或者先把手头的草稿核对发布。" />
) : ( hits.map((hit) => (
{DOC_TYPE_LABEL[hit.doc_type] ?? hit.doc_type} {hit.heading ? ( {hit.heading} ) : null} 相关度 {hit.score.toFixed(2)}

{hit.title}

{hit.content}

)) )}
) : loading && docs.length === 0 ? (
) : docs.length === 0 ? (
} title={ tab === "draft" ? "还没有待核对的知识" : "这里还空着" } hint={ tab === "draft" ? "把一张工单验收通过,系统会自动整理一份草稿;或者在这里「记一条」。" : "换个筛选条件看看,或者先记一条。" } />
) : (
    {docs.map((d) => (
  • ))}
)} {cursor && (
)}
{composing && ( setComposing(false)} onCreated={onCreated} /> )} {openId && ( setOpenId(null)} onChanged={() => { void load(null); void loadCategories(); }} /> )}
); } /** 左侧分类树:一层一层缩进,点一下就把列表收窄到该分类(含子分类)。 */ 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 ( ); } function CategoryButton({ active, indent = 0, onClick, children, }: { active: boolean; indent?: number; onClick: () => void; children: React.ReactNode; }) { return ( ); } /** 新建知识:标题、类型、分类、标签、正文一起填,不再是「输入标题就算完」。 */ function KnowledgeComposer({ wsId, categories, catBusy, onCreateCategory, onClose, onCreated, }: { wsId: string; categories: KnowledgeCategoryNode[]; catBusy: boolean; onCreateCategory: (name: string, parentId: string | null) => Promise; 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(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(`/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 ( 先存成草稿,核对发布后才进团队检索 } >
在当前分类下新建子分类(最多三级)
= 3 ? "已经到第三级了" : "子分类名"} value={newCat} disabled={parentDepth >= 3} onChange={(e) => setNewCat(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void addCategory(); } }} aria-label="新建子分类" />
正文 {/* 和详情抽屉同一个富文本编辑器:新建时就该能排版、写表格,而不是先存一个纯文本框 */}
{error && {error}}
); } /** 知识详情抽屉:能改、能提交审议、能发布/归档。 */ function KnowledgeDrawer({ wsId, itemId, categories, onClose, onChanged, }: { wsId: string; itemId: string; categories: KnowledgeCategoryNode[]; onClose: () => void; onChanged: () => void; }) { const [doc, setDoc] = useState(null); const [error, setError] = useState(null); const [title, setTitle] = useState(""); const [body, setBody] = useState(""); const [tags, setTags] = useState(""); const [categoryId, setCategoryId] = useState(""); const [attachments, setAttachments] = useState([]); /** 附件 id → 带签名的直链(见 lib/attachmentUrls.ts) */ const resources = useMemo(() => { const map: Record = {}; for (const row of attachments) { if (row.raw_url) map[row.id] = row.raw_url; } return map; }, [attachments]); const [busy, setBusy] = useState(false); /** 已经载入完成的是哪一条知识。重复的 effect / 慢响应不该再覆盖用户正在敲的内容。 */ const loadedFor = useRef(null); const load = useCallback( async (force = false) => { if (!force && loadedFor.current === itemId) return; try { const data = await api.get(`/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(); }, [load]); async function save() { if (!doc) return; setBusy(true); setError(null); try { const next = await api.patch(`/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); onChanged(); } catch (err) { if (err instanceof ApiError && err.code === 4009) { setError("刚被别人改过,已刷新为最新内容"); await load(true); } 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(`/workspaces/${wsId}/knowledge/${itemId}/status`, { action, version: doc.version, }); setDoc(next); onChanged(); } catch (err) { setError(err instanceof ApiError ? err.message : "操作失败"); await load(true); } 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 ( {canEdit ? ( ) : null} {actions .filter((a) => a.show) .map((a) => ( ))} {doc.knowledge.published_at ? `发布于 ${new Date(doc.knowledge.published_at).toLocaleString("zh-CN")}` : "尚未发布,只有你能看到"} ) : null } > {!doc ? (

{error ?? "加载中…"}

) : (
{doc.source ? ( 来源:{doc.source.kind === "ticket" ? "工单" : "任务"}「{doc.source.title}」 ) : ( 来源:手记(没有关联的事项) )}

{doc.knowledge.generated_by === "rule" ? "这份草稿由系统整理,请核对后再发布。" : "手动创建。"} {doc.knowledge.published_at ? ` 发布于 ${new Date(doc.knowledge.published_at).toLocaleString("zh-CN")}。` : ""}

setTitle(e.target.value)} aria-label="标题" />
setAttachments((prev) => prev.some((row) => row.id === created.id) ? prev : [...prev, created as unknown as Attachment], ) } onError={(message) => setError(message)} disabled={!canEdit} />
setError(message)} disabled={!canEdit} />
{error && {error}}
)}
); }