Files
DadaoTask/web/src/pages/Knowledge.tsx
T
Steven 9a382e4e8c
CI / backend (push) Canceled after 0s
fix(knowledge): 新建知识的正文换成富文本编辑器,不再是纯文本框
「记一条」面板里正文是个 textarea,排版、表格、贴图都没有——创建时反而比
详情页更寒酸。现在新建面板直接用详情同款 Vditor 编辑器,创建与修改一个模样。

- `RichTextEditor` 的 `itemId` 改为可选:新建时还没有条目 id,正文照写,
  只是上传按钮不摆出来;真去贴图会提示「先保存这条内容,再回来贴图」。
- `KnowledgeComposer` 的正文换用 `RichTextEditor`(minHeight 320),页脚提示
  按有没有条目 id 给不同文案。
- 安卓:新建对话框不再放纯文本正文框,保存后直接进 `RichTextEditorScreen`
  写正文(排版、表格、贴图都在那儿),写完回到列表并展开详情。
- e2e 增加一条断言:新建面板里的正文就是富文本编辑器(aria-label=正文 的
  contenteditable),正文按真实键盘输入后 Markdown 正常落库。

验证:`tsc --noEmit` 通过、`vite build` 通过、`knowledge-notebook.mjs` 11 项
全绿(新增「新建面板的正文是富文本编辑器」)。安卓端本机仍是 JDK 25 +
Gradle 8.7/AGP 8.7.3 不兼容,本次未编译验证。
2026-10-30 15:52:09 +08:00

990 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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 [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 / 改搜索会连发请求,慢的回来会盖掉新结果
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 (
<AppShell wsId={wsId} active="knowledge">
<PageHeader
title="知识"
desc="工单验收通过会自动整理一份草稿;核对无误再发布,发布后才会进入团队检索。"
eyebrow={<>沉淀下来的东西,半年后还认得出来吗</>}
actions={
<>
<div className="relative">
<span className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-400">
<Search size={14} />
</span>
<input
className="input h-9 w-48 pl-8 text-sm"
placeholder="搜标题或正文…(回车检索)"
value={q}
onChange={(e) => {
setQ(e.target.value);
if (!e.target.value.trim()) setHits(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") void runSearch();
}}
/>
</div>
<Button variant="ghost" disabled={searching || !q.trim()} onClick={() => void runSearch()}>
<Search size={14} />
{searching ? "检索中…" : "检索"}
</Button>
<Button variant="primary" onClick={() => setComposing(true)}>
<Plus size={14} />
记一条
</Button>
</>
}
/>
<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;
return (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`rounded-lg px-2.5 py-1.5 text-sm font-medium transition-colors ${
active
? "bg-white text-ink-900 shadow-sm ring-1 ring-line-200"
: "text-ink-500 hover:bg-white/60 hover:text-ink-800"
}`}
>
{t.label}
</button>
);
})}
{tab === "draft" && pendingCount > 0 ? (
<span className="ml-auto hidden text-2xs text-ink-400 sm:block">
{pendingCount} 份草稿等你核对
</span>
) : null}
</div>
{error && (
<div className="mb-4">
<InlineAlert>{error}</InlineAlert>
</div>
)}
{hits !== null ? (
<section className="space-y-2">
<div className="flex items-baseline justify-between px-1">
<p className="text-xs text-ink-500">
在已发布的知识里检索「{q.trim()}」,命中 {hits.length} 段
</p>
<button className="link text-2xs" onClick={() => setHits(null)}>
退出检索
</button>
</div>
{hits.length === 0 ? (
<div className="card">
<EmptyState
icon={<Search size={18} />}
title="没有检索到相关内容"
hint="只检索已发布的知识。换成更具体的说法,或者先把手头的草稿核对发布。"
/>
</div>
) : (
hits.map((hit) => (
<article key={`${hit.item_id}-${hit.idx}`} className="card p-4">
<div className="flex flex-wrap items-center gap-1.5">
<span className="chip bg-primary-50 text-primary-700">
{DOC_TYPE_LABEL[hit.doc_type] ?? hit.doc_type}
</span>
{hit.heading ? (
<span className="chip bg-paper-200 text-ink-600">{hit.heading}</span>
) : null}
<span className="ml-auto text-2xs text-ink-400">
相关度 {hit.score.toFixed(2)}
</span>
</div>
<p className="mt-2 text-sm font-medium text-ink-900">{hit.title}</p>
<p className="mt-1.5 line-clamp-3 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
{hit.content}
</p>
<div className="mt-2 text-right">
<Button variant="ghost" size="sm" onClick={() => setOpenId(hit.item_id)}>
打开原文
</Button>
</div>
</article>
))
)}
</section>
) : loading && docs.length === 0 ? (
<div className="card overflow-hidden">
<SkeletonRows rows={3} />
</div>
) : docs.length === 0 ? (
<div className="card">
<EmptyState
icon={<Sparkle size={18} />}
title={
tab === "draft" ? "还没有待核对的知识" : "这里还空着"
}
hint={
tab === "draft"
? "把一张工单验收通过,系统会自动整理一份草稿;或者在这里「记一条」。"
: "换个筛选条件看看,或者先记一条。"
}
/>
</div>
) : (
<ul className="grid gap-2 md:grid-cols-2 2xl:grid-cols-3">
{docs.map((d) => (
<li
key={d.id}
className="card group flex items-start justify-between gap-3 p-4 transition hover:border-line-300 hover:shadow-pop"
>
<button className="min-w-0 flex-1 text-left" onClick={() => setOpenId(d.id)}>
<div className="flex items-center gap-1.5">
<span className="chip bg-primary-50 text-primary-700">
{DOC_TYPE_LABEL[d.knowledge.doc_type] ?? d.knowledge.doc_type}
</span>
<StatusPill
status={d.knowledge.status_hint}
label={STATUS_LABEL[d.knowledge.status_hint]}
/>
{d.knowledge.generated_by === "rule" ? (
<span className="pill bg-clay-50 text-clay-600">
<Sparkle size={10} />
自动整理
</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}
</p>
<p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-2xs text-ink-400">
<span className="inline-flex items-center gap-1">
<Link2 size={11} />
{SOURCE_LABEL[d.knowledge.source_kind] ?? d.knowledge.source_kind}
</span>
{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)}>
打开
</Button>
</li>
))}
</ul>
)}
{cursor && (
<div className="mt-4 text-center">
<Button variant="ghost" disabled={loading} onClick={() => void load(cursor)}>
{loading ? "加载中…" : "加载更多"}
</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);
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>
<div>
<span className="label">正文</span>
{/* 和详情抽屉同一个富文本编辑器:新建时就该能排版、写表格,而不是先存一个纯文本框 */}
<div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2">
<RichTextEditor
ariaLabel="正文"
wsId={wsId}
value={body}
onChange={setBody}
minHeight={320}
onError={setError}
placeholder={"写下要点、步骤、结论…\n\n标题、类型、分类、标签都在上面,正文就是这张备忘的主体。"}
/>
</div>
</div>
{error && <InlineAlert>{error}</InlineAlert>}
</div>
</Sheet>
);
}
/** 知识详情抽屉:能改、能提交审议、能发布/归档。 */
function KnowledgeDrawer({
wsId,
itemId,
categories,
onClose,
onChanged,
}: {
wsId: string;
itemId: string;
categories: KnowledgeCategoryNode[];
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 [tags, setTags] = useState("");
const [categoryId, setCategoryId] = useState("");
const [attachments, setAttachments] = useState<Attachment[]>([]);
/** 附件 id → 带签名的直链(见 lib/attachmentUrls.ts) */
const resources = useMemo(() => {
const map: Record<string, string> = {};
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<string | null>(null);
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();
}, [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,
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<Knowledge>(`/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 (
<Sheet
open
onClose={onClose}
size="xl"
title={doc ? doc.title : "知识"}
subtitle={
doc
? [doc.category?.full_path, DOC_TYPE_LABEL[doc.knowledge.doc_type] ?? doc.knowledge.doc_type]
.filter(Boolean)
.join(" · ")
: "加载中…"
}
footer={
doc ? (
<div className="flex flex-wrap items-center gap-2">
{canEdit ? (
<Button variant="primary" disabled={busy} onClick={() => void save()}>
{busy ? "保存中…" : "保存"}
</Button>
) : null}
{actions
.filter((a) => a.show)
.map((a) => (
<Button
key={a.key}
variant={a.key === "publish" ? "clay" : "ghost"}
disabled={busy}
onClick={() => void act(a.key)}
>
{a.label}
</Button>
))}
<span className="ml-auto text-2xs text-ink-400">
{doc.knowledge.published_at
? `发布于 ${new Date(doc.knowledge.published_at).toLocaleString("zh-CN")}`
: "尚未发布,只有你能看到"}
</span>
</div>
) : null
}
>
{!doc ? (
<p className="text-sm text-ink-400">{error ?? "加载中…"}</p>
) : (
<div className="space-y-4">
<div className="rounded-xl border border-line-200 bg-paper-50 p-3.5 text-xs leading-relaxed text-ink-600">
<div className="flex items-center gap-1.5">
<Link2 size={12} className="text-ink-400" />
{doc.source ? (
<span>
来源:{doc.source.kind === "ticket" ? "工单" : "任务"}「{doc.source.title}」
</span>
) : (
<span>来源:手记(没有关联的事项)</span>
)}
</div>
<p className="mt-1 text-ink-400">
{doc.knowledge.generated_by === "rule"
? "这份草稿由系统整理,请核对后再发布。"
: "手动创建。"}
{doc.knowledge.published_at
? ` 发布于 ${new Date(doc.knowledge.published_at).toLocaleString("zh-CN")}。`
: ""}
</p>
</div>
<input
className="input text-base font-medium"
value={title}
disabled={!canEdit}
onChange={(e) => setTitle(e.target.value)}
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="正文"
wsId={wsId}
itemId={doc.id}
value={body}
onChange={setBody}
resources={resources}
onUploaded={(created) =>
setAttachments((prev) =>
prev.some((row) => row.id === created.id)
? prev
: [...prev, created as unknown as Attachment],
)
}
onError={(message) => setError(message)}
disabled={!canEdit}
/>
</div>
<div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2.5">
<AttachmentPanel
wsId={wsId}
itemId={doc.id}
attachments={attachments}
onChange={setAttachments}
onError={(message) => setError(message)}
disabled={!canEdit}
/>
</div>
{error && <InlineAlert>{error}</InlineAlert>}
</div>
)}
</Sheet>
);
}