diff --git a/backend/alembic/versions/0009_knowledge_category_color.py b/backend/alembic/versions/0009_knowledge_category_color.py new file mode 100644 index 0000000..56b1c17 --- /dev/null +++ b/backend/alembic/versions/0009_knowledge_category_color.py @@ -0,0 +1,48 @@ +"""知识分类配色(M3 补充) + +Revision ID: 0009 +Revises: 0008 +Create Date: 2026-11-02 + +分类从"只能分级"变成"还能上色"。一列而已,但解决了卡片墙上的一个真问题: +三十条知识平铺在一起,光看标题要逐条读才知道哪条属于部署、哪条属于业务。 +分类色让"归属"在扫视时就成立,不用读字。 + +三个取舍: + +1. **颜色存分类上,不存知识上**。同一类的颜色必须一致,否则卡片墙会花成 + 一盘散沙;存在分类上,改一次全类生效。 +2. **允许为空**。老数据(0007 建的分类)没有颜色,强行补一个默认值等于替用户 + 做决定;为空时前端按分类 id 稳定地挑一个调色板色,看起来仍是"每类一色"。 +3. **CHECK 约束卡格式**。只接受 `#RRGGBB`,大写。前端传 `rgb(...)` 或带 alpha + 的值会被挡在数据库外——渲染层不会因为一个脏值悄悄丢样式。 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0009" +down_revision: str = "0008" +branch_labels: Sequence[str] | None = None +depends_on: Sequence[str] | None = None + +UPGRADE_SQL = ( + "ALTER TABLE knowledge_category ADD COLUMN IF NOT EXISTS color varchar(7)", + "ALTER TABLE knowledge_category DROP CONSTRAINT IF EXISTS ck_kcat_color", + "ALTER TABLE knowledge_category ADD CONSTRAINT ck_kcat_color " + "CHECK (color IS NULL OR color ~ '^#[0-9A-F]{6}$')", + "COMMENT ON COLUMN knowledge_category.color IS '分类色 #RRGGBB,卡片上边线用它'", +) + + +def upgrade() -> None: + for statement in UPGRADE_SQL: + op.execute(statement) + + +def downgrade() -> None: + op.execute("ALTER TABLE knowledge_category DROP CONSTRAINT IF EXISTS ck_kcat_color") + op.execute("ALTER TABLE knowledge_category DROP COLUMN IF EXISTS color") diff --git a/backend/app/api/knowledge.py b/backend/app/api/knowledge.py index c035ce5..202d20e 100644 --- a/backend/app/api/knowledge.py +++ b/backend/app/api/knowledge.py @@ -15,6 +15,7 @@ from __future__ import annotations +import re import uuid from datetime import datetime, timezone from typing import Annotated, Any @@ -73,6 +74,8 @@ class CategoryIn(BaseModel): name: str = Field(min_length=1, max_length=40) parent_id: uuid.UUID | None = None sort_order: int = 0 + # 分类色,卡片上边线用它。不传就不上色(前端按 id 稳定地挑一个默认色)。 + color: str | None = Field(default=None, max_length=7) class CategoryPatch(BaseModel): @@ -80,6 +83,8 @@ class CategoryPatch(BaseModel): parent_id: uuid.UUID | None = None move: bool = False sort_order: int | None = None + # 不传 = 不改;传空串 = 清成默认色(分类色是“可选项”,得能取消)。 + color: str | None = Field(default=None, max_length=7) version: int @@ -147,6 +152,29 @@ def _doc_out(doc: KnowledgeDoc) -> dict[str, Any]: # ── 分类 ──────────────────────────────────────────────────────────────── +_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$") + + +def _norm_color(value: str | None) -> str | None: + """分类色统一成 ``#RRGGBB`` 大写;空串表示“不要颜色”。 + + 不在这里做“看得见就好”的宽松处理:颜色是直接拼进 CSS 的,宁可当场报错, + 也不要让一个 ``red`` 或带 alpha 的值进了库、在前端静默变成透明边框。 + """ + if value is None: + return None + raw = value.strip() + if raw == "": + return None + if not _COLOR_RE.match(raw): + raise AppError( + ErrorCode.VALIDATION_FAILED, + "分类颜色要用 #RRGGBB 格式,例如 #4F46E5", + details={"value": value}, + ) + return raw.upper() + + async def _categories( session: AsyncSession, ws_id: uuid.UUID ) -> dict[uuid.UUID, KnowledgeCategory]: @@ -175,6 +203,7 @@ def _category_payload( "name": cat.name, "depth": cat.depth, "sort_order": cat.sort_order, + "color": cat.color, "version": cat.version, "path": names, "full_path": " / ".join(names), @@ -412,6 +441,7 @@ async def create_category(body: CategoryIn, ctx: MemberDep, session: SessionDep) name=name, depth=depth, sort_order=body.sort_order, + color=_norm_color(body.color), ) session.add(cat) await session.flush() @@ -472,6 +502,9 @@ async def patch_category( values.update(parent_id=new_parent_id, depth=new_depth) if body.sort_order is not None: values["sort_order"] = body.sort_order + # 只传了 color 才算改色;没传保持原样,传空串等于取消上色。 + if body.color is not None: + values["color"] = _norm_color(body.color) result = await session.execute( update(KnowledgeCategory) diff --git a/backend/app/models_knowledge.py b/backend/app/models_knowledge.py index 3771ef9..5a68964 100644 --- a/backend/app/models_knowledge.py +++ b/backend/app/models_knowledge.py @@ -48,6 +48,10 @@ class KnowledgeCategory(Base, Timestamps): `depth` 是冗余列,但值得:每次筛选、移动子树都要判断层级,存下来就不用 递归数祖先。写入时由 API 计算,读到不一致的行属于数据事故。 + + `color` 是给人看的分类色(`#RRGGBB`)。它不参与任何判断,只负责让 + "这条知识属于哪一类"在卡片墙上一眼分得出来——所以允许为空(前端给默认色), + 但一旦有值就必须是合法十六进制,否则渲染会静默丢样式。 """ __tablename__ = "knowledge_category" @@ -55,6 +59,7 @@ class KnowledgeCategory(Base, Timestamps): Index("ix_kcat_ws_parent", "workspace_id", "parent_id"), CheckConstraint("depth BETWEEN 1 AND 3", name="ck_kcat_depth"), CheckConstraint("length(btrim(name)) > 0", name="ck_kcat_name"), + CheckConstraint("color IS NULL OR color ~ '^#[0-9A-F]{6}$'", name="ck_kcat_color"), {"comment": "知识分类树,深不过三"}, ) @@ -68,6 +73,7 @@ class KnowledgeCategory(Base, Timestamps): name: Mapped[str] = mapped_column(String(40), nullable=False) depth: Mapped[int] = mapped_column(nullable=False, default=1) sort_order: Mapped[int] = mapped_column(nullable=False, default=0) + color: Mapped[str | None] = mapped_column(String(7)) class KnowledgeDoc(Base, Timestamps): diff --git a/backend/tests/test_knowledge_categories.py b/backend/tests/test_knowledge_categories.py index 38c096b..f45e24c 100644 --- a/backend/tests/test_knowledge_categories.py +++ b/backend/tests/test_knowledge_categories.py @@ -329,3 +329,86 @@ async def test_categories_come_back_as_a_tree(client: AsyncClient) -> None: "技术开发 / 技术 / 部署", "餐饮业务", ] + + +@pytest.mark.asyncio +async def test_category_color_roundtrip(client: AsyncClient) -> None: + """分类色:建时能给、能改、能取消,非法值当场拒绝。 + + 颜色是直接拼进 CSS 的(卡片上边线),所以格式必须在入口挡住: + 一个 `red` 或 `#4f46e5cc` 进了库,前端只会静默退化成灰边框,没人知道为什么。 + """ + user, ws_id = await _ws(client) + base = f"{V1}/workspaces/{ws_id}/knowledge/categories" + + created = unwrap( + await client.post(json={"name": "部署", "color": "#4f46e5"}, url=base, headers=user["headers"]) + ) + # 统一成大写,前端不用再 casefold + assert created["color"] == "#4F46E5" + + listed = unwrap(await client.get(base, headers=user["headers"]))["items"] + assert [c["color"] for c in listed] == ["#4F46E5"] + + recolored = unwrap( + await client.patch( + f"{base}/{created['id']}", + json={"color": "#0EA5E9", "version": created["version"]}, + headers=user["headers"], + ) + ) + assert recolored["color"] == "#0EA5E9" + assert recolored["name"] == "部署" # 只改色不动名字 + + cleared = unwrap( + await client.patch( + f"{base}/{created['id']}", + json={"color": "", "version": recolored["version"]}, + headers=user["headers"], + ) + ) + assert cleared["color"] is None + + # 不传 color 的普通改名不该顺手把颜色清掉 + colored = unwrap( + await client.patch( + f"{base}/{created['id']}", + json={"color": "#10B981", "version": cleared["version"]}, + headers=user["headers"], + ) + ) + renamed = unwrap( + await client.patch( + f"{base}/{created['id']}", + json={"name": "上线", "version": colored["version"]}, + headers=user["headers"], + ) + ) + assert renamed["name"] == "上线" + assert renamed["color"] == "#10B981" + + bad = await client.post(json={"name": "业务", "color": "red"}, url=base, headers=user["headers"]) + assert bad.status_code == 400, bad.text + assert _code(bad) == 1002 + + too_long = await client.post( + json={"name": "业务2", "color": "#4f46e5cc"}, url=base, headers=user["headers"] + ) + assert too_long.status_code == 400, too_long.text + + +@pytest.mark.asyncio +async def test_knowledge_card_carries_category_color(client: AsyncClient) -> None: + """知识列表里带上分类(含颜色),前端画卡片上边线不用再查一次。""" + user, ws_id = await _ws(client) + cat = unwrap( + await client.post( + f"{V1}/workspaces/{ws_id}/knowledge/categories", + json={"name": "复盘", "color": "#EF4444"}, + headers=user["headers"], + ) + ) + await _note(client, user, ws_id, "一次事故复盘", category_id=cat["id"]) + + page = unwrap(await client.get(f"{V1}/workspaces/{ws_id}/knowledge", headers=user["headers"])) + assert page["items"][0]["category"]["color"] == "#EF4444" diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 4e13ab6..0bbaab6 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -159,6 +159,8 @@ export interface KnowledgeCategory { name: string; depth: number; sort_order: number; + /** 分类色 `#RRGGBB`;没设时前端按 id 稳定地挑一个调色板色,卡片上边线用它 */ + color: string | null; version: number; /** 从根到自己的名字链,后端拼好了,前端不用自己爬树 */ path: string[]; diff --git a/web/src/pages/Knowledge.tsx b/web/src/pages/Knowledge.tsx index 1716016..598682e 100644 --- a/web/src/pages/Knowledge.tsx +++ b/web/src/pages/Knowledge.tsx @@ -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 = { 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 = { 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( `/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 ( @@ -245,12 +340,16 @@ export function KnowledgePage() { { 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)} />
@@ -270,7 +369,12 @@ export function KnowledgePage() { ); })} - {tab === "draft" && pendingCount > 0 ? ( + {loading ? ( + + + 载入中… + + ) : tab === "draft" && pendingCount > 0 ? ( {pendingCount} 份草稿等你核对 @@ -283,6 +387,26 @@ export function KnowledgePage() {
)} + {/* 当前筛选要写在列表头上:光左侧高亮一条,回头就忘了自己在看哪个分类 */} + {hits === null && catFilter ? ( +
+ {activeCategory ? ( + + {activeCategory.full_path} + + ) : ( + 未分类 + )} + 只看这个分类(含子分类) + +
+ ) : null} + {hits !== null ? (
@@ -347,10 +471,21 @@ export function KnowledgePage() { />
) : ( -
    +
      {docs.map((d) => (
    • + ); +} + /** 左侧分类树:一层一层缩进,点一下就把列表收窄到该分类(含子分类)。 */ 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(CATEGORY_PALETTE[0]); + /** 哪一行展开了管理面板:改名、改色、新建子分类、删除都在这一块里 */ + const [openId, setOpenId] = useState(null); return (