分类之前只能新增,建错了只能干看着。补上三件事: - knowledge_category 加 color 列(迁移 0009,CHECK 卡 #RRGGBB), PATCH 支持改名与改色,空串表示取消上色;知识列表带出分类色,前端不用再查。 - 分类行悬停出「⋯」:改名、选色板/手填色值、建子分类、删除(有知识时确认后 force)。 - 选中分类改成主色浅底 + 描边 + 色点,列表头显示当前筛选;切分类时列表淡出并转圈, 不再一动不动等请求回来。 顺带给卡片画分类色上边线:扫一眼就能分出哪几条属于同一类。
This commit is contained in:
@@ -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")
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
@@ -73,6 +74,8 @@ class CategoryIn(BaseModel):
|
|||||||
name: str = Field(min_length=1, max_length=40)
|
name: str = Field(min_length=1, max_length=40)
|
||||||
parent_id: uuid.UUID | None = None
|
parent_id: uuid.UUID | None = None
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
|
# 分类色,卡片上边线用它。不传就不上色(前端按 id 稳定地挑一个默认色)。
|
||||||
|
color: str | None = Field(default=None, max_length=7)
|
||||||
|
|
||||||
|
|
||||||
class CategoryPatch(BaseModel):
|
class CategoryPatch(BaseModel):
|
||||||
@@ -80,6 +83,8 @@ class CategoryPatch(BaseModel):
|
|||||||
parent_id: uuid.UUID | None = None
|
parent_id: uuid.UUID | None = None
|
||||||
move: bool = False
|
move: bool = False
|
||||||
sort_order: int | None = None
|
sort_order: int | None = None
|
||||||
|
# 不传 = 不改;传空串 = 清成默认色(分类色是“可选项”,得能取消)。
|
||||||
|
color: str | None = Field(default=None, max_length=7)
|
||||||
version: int
|
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(
|
async def _categories(
|
||||||
session: AsyncSession, ws_id: uuid.UUID
|
session: AsyncSession, ws_id: uuid.UUID
|
||||||
) -> dict[uuid.UUID, KnowledgeCategory]:
|
) -> dict[uuid.UUID, KnowledgeCategory]:
|
||||||
@@ -175,6 +203,7 @@ def _category_payload(
|
|||||||
"name": cat.name,
|
"name": cat.name,
|
||||||
"depth": cat.depth,
|
"depth": cat.depth,
|
||||||
"sort_order": cat.sort_order,
|
"sort_order": cat.sort_order,
|
||||||
|
"color": cat.color,
|
||||||
"version": cat.version,
|
"version": cat.version,
|
||||||
"path": names,
|
"path": names,
|
||||||
"full_path": " / ".join(names),
|
"full_path": " / ".join(names),
|
||||||
@@ -412,6 +441,7 @@ async def create_category(body: CategoryIn, ctx: MemberDep, session: SessionDep)
|
|||||||
name=name,
|
name=name,
|
||||||
depth=depth,
|
depth=depth,
|
||||||
sort_order=body.sort_order,
|
sort_order=body.sort_order,
|
||||||
|
color=_norm_color(body.color),
|
||||||
)
|
)
|
||||||
session.add(cat)
|
session.add(cat)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
@@ -472,6 +502,9 @@ async def patch_category(
|
|||||||
values.update(parent_id=new_parent_id, depth=new_depth)
|
values.update(parent_id=new_parent_id, depth=new_depth)
|
||||||
if body.sort_order is not None:
|
if body.sort_order is not None:
|
||||||
values["sort_order"] = body.sort_order
|
values["sort_order"] = body.sort_order
|
||||||
|
# 只传了 color 才算改色;没传保持原样,传空串等于取消上色。
|
||||||
|
if body.color is not None:
|
||||||
|
values["color"] = _norm_color(body.color)
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
update(KnowledgeCategory)
|
update(KnowledgeCategory)
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ class KnowledgeCategory(Base, Timestamps):
|
|||||||
|
|
||||||
`depth` 是冗余列,但值得:每次筛选、移动子树都要判断层级,存下来就不用
|
`depth` 是冗余列,但值得:每次筛选、移动子树都要判断层级,存下来就不用
|
||||||
递归数祖先。写入时由 API 计算,读到不一致的行属于数据事故。
|
递归数祖先。写入时由 API 计算,读到不一致的行属于数据事故。
|
||||||
|
|
||||||
|
`color` 是给人看的分类色(`#RRGGBB`)。它不参与任何判断,只负责让
|
||||||
|
"这条知识属于哪一类"在卡片墙上一眼分得出来——所以允许为空(前端给默认色),
|
||||||
|
但一旦有值就必须是合法十六进制,否则渲染会静默丢样式。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "knowledge_category"
|
__tablename__ = "knowledge_category"
|
||||||
@@ -55,6 +59,7 @@ class KnowledgeCategory(Base, Timestamps):
|
|||||||
Index("ix_kcat_ws_parent", "workspace_id", "parent_id"),
|
Index("ix_kcat_ws_parent", "workspace_id", "parent_id"),
|
||||||
CheckConstraint("depth BETWEEN 1 AND 3", name="ck_kcat_depth"),
|
CheckConstraint("depth BETWEEN 1 AND 3", name="ck_kcat_depth"),
|
||||||
CheckConstraint("length(btrim(name)) > 0", name="ck_kcat_name"),
|
CheckConstraint("length(btrim(name)) > 0", name="ck_kcat_name"),
|
||||||
|
CheckConstraint("color IS NULL OR color ~ '^#[0-9A-F]{6}$'", name="ck_kcat_color"),
|
||||||
{"comment": "知识分类树,深不过三"},
|
{"comment": "知识分类树,深不过三"},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,6 +73,7 @@ class KnowledgeCategory(Base, Timestamps):
|
|||||||
name: Mapped[str] = mapped_column(String(40), nullable=False)
|
name: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||||
depth: Mapped[int] = mapped_column(nullable=False, default=1)
|
depth: Mapped[int] = mapped_column(nullable=False, default=1)
|
||||||
sort_order: Mapped[int] = mapped_column(nullable=False, default=0)
|
sort_order: Mapped[int] = mapped_column(nullable=False, default=0)
|
||||||
|
color: Mapped[str | None] = mapped_column(String(7))
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeDoc(Base, Timestamps):
|
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"
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ export interface KnowledgeCategory {
|
|||||||
name: string;
|
name: string;
|
||||||
depth: number;
|
depth: number;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
|
/** 分类色 `#RRGGBB`;没设时前端按 id 稳定地挑一个调色板色,卡片上边线用它 */
|
||||||
|
color: string | null;
|
||||||
version: number;
|
version: number;
|
||||||
/** 从根到自己的名字链,后端拼好了,前端不用自己爬树 */
|
/** 从根到自己的名字链,后端拼好了,前端不用自己爬树 */
|
||||||
path: string[];
|
path: string[];
|
||||||
|
|||||||
+460
-32
@@ -4,7 +4,7 @@ import { AppShell } from "../components/AppShell";
|
|||||||
import { AttachmentPanel } from "../components/AttachmentPanel";
|
import { AttachmentPanel } from "../components/AttachmentPanel";
|
||||||
import { Markdown } from "../components/Markdown";
|
import { Markdown } from "../components/Markdown";
|
||||||
import { RichTextEditor } from "../components/RichTextEditor";
|
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 {
|
import {
|
||||||
Button,
|
Button,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
@@ -52,6 +52,39 @@ const SOURCE_LABEL: Record<string, string> = {
|
|||||||
ticket: "来自工单",
|
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> = {
|
const ACTION_LABEL: Record<string, string> = {
|
||||||
submit_review: "提交审议",
|
submit_review: "提交审议",
|
||||||
withdraw: "撤回审议",
|
withdraw: "撤回审议",
|
||||||
@@ -113,13 +146,13 @@ export function KnowledgePage() {
|
|||||||
void loadCategories();
|
void loadCategories();
|
||||||
}, [loadCategories]);
|
}, [loadCategories]);
|
||||||
|
|
||||||
async function createCategory(name: string, parentId: string | null) {
|
async function createCategory(name: string, parentId: string | null, color: string | null = null) {
|
||||||
setCatBusy(true);
|
setCatBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const created = await api.post<KnowledgeCategoryNode>(
|
const created = await api.post<KnowledgeCategoryNode>(
|
||||||
`/workspaces/${wsId}/knowledge/categories`,
|
`/workspaces/${wsId}/knowledge/categories`,
|
||||||
{ name, parent_id: parentId },
|
{ name, parent_id: parentId, color },
|
||||||
);
|
);
|
||||||
await loadCategories();
|
await loadCategories();
|
||||||
return created;
|
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(
|
const load = useCallback(
|
||||||
async (nextCursor: string | null) => {
|
async (nextCursor: string | null) => {
|
||||||
// 切 tab / 改搜索会连发请求,慢的回来会盖掉新结果
|
// 切 tab / 改搜索会连发请求,慢的回来会盖掉新结果
|
||||||
@@ -203,6 +297,7 @@ export function KnowledgePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pendingCount = docs.filter((d) => d.knowledge.status_hint !== "published").length;
|
const pendingCount = docs.filter((d) => d.knowledge.status_hint !== "published").length;
|
||||||
|
const activeCategory = categories.find((c) => c.id === catFilter) ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell wsId={wsId} active="knowledge">
|
<AppShell wsId={wsId} active="knowledge">
|
||||||
@@ -245,12 +340,16 @@ export function KnowledgePage() {
|
|||||||
<CategorySidebar
|
<CategorySidebar
|
||||||
categories={categories}
|
categories={categories}
|
||||||
selected={catFilter}
|
selected={catFilter}
|
||||||
|
loading={loading}
|
||||||
onSelect={(value) => {
|
onSelect={(value) => {
|
||||||
setCatFilter(value);
|
setCatFilter(value);
|
||||||
setHits(null);
|
setHits(null);
|
||||||
}}
|
}}
|
||||||
busy={catBusy}
|
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="min-w-0">
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-1.5">
|
<div className="mb-4 flex flex-wrap items-center gap-1.5">
|
||||||
@@ -270,7 +369,12 @@ export function KnowledgePage() {
|
|||||||
</button>
|
</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">
|
<span className="ml-auto hidden text-2xs text-ink-400 sm:block">
|
||||||
{pendingCount} 份草稿等你核对
|
{pendingCount} 份草稿等你核对
|
||||||
</span>
|
</span>
|
||||||
@@ -283,6 +387,26 @@ export function KnowledgePage() {
|
|||||||
</div>
|
</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 ? (
|
{hits !== null ? (
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<div className="flex items-baseline justify-between px-1">
|
<div className="flex items-baseline justify-between px-1">
|
||||||
@@ -347,10 +471,21 @@ export function KnowledgePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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) => (
|
{docs.map((d) => (
|
||||||
<li
|
<li
|
||||||
key={d.id}
|
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"
|
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)}>
|
<button className="min-w-0 flex-1 text-left" onClick={() => setOpenId(d.id)}>
|
||||||
@@ -370,6 +505,11 @@ export function KnowledgePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{d.category ? (
|
{d.category ? (
|
||||||
<span className="chip max-w-[16rem] truncate bg-paper-200 text-ink-500">
|
<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}
|
{d.category.full_path}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : 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({
|
function CategorySidebar({
|
||||||
categories,
|
categories,
|
||||||
selected,
|
selected,
|
||||||
|
loading,
|
||||||
onSelect,
|
onSelect,
|
||||||
busy,
|
busy,
|
||||||
onCreate,
|
onCreate,
|
||||||
|
onRename,
|
||||||
|
onRecolor,
|
||||||
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
categories: KnowledgeCategoryNode[];
|
categories: KnowledgeCategoryNode[];
|
||||||
selected: string | null;
|
selected: string | null;
|
||||||
|
loading: boolean;
|
||||||
onSelect: (value: string | null) => void;
|
onSelect: (value: string | null) => void;
|
||||||
busy: boolean;
|
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 [adding, setAdding] = useState(false);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [parentId, setParentId] = useState("");
|
const [parentId, setParentId] = useState("");
|
||||||
|
const [newColor, setNewColor] = useState<string | null>(CATEGORY_PALETTE[0]);
|
||||||
|
/** 哪一行展开了管理面板:改名、改色、新建子分类、删除都在这一块里 */
|
||||||
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="card h-fit p-3 lg:sticky lg:top-4">
|
<aside className="card h-fit p-3 lg:sticky lg:top-4">
|
||||||
@@ -475,8 +655,9 @@ function CategorySidebar({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const value = name.trim();
|
const value = name.trim();
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
onCreate(value, parentId || null);
|
onCreate(value, parentId || null, newColor);
|
||||||
setName("");
|
setName("");
|
||||||
|
setParentId("");
|
||||||
setAdding(false);
|
setAdding(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -503,6 +684,26 @@ function CategorySidebar({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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 variant="primary" size="sm" type="submit" disabled={busy || !name.trim()}>
|
||||||
创建
|
创建
|
||||||
</Button>
|
</Button>
|
||||||
@@ -517,15 +718,28 @@ function CategorySidebar({
|
|||||||
未分类
|
未分类
|
||||||
</CategoryButton>
|
</CategoryButton>
|
||||||
{categories.map((c) => (
|
{categories.map((c) => (
|
||||||
<CategoryButton
|
<CategoryRow
|
||||||
key={c.id}
|
key={c.id}
|
||||||
|
category={c}
|
||||||
active={selected === c.id}
|
active={selected === c.id}
|
||||||
indent={c.depth - 1}
|
loading={loading && selected === c.id}
|
||||||
onClick={() => onSelect(c.id)}
|
open={openId === c.id}
|
||||||
>
|
busy={busy}
|
||||||
<span className="truncate">{c.name}</span>
|
onToggle={() => setOpenId((v) => (v === c.id ? null : c.id))}
|
||||||
{c.doc_count > 0 ? <span className="text-2xs text-ink-400">{c.doc_count}</span> : null}
|
onSelect={() => {
|
||||||
</CategoryButton>
|
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>
|
</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({
|
function CategoryButton({
|
||||||
active,
|
active,
|
||||||
indent = 0,
|
|
||||||
onClick,
|
onClick,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
indent?: number;
|
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
style={{ paddingLeft: `${8 + indent * 14}px` }}
|
aria-current={active ? "true" : undefined}
|
||||||
className={`flex w-full items-center justify-between gap-2 rounded-lg py-1.5 pr-2 text-sm transition-colors ${
|
className={`flex w-full items-center justify-between gap-2 rounded-lg py-1.5 pl-2 pr-2 text-sm transition-colors ${
|
||||||
active
|
active
|
||||||
? "bg-white text-ink-900 shadow-sm ring-1 ring-line-200"
|
? "bg-primary-50 font-medium text-primary-800 ring-1 ring-primary-200"
|
||||||
: "text-ink-600 hover:bg-white/60 hover:text-ink-800"
|
: "text-ink-600 hover:bg-paper-100 hover:text-ink-900"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-1">
|
<span className="flex min-w-0 items-center gap-1">{children}</span>
|
||||||
{/* 二级及以下带一个点,光靠缩进在小屏上不够分得清是子分类还是换了一棵树 */}
|
|
||||||
{indent > 0 ? (
|
|
||||||
<span aria-hidden className="text-ink-300">
|
|
||||||
·
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -584,7 +1008,11 @@ function KnowledgeComposer({
|
|||||||
wsId: string;
|
wsId: string;
|
||||||
categories: KnowledgeCategoryNode[];
|
categories: KnowledgeCategoryNode[];
|
||||||
catBusy: boolean;
|
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;
|
onClose: () => void;
|
||||||
onCreated: (doc: Knowledge) => void;
|
onCreated: (doc: Knowledge) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -616,7 +1044,7 @@ function KnowledgeComposer({
|
|||||||
async function addCategory() {
|
async function addCategory() {
|
||||||
const name = newCat.trim();
|
const name = newCat.trim();
|
||||||
if (!name || !canAddCategory) return;
|
if (!name || !canAddCategory) return;
|
||||||
const created = await onCreateCategory(name, categoryId || null);
|
const created = await onCreateCategory(name, categoryId || null, null);
|
||||||
if (created) {
|
if (created) {
|
||||||
setCategoryId(created.id);
|
setCategoryId(created.id);
|
||||||
setNewCat("");
|
setNewCat("");
|
||||||
|
|||||||
Reference in New Issue
Block a user