知识以前只能"填个标题就完成",正文、标签、归类都要点进去再补。现在新建 就是一张完整的备忘录:标题、类型、分类、标签、正文一次写完,存完直接打开 详情继续编辑;知识页左侧多了工作区三级分类树,可按分类子树 / 未分类 / 标签 筛选。凡是填信息的抽屉都加宽了,还能一键展开整屏。 后端 - 新增 `knowledge_category`(迁移 0007):`workspace_id NOT NULL` + 自引用 `parent_id`、`depth` 1..3 检查约束、同层重名唯一索引(lower(name))、 RLS 策略与 `dadao_app` 授权;`knowledge_doc.category_id` 外键 ON DELETE SET NULL。 - 新增 `/knowledge/categories` 增删改查:超三级 `7007`、同层重名 `7008`、 跨租户或不存在 `7006`;`move=true` 才改父级,成环与移动后超过三级的子树 一律拒绝,删除有知识的分类要 `force=true`(知识变未分类)。 - 知识列表支持 `category_id`(含子树)、`uncategorized`、`tag`;输出带 `category`(含 `path` / `full_path`)。 - PATCH 只改分类或标签也会推进 `version`,避免"只动标签"之后下次保存撞 乐观锁 `4009`。 Web - `Sheet` 增 `size="md/lg/xl"` 与展开整屏按钮;任务、工单、知识抽屉统一 加宽(`TaskDetail` / `Tickets` / `Knowledge`)。 - 知识页:左侧三级分类树(新建、按子树筛选、未分类)、`KnowledgeComposer` 一次写全新的备忘录、列表卡片露出分类路径与标签、详情抽屉里可直接改分类 与标签。 - 抽屉详情只拉一次:重复的 effect 或迟到的响应不再覆盖用户正在敲的内容。 安卓 - `Api.kt`:分类模型与接口、新建/更新带 `tag_names`/`category_id`、 列表带分类与标签筛选。 - 知识页重做:新建改为近全屏大对话框(标题/类型/分类/标签/正文),列表 顶部按分类筛选,详情里能改分类与标签后单独保存。 文档与测试 - `2.Design/API.md`:5.1/5.3/5.4 补分类与筛选参数,新增 5.5 分类接口章节, 错误码补 `7006`/`7007`/`7008`。 - `2.Design/ROADMAP-M0-M1.md`:新增 3.11(三级分类)、3.12(备忘录形态)。 - 新增 `backend/tests/test_knowledge_categories.py`(8 例):备忘形态落库、 三级到底、移动越界与成环、重名、按子树/未分类/标签/关键词筛选、删分类需 `force`、跨租户隔离。 - 新增 `web/e2e/knowledge-notebook.mjs`(10 项,全绿):建两级分类 → 一次 写全 → 卡片与筛选 → 抽屉改标签/分类 → 正文落库 → 重名 7008。 验证:`pytest` 93 passed、`ruff check` / `ruff format --check` 全过、 `tsc --noEmit` 通过、`vite build` 通过、`knowledge-notebook.mjs` 10 项全绿。 安卓端本机只有 JDK 25,Gradle 8.7 / AGP 8.7.3 不支持,本次未做编译验证。
This commit is contained in:
+471
-68
@@ -3,7 +3,7 @@ import { useParams } from "react-router-dom";
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { AttachmentPanel } from "../components/AttachmentPanel";
|
||||
import { RichTextEditor } from "../components/RichTextEditor";
|
||||
import { Book, Link2, Plus, Search, Sparkle } from "../components/icons";
|
||||
import { Link2, Plus, Search, Sparkle } from "../components/icons";
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
@@ -14,7 +14,13 @@ import {
|
||||
StatusPill,
|
||||
} from "../components/ui";
|
||||
import { ApiError, api } from "../lib/client";
|
||||
import type { Attachment, Knowledge, KnowledgeStatus, SearchHit } from "../lib/types";
|
||||
import type {
|
||||
Attachment,
|
||||
Knowledge,
|
||||
KnowledgeCategoryNode,
|
||||
KnowledgeStatus,
|
||||
SearchHit,
|
||||
} from "../lib/types";
|
||||
|
||||
const TABS = [
|
||||
{ key: "draft", label: "待核对", statuses: ["draft", "in_review"] },
|
||||
@@ -61,12 +67,48 @@ export function KnowledgePage() {
|
||||
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 [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 / 改搜索会连发请求,慢的回来会盖掉新结果
|
||||
@@ -78,6 +120,8 @@ export function KnowledgePage() {
|
||||
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[];
|
||||
@@ -94,7 +138,7 @@ export function KnowledgePage() {
|
||||
if (mine === reqId.current) setLoading(false);
|
||||
}
|
||||
},
|
||||
[wsId, tab, q],
|
||||
[wsId, tab, q, catFilter],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -127,22 +171,13 @@ export function KnowledgePage() {
|
||||
}
|
||||
}
|
||||
|
||||
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 : "保存失败");
|
||||
}
|
||||
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;
|
||||
@@ -176,7 +211,7 @@ export function KnowledgePage() {
|
||||
<Search size={14} />
|
||||
{searching ? "检索中…" : "检索"}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => setAdding((v) => !v)}>
|
||||
<Button variant="primary" onClick={() => setComposing(true)}>
|
||||
<Plus size={14} />
|
||||
记一条
|
||||
</Button>
|
||||
@@ -184,6 +219,18 @@ export function KnowledgePage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<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;
|
||||
@@ -208,34 +255,6 @@ export function KnowledgePage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{adding && (
|
||||
<div className="card mb-4 p-4">
|
||||
<div className="flex items-start gap-2 text-xs text-ink-500">
|
||||
<span className="mt-0.5 text-clay-500">
|
||||
<Book size={14} />
|
||||
</span>
|
||||
<p className="leading-relaxed">
|
||||
手记的知识没有来源,它是你自己的笔记;想沉淀某件事,去那件事的详情里点「沉淀成知识」。
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
placeholder="一句话标题,比如「报销单要贴发票原件」"
|
||||
value={draftTitle}
|
||||
autoFocus
|
||||
onChange={(e) => setDraftTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void addNote();
|
||||
}}
|
||||
/>
|
||||
<Button variant="primary" disabled={!draftTitle.trim()} onClick={() => void addNote()}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4">
|
||||
<InlineAlert>{error}</InlineAlert>
|
||||
@@ -327,6 +346,11 @@ export function KnowledgePage() {
|
||||
自动整理
|
||||
</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}
|
||||
@@ -339,6 +363,16 @@ export function KnowledgePage() {
|
||||
{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)}>
|
||||
打开
|
||||
@@ -355,28 +389,353 @@ export function KnowledgePage() {
|
||||
</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)}
|
||||
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>
|
||||
|
||||
<label className="block">
|
||||
<span className="label">正文</span>
|
||||
<textarea
|
||||
className="input min-h-64 resize-y font-mono text-sm leading-relaxed"
|
||||
rows={14}
|
||||
placeholder={"可以直接写 Markdown:\n\n## 步骤\n1. 停灰度\n2. 回滚镜像\n3. 验证关键接口"}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
aria-label="正文"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <InlineAlert>{error}</InlineAlert>}
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** 知识详情抽屉:能改、能提交审议、能发布/归档。 */
|
||||
function KnowledgeDrawer({
|
||||
wsId,
|
||||
itemId,
|
||||
categories,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
wsId: string;
|
||||
itemId: string;
|
||||
categories: KnowledgeCategoryNode[];
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
@@ -384,6 +743,8 @@ function KnowledgeDrawer({
|
||||
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(() => {
|
||||
@@ -394,18 +755,27 @@ function KnowledgeDrawer({
|
||||
return map;
|
||||
}, [attachments]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
/** 已经载入完成的是哪一条知识。重复的 effect / 慢响应不该再覆盖用户正在敲的内容。 */
|
||||
const loadedFor = useRef<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`);
|
||||
setDoc(data);
|
||||
setTitle(data.title);
|
||||
setBody(data.body_md);
|
||||
setAttachments(data.attachments ?? []);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "加载失败");
|
||||
}
|
||||
}, [wsId, itemId]);
|
||||
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();
|
||||
@@ -419,6 +789,8 @@ function KnowledgeDrawer({
|
||||
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);
|
||||
@@ -426,7 +798,7 @@ function KnowledgeDrawer({
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.code === 4009) {
|
||||
setError("刚被别人改过,已刷新为最新内容");
|
||||
await load();
|
||||
await load(true);
|
||||
} else {
|
||||
setError(err instanceof ApiError ? err.message : "保存失败");
|
||||
}
|
||||
@@ -448,7 +820,7 @@ function KnowledgeDrawer({
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "操作失败");
|
||||
await load();
|
||||
await load(true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -472,13 +844,13 @@ function KnowledgeDrawer({
|
||||
<Sheet
|
||||
open
|
||||
onClose={onClose}
|
||||
width="max-w-2xl"
|
||||
size="xl"
|
||||
title={doc ? doc.title : "知识"}
|
||||
subtitle={
|
||||
doc
|
||||
? `${DOC_TYPE_LABEL[doc.knowledge.doc_type] ?? doc.knowledge.doc_type} · ${
|
||||
STATUS_LABEL[doc.knowledge.status_hint]
|
||||
} · 版本 ${doc.version}`
|
||||
? [doc.category?.full_path, DOC_TYPE_LABEL[doc.knowledge.doc_type] ?? doc.knowledge.doc_type]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: "加载中…"
|
||||
}
|
||||
footer={
|
||||
@@ -543,6 +915,37 @@ function KnowledgeDrawer({
|
||||
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="正文"
|
||||
|
||||
Reference in New Issue
Block a user