feat(knowledge): 分类可改名/配色/删除,选中态与加载态更清楚
CI / backend (push) Canceled after 0s

分类之前只能新增,建错了只能干看着。补上三件事:

- knowledge_category 加 color 列(迁移 0009,CHECK 卡 #RRGGBB),
  PATCH 支持改名与改色,空串表示取消上色;知识列表带出分类色,前端不用再查。
- 分类行悬停出「⋯」:改名、选色板/手填色值、建子分类、删除(有知识时确认后 force)。
- 选中分类改成主色浅底 + 描边 + 色点,列表头显示当前筛选;切分类时列表淡出并转圈,
  不再一动不动等请求回来。

顺带给卡片画分类色上边线:扫一眼就能分出哪几条属于同一类。
This commit is contained in:
2026-11-02 19:24:44 +08:00
parent d7c9670880
commit 71982f9fa4
6 changed files with 632 additions and 32 deletions
+2
View File
@@ -159,6 +159,8 @@ export interface KnowledgeCategory {
name: string;
depth: number;
sort_order: number;
/** 分类色 `#RRGGBB`;没设时前端按 id 稳定地挑一个调色板色,卡片上边线用它 */
color: string | null;
version: number;
/** 从根到自己的名字链,后端拼好了,前端不用自己爬树 */
path: string[];
+460 -32
View File
@@ -4,7 +4,7 @@ import { AppShell } from "../components/AppShell";
import { AttachmentPanel } from "../components/AttachmentPanel";
import { Markdown } from "../components/Markdown";
import { RichTextEditor } from "../components/RichTextEditor";
import { Link2, Paperclip, Pencil, Plus, Search, Sparkle } from "../components/icons";
import { Check, Link2, More, Paperclip, Pencil, Plus, Search, Sparkle, Spinner, Trash } from "../components/icons";
import {
Button,
EmptyState,
@@ -52,6 +52,39 @@ const SOURCE_LABEL: Record<string, string> = {
ticket: "来自工单",
};
/**
* 分类调色板。给的是“彼此能分开”的一组色,不是渐变——分类色的用途是让人
* 在卡片墙上一眼分辨归属,颜色之间越差得开越好用。调色板没有的色可以手填。
*/
const CATEGORY_PALETTE = [
"#4F46E5",
"#0EA5E9",
"#10B981",
"#F59E0B",
"#EF4444",
"#EC4899",
"#8B5CF6",
"#14B8A6",
"#64748B",
"#84CC16",
];
const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/;
/**
* 分类实际用的颜色:设了就用设的,没设就按 id 稳定地挑一个调色板色。
*
* 为什么没设也要给色:老分类(加颜色列之前建的)全是空值,如果空值一律画灰边,
* 刚上的“分类色”看起来就像没生效。按 id 取模是确定性的:同一条分类刷新前后同色,
* 也不会因为新增一条分类而让别的分类换色。
*/
function categoryColor(cat: { id: string; color: string | null }): string {
if (cat.color) return cat.color;
let hash = 0;
for (let i = 0; i < cat.id.length; i += 1) hash = (hash * 31 + cat.id.charCodeAt(i)) % 9973;
return CATEGORY_PALETTE[hash % CATEGORY_PALETTE.length];
}
const ACTION_LABEL: Record<string, string> = {
submit_review: "提交审议",
withdraw: "撤回审议",
@@ -113,13 +146,13 @@ export function KnowledgePage() {
void loadCategories();
}, [loadCategories]);
async function createCategory(name: string, parentId: string | null) {
async function createCategory(name: string, parentId: string | null, color: string | null = null) {
setCatBusy(true);
setError(null);
try {
const created = await api.post<KnowledgeCategoryNode>(
`/workspaces/${wsId}/knowledge/categories`,
{ name, parent_id: parentId },
{ name, parent_id: parentId, color },
);
await loadCategories();
return created;
@@ -131,6 +164,67 @@ export function KnowledgePage() {
}
}
/** 改分类名:只动名字,不带 move,也不碰颜色 */
async function renameCategory(cat: KnowledgeCategoryNode, name: string) {
const value = name.trim();
if (!value || value === cat.name) return;
setCatBusy(true);
setError(null);
try {
await api.patch(`/workspaces/${wsId}/knowledge/categories/${cat.id}`, {
name: value,
version: cat.version,
});
await loadCategories();
} catch (err) {
setError(err instanceof ApiError ? err.message : "分类改名失败");
} finally {
setCatBusy(false);
}
}
/** 改分类色。传空串 = 取消上色(后端把空串落成 NULL) */
async function recolorCategory(cat: KnowledgeCategoryNode, color: string | null) {
setCatBusy(true);
setError(null);
try {
await api.patch(`/workspaces/${wsId}/knowledge/categories/${cat.id}`, {
color: color ?? "",
version: cat.version,
});
await loadCategories();
} catch (err) {
setError(err instanceof ApiError ? err.message : "分类颜色保存失败");
} finally {
setCatBusy(false);
}
}
/**
* 删分类。里面还有知识时后端要求显式 `force`,所以先问一句:
* 删分类不删知识(知识退回未分类),但“突然从分类里消失”仍然值得确认。
* 删完要重拉列表:那些知识已从当前筛选里掉出去了。
*/
async function deleteCategory(cat: KnowledgeCategoryNode) {
const sure = window.confirm(
cat.doc_count > 0
? `「${cat.full_path}」里还有 ${cat.doc_count} 条知识。\n删除分类不会删知识,它们会回到「未分类」。继续?`
: `删除分类「${cat.full_path}」?`,
);
if (!sure) return;
setCatBusy(true);
setError(null);
try {
await api.del(`/workspaces/${wsId}/knowledge/categories/${cat.id}?force=true`);
if (catFilter === cat.id) setCatFilter(null);
await loadCategories();
} catch (err) {
setError(err instanceof ApiError ? err.message : "分类删除失败");
} finally {
setCatBusy(false);
}
}
const load = useCallback(
async (nextCursor: string | null) => {
// 切 tab / 改搜索会连发请求,慢的回来会盖掉新结果
@@ -203,6 +297,7 @@ export function KnowledgePage() {
}
const pendingCount = docs.filter((d) => d.knowledge.status_hint !== "published").length;
const activeCategory = categories.find((c) => c.id === catFilter) ?? null;
return (
<AppShell wsId={wsId} active="knowledge">
@@ -245,12 +340,16 @@ export function KnowledgePage() {
<CategorySidebar
categories={categories}
selected={catFilter}
loading={loading}
onSelect={(value) => {
setCatFilter(value);
setHits(null);
}}
busy={catBusy}
onCreate={(name, parentId) => void createCategory(name, parentId)}
onCreate={(name, parentId, color) => void createCategory(name, parentId, color)}
onRename={(cat, name) => void renameCategory(cat, name)}
onRecolor={(cat, color) => void recolorCategory(cat, color)}
onDelete={(cat) => void deleteCategory(cat)}
/>
<div className="min-w-0">
<div className="mb-4 flex flex-wrap items-center gap-1.5">
@@ -270,7 +369,12 @@ export function KnowledgePage() {
</button>
);
})}
{tab === "draft" && pendingCount > 0 ? (
{loading ? (
<span className="ml-auto inline-flex items-center gap-1.5 text-2xs text-ink-500">
<Spinner size={12} />
载入中…
</span>
) : tab === "draft" && pendingCount > 0 ? (
<span className="ml-auto hidden text-2xs text-ink-400 sm:block">
{pendingCount} 份草稿等你核对
</span>
@@ -283,6 +387,26 @@ export function KnowledgePage() {
</div>
)}
{/* 当前筛选要写在列表头上:光左侧高亮一条,回头就忘了自己在看哪个分类 */}
{hits === null && catFilter ? (
<div className="mb-3 flex flex-wrap items-center gap-2">
{activeCategory ? (
<span
className="chip bg-paper-100 text-ink-700"
style={{ boxShadow: `inset 3px 0 0 ${categoryColor(activeCategory)}` }}
>
{activeCategory.full_path}
</span>
) : (
<span className="chip bg-paper-100 text-ink-700">未分类</span>
)}
<span className="text-2xs text-ink-400">只看这个分类(含子分类)</span>
<button className="link text-2xs" onClick={() => setCatFilter(null)}>
清除筛选
</button>
</div>
) : null}
{hits !== null ? (
<section className="space-y-2">
<div className="flex items-baseline justify-between px-1">
@@ -347,10 +471,21 @@ export function KnowledgePage() {
/>
</div>
) : (
<ul className="grid gap-2 md:grid-cols-2 2xl:grid-cols-3">
<ul
className={`grid gap-2 transition-opacity duration-150 md:grid-cols-2 2xl:grid-cols-3 ${
loading ? "pointer-events-none opacity-50" : ""
}`}
aria-busy={loading}
>
{docs.map((d) => (
<li
key={d.id}
// 分类色画在卡片上边线上:不是装饰,是“扫一眼就知道这类是我要找的”的标记
style={
d.category
? { borderTopWidth: 3, borderTopColor: categoryColor(d.category) }
: undefined
}
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)}>
@@ -370,6 +505,11 @@ export function KnowledgePage() {
) : null}
{d.category ? (
<span className="chip max-w-[16rem] truncate bg-paper-200 text-ink-500">
<span
aria-hidden
className="mr-1 inline-block h-1.5 w-1.5 shrink-0 rounded-full align-middle"
style={{ background: categoryColor(d.category) }}
/>
{d.category.full_path}
</span>
) : null}
@@ -441,23 +581,63 @@ export function KnowledgePage() {
);
}
/** 分类色块:选中的那个带描边和钩,不用猜“现在到底是哪个色” */
function ColorSwatch({
value,
active,
onClick,
title,
}: {
value: string | null;
active: boolean;
onClick: () => void;
title: string;
}) {
return (
<button
type="button"
title={title}
aria-label={title}
aria-pressed={active}
onClick={onClick}
className={`grid h-5 w-5 place-items-center rounded-full ring-1 transition ${
active ? "ring-2 ring-ink-900 ring-offset-1" : "ring-line-300 hover:ring-ink-400"
}`}
style={{ background: value ?? "#FFFFFF" }}
>
{active ? <Check size={11} className={value ? "text-white" : "text-ink-700"} /> : null}
</button>
);
}
/** 左侧分类树:一层一层缩进,点一下就把列表收窄到该分类(含子分类)。 */
function CategorySidebar({
categories,
selected,
loading,
onSelect,
busy,
onCreate,
onRename,
onRecolor,
onDelete,
}: {
categories: KnowledgeCategoryNode[];
selected: string | null;
loading: boolean;
onSelect: (value: string | null) => void;
busy: boolean;
onCreate: (name: string, parentId: string | null) => void;
onCreate: (name: string, parentId: string | null, color: string | null) => void;
onRename: (cat: KnowledgeCategoryNode, name: string) => void;
onRecolor: (cat: KnowledgeCategoryNode, color: string | null) => void;
onDelete: (cat: KnowledgeCategoryNode) => void;
}) {
const [adding, setAdding] = useState(false);
const [name, setName] = useState("");
const [parentId, setParentId] = useState("");
const [newColor, setNewColor] = useState<string | null>(CATEGORY_PALETTE[0]);
/** 哪一行展开了管理面板:改名、改色、新建子分类、删除都在这一块里 */
const [openId, setOpenId] = useState<string | null>(null);
return (
<aside className="card h-fit p-3 lg:sticky lg:top-4">
@@ -475,8 +655,9 @@ function CategorySidebar({
e.preventDefault();
const value = name.trim();
if (!value) return;
onCreate(value, parentId || null);
onCreate(value, parentId || null, newColor);
setName("");
setParentId("");
setAdding(false);
}}
>
@@ -503,6 +684,26 @@ function CategorySidebar({
</option>
))}
</select>
<div>
<p className="mb-1 text-2xs text-ink-400">卡片颜色</p>
<div className="flex flex-wrap items-center gap-1.5">
{CATEGORY_PALETTE.map((c) => (
<ColorSwatch
key={c}
value={c}
active={newColor === c}
onClick={() => setNewColor(c)}
title={`用 ${c}`}
/>
))}
<ColorSwatch
value={null}
active={newColor === null}
onClick={() => setNewColor(null)}
title="先不上色"
/>
</div>
</div>
<Button variant="primary" size="sm" type="submit" disabled={busy || !name.trim()}>
创建
</Button>
@@ -517,15 +718,28 @@ function CategorySidebar({
未分类
</CategoryButton>
{categories.map((c) => (
<CategoryButton
<CategoryRow
key={c.id}
category={c}
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>
loading={loading && selected === c.id}
open={openId === c.id}
busy={busy}
onToggle={() => setOpenId((v) => (v === c.id ? null : c.id))}
onSelect={() => {
setOpenId(null);
onSelect(c.id);
}}
onRename={(value) => onRename(c, value)}
onRecolor={(color) => onRecolor(c, color)}
onDelete={() => onDelete(c)}
onAddChild={() => {
setOpenId(null);
setParentId(c.id);
setName("");
setAdding(true);
}}
/>
))}
</nav>
@@ -538,36 +752,246 @@ function CategorySidebar({
);
}
/** 一行分类:左边是名字(前面圆点是分类色),悬停出现「⋯」展开管理面板。 */
function CategoryRow({
category,
active,
loading,
open,
busy,
onToggle,
onSelect,
onRename,
onRecolor,
onDelete,
onAddChild,
}: {
category: KnowledgeCategoryNode;
active: boolean;
loading: boolean;
open: boolean;
busy: boolean;
onToggle: () => void;
onSelect: () => void;
onRename: (name: string) => void;
onRecolor: (color: string | null) => void;
onDelete: () => void;
onAddChild: () => void;
}) {
const indent = category.depth - 1;
return (
<div
className={`group/cat relative rounded-lg transition-colors ${
active ? "bg-primary-50 ring-1 ring-primary-200" : ""
}`}
>
<div className="flex items-center">
<button
onClick={onSelect}
aria-current={active ? "true" : undefined}
style={{ paddingLeft: `${8 + indent * 14}px` }}
className={`flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg py-1.5 pr-1 text-sm transition-colors ${
active
? "font-medium text-primary-800"
: "text-ink-600 hover:bg-paper-100 hover:text-ink-900"
}`}
>
<span className="flex min-w-0 items-center gap-1.5">
{/* 二级及以下带一个点,光靠缩进在小屏上不够分得清是子分类还是换了一棵树 */}
{indent > 0 ? (
<span aria-hidden className="text-ink-300">
·
</span>
) : null}
<span
aria-hidden
className="h-2 w-2 shrink-0 rounded-full ring-1 ring-black/5"
style={{ background: categoryColor(category) }}
/>
<span className="truncate">{category.name}</span>
</span>
<span className="flex shrink-0 items-center gap-1">
{loading ? <Spinner size={12} className="text-primary-600" /> : null}
{category.doc_count > 0 ? (
<span className={`text-2xs ${active ? "text-primary-500" : "text-ink-400"}`}>
{category.doc_count}
</span>
) : null}
</span>
</button>
<button
type="button"
aria-label={`管理分类「${category.name}」`}
aria-expanded={open}
onClick={onToggle}
className={`mr-1 shrink-0 rounded-md p-1 text-ink-400 transition hover:bg-white hover:text-ink-700 ${
open ? "opacity-100" : "opacity-0 group-hover/cat:opacity-100 focus-visible:opacity-100"
}`}
>
<More size={13} />
</button>
</div>
{open ? (
<CategoryEditor
category={category}
busy={busy}
onRename={onRename}
onRecolor={onRecolor}
onDelete={onDelete}
onAddChild={onAddChild}
/>
) : null}
</div>
);
}
/** 分类管理面板:改名、挑色(或手填)、建子分类、删除。 */
function CategoryEditor({
category,
busy,
onRename,
onRecolor,
onDelete,
onAddChild,
}: {
category: KnowledgeCategoryNode;
busy: boolean;
onRename: (name: string) => void;
onRecolor: (color: string | null) => void;
onDelete: () => void;
onAddChild: () => void;
}) {
const [name, setName] = useState(category.name);
const [hex, setHex] = useState(category.color ?? "");
const [hexError, setHexError] = useState<string | null>(null);
const current = category.color ?? categoryColor(category);
return (
<div className="mb-1 mt-1 space-y-2 rounded-lg border border-line-200 bg-white p-2 shadow-card">
<form
onSubmit={(e) => {
e.preventDefault();
onRename(name);
}}
>
<label className="mb-1 block text-2xs text-ink-400">名称</label>
<div className="flex gap-1">
<input
className="input h-8 min-w-0 flex-1 text-sm"
value={name}
onChange={(e) => setName(e.target.value)}
aria-label="分类名称"
disabled={busy}
/>
<Button
variant="subtle"
size="sm"
type="submit"
disabled={busy || !name.trim() || name.trim() === category.name}
>
保存
</Button>
</div>
</form>
<div>
<p className="mb-1 text-2xs text-ink-400">卡片边线颜色</p>
<div className="flex flex-wrap items-center gap-1.5">
{CATEGORY_PALETTE.map((c) => (
<ColorSwatch
key={c}
value={c}
active={current.toUpperCase() === c}
onClick={() => {
setHex(c);
setHexError(null);
onRecolor(c);
}}
title={`用 ${c}`}
/>
))}
<ColorSwatch
value={null}
active={category.color === null}
onClick={() => {
setHex("");
setHexError(null);
onRecolor(null);
}}
title="不上色(按分类自动挑)"
/>
</div>
<form
className="mt-1.5 flex gap-1"
onSubmit={(e) => {
e.preventDefault();
const value = hex.trim().toUpperCase();
if (value === "") {
onRecolor(null);
return;
}
if (!HEX_COLOR_RE.test(value)) {
setHexError("要 #RRGGBB 这样 6 位十六进制");
return;
}
setHexError(null);
onRecolor(value);
}}
>
<input
className="input h-7 min-w-0 flex-1 font-mono text-xs"
value={hex}
placeholder="#4F46E5"
onChange={(e) => {
setHex(e.target.value);
setHexError(null);
}}
aria-label="自定义分类颜色"
disabled={busy}
/>
<Button variant="subtle" size="sm" type="submit" disabled={busy}>
用这个色
</Button>
</form>
{hexError ? <p className="mt-1 text-2xs text-rust-600">{hexError}</p> : null}
</div>
<div className="flex items-center gap-1 border-t border-line-100 pt-1.5">
{category.depth < 3 ? (
<Button variant="ghost" size="sm" onClick={onAddChild} disabled={busy}>
<Plus size={12} />
子分类
</Button>
) : null}
<Button variant="danger" size="sm" className="ml-auto" onClick={onDelete} disabled={busy}>
<Trash size={12} />
删除
</Button>
</div>
</div>
);
}
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 ${
aria-current={active ? "true" : undefined}
className={`flex w-full items-center justify-between gap-2 rounded-lg py-1.5 pl-2 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"
? "bg-primary-50 font-medium text-primary-800 ring-1 ring-primary-200"
: "text-ink-600 hover:bg-paper-100 hover:text-ink-900"
}`}
>
<span className="flex min-w-0 items-center gap-1">
{/* 二级及以下带一个点,光靠缩进在小屏上不够分得清是子分类还是换了一棵树 */}
{indent > 0 ? (
<span aria-hidden className="text-ink-300">
·
</span>
) : null}
{children}
</span>
<span className="flex min-w-0 items-center gap-1">{children}</span>
</button>
);
}
@@ -584,7 +1008,11 @@ function KnowledgeComposer({
wsId: string;
categories: KnowledgeCategoryNode[];
catBusy: boolean;
onCreateCategory: (name: string, parentId: string | null) => Promise<KnowledgeCategoryNode | null>;
onCreateCategory: (
name: string,
parentId: string | null,
color?: string | null,
) => Promise<KnowledgeCategoryNode | null>;
onClose: () => void;
onCreated: (doc: Knowledge) => void;
}) {
@@ -616,7 +1044,7 @@ function KnowledgeComposer({
async function addCategory() {
const name = newCat.trim();
if (!name || !canAddCategory) return;
const created = await onCreateCategory(name, categoryId || null);
const created = await onCreateCategory(name, categoryId || null, null);
if (created) {
setCategoryId(created.id);
setNewCat("");