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
@@ -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")
+33
View File
@@ -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)
+6
View File
@@ -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):
@@ -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"