feat(knowledge): 知识改成备忘录形态,一次写全 + 三级分类,抽屉加宽
CI / backend (push) Canceled after 0s

知识以前只能"填个标题就完成",正文、标签、归类都要点进去再补。现在新建
就是一张完整的备忘录:标题、类型、分类、标签、正文一次写完,存完直接打开
详情继续编辑;知识页左侧多了工作区三级分类树,可按分类子树 / 未分类 / 标签
筛选。凡是填信息的抽屉都加宽了,还能一键展开整屏。

后端
- 新增 `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:
Steven
2026-10-30 03:51:43 +08:00
parent 91b3dde670
commit ed31af3f46
18 changed files with 1932 additions and 164 deletions
+1 -1
View File
@@ -353,7 +353,7 @@ export function TaskDetail({
<Sheet
open
onClose={onClose}
width="max-w-2xl"
size="xl"
title={item ? item.title : "打开中…"}
subtitle={meta}
footer={
+13
View File
@@ -234,6 +234,19 @@ export const Link2 = (p: IconProps) => (
</svg>
);
/** 放大到整屏 / 收回原来的宽度:长文和图片需要有地方放 */
export const Expand = (p: IconProps) => (
<svg {...base(p)}>
<path d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5" />
</svg>
);
export const Collapse = (p: IconProps) => (
<svg {...base(p)}>
<path d="M4 9h5V4M20 9h-5V4M4 15h5v5M20 15h-5v5" />
</svg>
);
export const More = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="6" cy="12" r="1.2" fill="currentColor" stroke="none" />
+38 -7
View File
@@ -8,7 +8,17 @@ import {
useState,
type ReactNode,
} from "react";
import { Check, X } from "./icons";
import { Check, Collapse, Expand, X } from "./icons";
/**
* 抽屉宽度档位。以前的抽屉默认 `max-w-xl`(576px),写长文像在门缝里写字;
* 知识、工单这类要贴图的内容统一走 xl,上面还有"展开整屏"。
*/
const SHEET_SIZE = {
md: "max-w-2xl",
lg: "max-w-4xl",
xl: "max-w-6xl",
} as const;
/* ——————————————————————————— 按钮 ——————————————————————————— */
@@ -267,7 +277,8 @@ export function Sheet({
subtitle,
children,
footer,
width = "max-w-xl",
size = "lg",
width,
}: {
open: boolean;
onClose: () => void;
@@ -275,8 +286,14 @@ export function Sheet({
subtitle?: ReactNode;
children: ReactNode;
footer?: ReactNode;
/** 宽度档位;长文/多图用 xl,右上方还能一键放满整屏 */
size?: "md" | "lg" | "xl";
/** 兜底:直接给 Tailwind 宽度类 */
width?: string;
}) {
const [expanded, setExpanded] = useState(false);
const sizeClass = width ?? SHEET_SIZE[size];
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
@@ -298,17 +315,31 @@ export function Sheet({
role="dialog"
aria-modal="true"
aria-label={typeof title === "string" ? title : "详情"}
className={`relative flex h-full w-full ${width} animate-slide-in-right flex-col border-l border-line-200 bg-white shadow-pop`}
className={`relative flex h-full w-full ${
expanded ? "max-w-none" : sizeClass
} animate-slide-in-right flex-col border-l border-line-200 bg-white shadow-pop`}
>
<header className="flex items-start justify-between gap-3 border-b border-line-100 px-6 py-4">
<div className="min-w-0">
<div className="text-base font-semibold text-ink-900">{title}</div>
{subtitle ? <div className="mt-0.5 text-xs text-ink-400">{subtitle}</div> : null}
</div>
<Button variant="subtle" size="sm" onClick={onClose} aria-label="关闭">
<X size={15} />
关闭
</Button>
<div className="flex shrink-0 items-center gap-1.5">
<Button
variant="subtle"
size="sm"
onClick={() => setExpanded((v) => !v)}
aria-label={expanded ? "收起到侧栏" : "展开整屏"}
title={expanded ? "收起到侧栏" : "展开整屏"}
>
{expanded ? <Collapse size={15} /> : <Expand size={15} />}
<span className="hidden sm:inline">{expanded ? "收起" : "展开"}</span>
</Button>
<Button variant="subtle" size="sm" onClick={onClose} aria-label="关闭">
<X size={15} />
关闭
</Button>
</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">{children}</div>
{footer ? (