CI / backend (push) Canceled after 0s
「记一条」面板里正文是个 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 不兼容,本次未编译验证。
990 lines
33 KiB
TypeScript
990 lines
33 KiB
TypeScript
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>
|
||
);
|
||
}
|