/** * 知识阅读页(`/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, Trash } 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(null); const [categories, setCategories] = useState([]); const [error, setError] = useState(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(`/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 = {}; 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(`/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"; /** 删除:确认后回列表页。后端是软删,但界面按"没了"处理最不容易让人误会。 */ async function remove() { if (!doc) return; if (!window.confirm(`删除「${doc.title}」?删了不会回来,检索里也会一起消失。`)) return; setBusy(true); setError(null); try { await api.del(`/workspaces/${wsId}/knowledge/${itemId}?confirm=true`); navigate(`/w/${wsId}/knowledge`, { replace: true }); } catch (err) { setError(err instanceof ApiError ? err.message : "删除失败"); } finally { setBusy(false); } } 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 ( {/* 宽屏上别把正文挤成窄条:整块跟到 78rem,正文列限宽到 56rem(约 55 个汉字一行)。 再宽眼睛就要来回扫行了,留白比字长更难读。 */}
返回知识库 / {doc?.category?.full_path ?? "未分类"}
{error && (
{error}
)} {loading && !doc ? (
) : !doc ? (

这条知识不在了,或者你没有权限看它。

) : (
{DOC_TYPE_LABEL[doc.knowledge.doc_type] ?? doc.knowledge.doc_type} {doc.category ? ( {doc.category.full_path} ) : null}

{doc.title}

更新于 {new Date(doc.updated_at).toLocaleString("zh-CN")} {doc.knowledge.published_at ? ( 发布于 {new Date(doc.knowledge.published_at).toLocaleString("zh-CN")} ) : ( 尚未发布,只有工作区里的人能看到 )} {doc.knowledge.chunk_count > 0 ? ( 检索切片 {doc.knowledge.chunk_count} 段 ) : null}

{doc.tags.length > 0 ? (
{doc.tags.map((t) => ( #{t.name} ))}
) : null}
{canEdit ? ( ) : null} {actions .filter((a) => a.show) .map((a) => ( ))} {/* 删除放最后、用弱色 + 垃圾桶:它是不可逆的,不该和"发布"抢注意力 */}
{doc.source ? (
来源:{doc.source.kind === "ticket" ? "工单" : "任务"}「 {doc.source.title} 」
{doc.knowledge.generated_by === "rule" ? (

这份草稿由系统整理,请核对后再发布。

) : null}
) : null} {(doc.attachments?.length ?? 0) > 0 ? (
void load()} onError={(message) => setError(message)} disabled />
) : null}
)}
{editing && doc ? ( setEditing(false)} onChanged={() => void load()} /> ) : null}
); }