Compare commits
2
Commits
3471b07ac1
...
07688881b4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07688881b4 | ||
|
|
e9addeed25 |
+90
-2
@@ -1,4 +1,4 @@
|
||||
# 接口约定(M0–M2 已实现)
|
||||
# 接口约定(M0–M3 已实现)
|
||||
|
||||
> 只写**已经在跑**的接口。写完没验证的东西不进这份文档。
|
||||
> 前缀 `/api/v1`。所有响应都是同一个信封,**HTTP 200 不代表业务成功**。
|
||||
@@ -301,7 +301,95 @@ canceled→ todo (恢复)
|
||||
|
||||
---
|
||||
|
||||
## 5. 健康与版本
|
||||
## 5. 知识 `/workspaces/{ws_id}`
|
||||
|
||||
### 5.1 `GET /knowledge`
|
||||
|
||||
| 参数 | 说明 |
|
||||
|---|---|
|
||||
| `status` | 可重复;`draft`/`in_review`/`published`/`archived`,不传为全部 |
|
||||
| `doc_type` | `note`/`howto`/`faq`/`postmortem`/`decision` |
|
||||
| `source_item_id` | 按来源事项筛 |
|
||||
| `q` | 标题 / 正文 / 摘要子串 |
|
||||
| `limit` | 1–100,默认 30 |
|
||||
| `cursor` | 上一页的 `next_cursor` |
|
||||
|
||||
返回 `{items, next_cursor, has_more}`。每条是 `item` 字段 + `knowledge` + `source`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "…", "kind": "knowledge", "title": "发票原件必须一起寄",
|
||||
"body_md": "…", "status": "published", "tags": [],
|
||||
"knowledge": {
|
||||
"doc_type": "howto", "summary": "…", "status_hint": "published",
|
||||
"source_kind": "task", "source_item_id": "…", "generated_by": "human",
|
||||
"published_at": "2026-10-28T05:54:44Z", "archived_at": null,
|
||||
"chunk_count": 0, "author_id": "…"
|
||||
},
|
||||
"source": {"id": "…", "kind": "task", "title": "发票原件必须一起寄", "status": "done"}
|
||||
}
|
||||
```
|
||||
|
||||
> `status` 与 `knowledge.status_hint` 是同一件事:前者在 `item` 上(复用事项表),
|
||||
> 后者在 `knowledge_doc` 上(列表按知识口径筛时不必回连 `item`)。**两者必须同步**,
|
||||
> 状态动作接口会一起写。
|
||||
|
||||
### 5.2 `GET /knowledge/{item_id}`
|
||||
|
||||
详情,附最近 50 条 `activities`。不存在或跨租户统一 `7001`。
|
||||
|
||||
### 5.3 `POST /knowledge` — 手记或指定来源
|
||||
|
||||
```json
|
||||
{"title": "报销单要贴发票原件", "body_md": "…", "doc_type": "note",
|
||||
"source_item_id": "可选的来源事项 id"}
|
||||
```
|
||||
|
||||
- 不带 `source_item_id` → `source_kind=manual`(手记),允许没有来源。
|
||||
- 带 → 来源必须是同工作区的 `task`/`ticket`,否则 `7005`。
|
||||
- 新知识一律 `draft`,不会直接对外可见。
|
||||
|
||||
### 5.4 `PATCH /knowledge/{item_id}` — 修改内容
|
||||
|
||||
必须带 `version`(乐观锁,冲突 `4009`)。可改 `title`/`body_md`/`summary`/`doc_type`。
|
||||
**已发布的不许直接改**(`7003`,先撤回审议或归档),避免"发布后被悄悄改写"。
|
||||
|
||||
### 5.5 `POST /knowledge/{item_id}/status` — 状态动作
|
||||
|
||||
| action | 允许的前置状态 | 结果 | 谁能做 |
|
||||
|---|---|---|---|
|
||||
| `submit_review` | draft | in_review | 成员 |
|
||||
| `withdraw` | in_review | draft | 成员 |
|
||||
| `publish` | draft / in_review | published | owner / admin |
|
||||
| `archive` | draft / in_review / published | archived | 成员 |
|
||||
| `restore` | archived | draft | 成员 |
|
||||
|
||||
非法动作返回 `7003`,`details.allowed_from` 是动作要求的前置状态,`details.allowed_actions`
|
||||
是当前状态下还能做什么——前端据此渲染按钮,不用自己维护一份状态机。
|
||||
|
||||
**发布要 owner/admin 的理由**:发布等于团队为这条结论背书,不能谁都能推。
|
||||
|
||||
### 5.6 `POST /items/{item_id}/knowledge` — 把一件事沉淀成知识
|
||||
|
||||
Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;工单会带出验收结论。
|
||||
返回与 5.2 相同的知识对象;同一来源重复沉淀 → `7002`。
|
||||
|
||||
**工单验收通过时,后端会自动调用这条逻辑**(`generated_by=rule`,`doc_type=postmortem`,
|
||||
正文开头固定一句"这份草稿由系统根据来源自动整理,请先核对再发布"),所以用户不需要记得点。
|
||||
|
||||
### 5.7 知识的错误码
|
||||
|
||||
| 码 | HTTP | 含义 |
|
||||
|---|---|---|
|
||||
| 7001 | 404 | 知识不存在,或不是知识 |
|
||||
| 7002 | 409 | 这个来源已经有知识了 |
|
||||
| 7003 | 409 | 当前状态不允许这个动作 |
|
||||
| 7004 | 403 | 权限不足(如非管理员发布) |
|
||||
| 7005 | 409 | 来源事项无效或缺失 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 健康与版本
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -84,11 +84,16 @@ ruff check . && ruff format --check .
|
||||
|
||||
```bash
|
||||
cd web
|
||||
node e2e/smoke.mjs # 登录→建任务→详情保存→看板拖拽→非法流转被拒,7 项断言
|
||||
node e2e/smoke.mjs # 登录→建任务→详情保存→看板拖拽→非法流转被拒,7 项断言
|
||||
node e2e/tickets.mjs # 双人:升级工单→受理→提交→验收→原任务回流,8 项断言
|
||||
node e2e/knowledge.mjs # 沉淀成知识→待核对→发布,7 项断言
|
||||
```
|
||||
|
||||
用本机缓存的 Chromium(`playwright-core`,不下载浏览器),截图落在 `web/e2e/artifacts/`。
|
||||
|
||||
> e2e 抓的都是"手动点几下看不出来"的问题:字段缺失导致整页白屏、请求竞态导致的空列表、
|
||||
> 点错按钮的 404。加功能时**先想一条能失败的断言**,再动手写页面。
|
||||
|
||||
### 1000 条下的首屏耗时
|
||||
|
||||
```bash
|
||||
|
||||
@@ -261,7 +261,36 @@
|
||||
|
||||
---
|
||||
|
||||
## 关键验收用例(M0–M2 必须通过)
|
||||
## M3 · 知识库(第 7–8 周,10 人日)
|
||||
|
||||
| # | 任务 | 人日 | 状态 |
|
||||
|---|---|---|---|
|
||||
| 3.1 | `knowledge_doc`(1:1 于 item,`source_kind` + `source_item_id` 必填约束)+ 迁移 0004 + RLS | 1.0 | [x] 已跑通,隔离测试覆盖 |
|
||||
| 3.2 | 状态机 `draft / in_review / published / archived`,发布要 owner/admin | 0.5 | [x] 5 个动作,非法动作回 `allowed_from` + `allowed_actions` |
|
||||
| 3.3 | 结构化编辑:标题 / 正文 / 摘要 / 类型,乐观锁,已发布禁改 | 1.0 | [x] 含 `4009` 冲突用例 |
|
||||
| 3.4 | **闭环沉淀**:工单验收通过自动生成草稿(`generated_by=rule`),同一来源只生成一次 | 1.5 | [x] 只转述不推断,正文带"请先核对再发布" |
|
||||
| 3.5 | 手动沉淀:任务/工单详情里"沉淀成知识" | 0.5 | [x] 有 e2e |
|
||||
| 3.6 | 手记:没有来源的知识(唯一允许 `source_kind=manual` 的入口) | 0.5 | [x] 手记页 + 列表 |
|
||||
| 3.7 | 检索:标题/正文/摘要子串 + 类型/状态筛选 + 游标分页 | 1.0 | [x] trigram 索引在 0002 已建 |
|
||||
| 3.8 | 切片与向量索引(`chunk` 表、pgvector HNSW) | 2.0 | [ ] 见下方说明 |
|
||||
| 3.9 | 安卓知识列表 | 1.0 | [ ] 与工单一起延后到编译链就绪 |
|
||||
| 3.10 | 文档与 e2e | 1.0 | [x] `web/e2e/knowledge.mjs` 7 项断言绿 |
|
||||
|
||||
### M3 完成定义(DOD)
|
||||
- [x] 一件事办完 → 自动出现一份草稿 → 核对 → 发布,全程不用手动复制粘贴
|
||||
- [x] 每条知识都能追到来源(除手记),点开能看到来源事项
|
||||
- [x] 非管理员发布被拒;跨租户看不到别人的知识
|
||||
- [ ] 向量切片与语义检索(需要独立 pgvector 实例真正跑起来)
|
||||
- [ ] 真实用两周:新同事靠知识库自己解决过一次问题(需要真实团队)
|
||||
|
||||
**关于 3.8 的说明**:本机 Docker 起不来、fnsvr-01 上也没有 pgvector 扩展,
|
||||
所以 `deploy/docker-compose.vector.yml` 与 `VECTOR_DATABASE_URL` 只有配置,**没有跑过**。
|
||||
按"写完不算完成"的规矩,它在真跑起来之前保持未勾。切片表设计要点已写进
|
||||
`2.Design/PRODUCT-PLAN.md` 的 D8,落地时不要改 `EMBEDDING_DIM`(改了要重算全量)。
|
||||
|
||||
---
|
||||
|
||||
## 关键验收用例(M0–M3 必须通过)
|
||||
|
||||
| # | 用例 | 期望 | 现状 |
|
||||
|---|---|---|---|
|
||||
@@ -275,14 +304,16 @@
|
||||
| 8 | 部署后 `/version` | commit 与仓库 HEAD 一致 | [~] 机制已有,等部署验证 |
|
||||
| 9 | 办理人验收自己的工单 | 403 `6004`,不能自己给自己过 | ✅ 已覆盖 |
|
||||
| 10 | 工单验收通过后原任务 | 自动从"被阻塞"回到"待办",事件流留痕 | ✅ 已覆盖(含 e2e) |
|
||||
| 11 | 工单验收通过后知识库 | 自动出现一份草稿,且只生成一次 | ✅ 已覆盖 |
|
||||
| 12 | 成员发布知识 | 403 `7004`;owner 发布成功且记录发布时间 | ✅ 已覆盖 |
|
||||
| 13 | 知识有没有来源 | 非手记必须有 `source_item_id`,否则 7005 / 库级约束拒绝 | ✅ 已覆盖 |
|
||||
|
||||
---
|
||||
|
||||
## 下一步排期建议
|
||||
|
||||
```
|
||||
已完成 0.1–0.9 骨架与认证 + 1.1–1.7 任务域 + 2.1–2.6 工单协同(含双人 e2e)
|
||||
待补 0.11 部署 / 0.12 契约 / 0.14 微信 spike / CI 在 Gitea 实跑 / 安卓编译链
|
||||
第7–8周 M3 知识:knowledge_doc + chunk、切片与索引、工单关闭生成草稿、带引用问答
|
||||
已完成 0.1–0.9 骨架与认证 + 1.1–1.7 任务域 + 2.1–2.6 工单协同 + 3.1–3.7、3.10 知识库
|
||||
待补 0.11 部署 / 0.12 契约 / 0.14 微信 spike / CI 在 Gitea 实跑 / 安卓编译链 / 3.8 向量索引
|
||||
第9–10周 M4 AI:结构化录入、人物工作侧写、主动提醒、20 条黄金用例回归
|
||||
```
|
||||
|
||||
@@ -21,13 +21,15 @@
|
||||
|---|---|
|
||||
| 手机号验证码登录、刷新令牌、幂等请求 | ✅ 已实现并有测试 |
|
||||
| 工作区 / 成员 / 邀请码加入,跨租户隔离(RLS) | ✅ 同上 |
|
||||
| 任务:收件箱→待办→进行中→完成、截止时间、标签、评论、事件流、乐观锁 | ✅ 后端 + Web 已通,57 个测试全绿 |
|
||||
| 任务:收件箱→待办→进行中→完成、截止时间、标签、评论、事件流、乐观锁 | ✅ 后端 + Web 已通 |
|
||||
| Web 列表 / 看板拖拽 / 详情侧栏编辑 / 键盘流 / 快速录入语法 | ✅ e2e 7 项断言全绿,构建通过 |
|
||||
| 1000 条任务下的首屏性能 | ✅ 实测 P50 38–55ms(目标 < 300ms) |
|
||||
| 安卓端任务列表与详情 | ⚠️ 代码已写,**本机无 JDK/Gradle,未编译验证** |
|
||||
| 工单:升级不搬数据、受理→办理→提交→验收、驳回、改派、站内通知 | ✅ 后端 + Web 已通,双人 e2e 8 项全绿 |
|
||||
| 后端测试 | ✅ 57 passed,ruff 干净 |
|
||||
| 知识库、LLM 能力 | ⬜ M3–M4 |
|
||||
| 知识:工单验收自动生成草稿、核对后发布、来源可追、手记 | ✅ 后端 + Web 已通,e2e 7 项全绿 |
|
||||
| 后端测试 | ✅ 65 passed,ruff 干净 |
|
||||
| 知识切片与语义检索(pgvector) | ⬜ 配置已就绪(`deploy/docker-compose.vector.yml`),本机 Docker 起不来,未跑过 |
|
||||
| 安卓端知识页 / LLM 能力 | ⬜ M3 收尾 / M4 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""M3 知识域:knowledge_doc + 扩宽 item.status(加知识四态)。
|
||||
|
||||
三个需要注意的点:
|
||||
|
||||
1. **item.status 的 CHECK 再扩一次**。0003 里是"任务 ∪ 工单",知识的
|
||||
`draft/in_review/published/archived` 会被直接拦下。这里改为三者并集;
|
||||
"哪种 kind 只能用哪组状态"由 `app/domain/*.py` 的状态机保证,不靠数据库
|
||||
(CHECK 不能跨行判断 kind 与 status 的组合)。
|
||||
2. **知识的来源写在数据库里**。`source_kind <> 'manual'` 时 `source_item_id` 必填。
|
||||
这条 CHECK 是 D3 的物理落点:LLM 自动归档出来的知识若没有来源,用户无法核对,
|
||||
等于往库里塞断言。
|
||||
3. **downgrade 会删掉所有知识**(kind='knowledge' 的 item 及其扩展),原因与 0003 相同:
|
||||
旧约束容纳不下知识状态,不回删就回不到 0003。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0004"
|
||||
down_revision: str | None = "0003"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
APP_ROLE = "dadao_app"
|
||||
|
||||
TENANT_TABLES_0004: tuple[str, ...] = ("knowledge_doc",)
|
||||
|
||||
TASK_STATUSES = ("inbox", "todo", "doing", "done", "blocked", "canceled")
|
||||
TICKET_STATUSES = ("open", "accepted", "in_progress", "pending_verify", "done", "canceled")
|
||||
KNOWLEDGE_STATUSES = ("draft", "in_review", "published", "archived")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1) item.status 放宽到 任务 ∪ 工单 ∪ 知识
|
||||
op.execute("ALTER TABLE item DROP CONSTRAINT IF EXISTS ck_item_status")
|
||||
statuses = ",".join(
|
||||
f"'{s}'" for s in (*TASK_STATUSES, *TICKET_STATUSES, *KNOWLEDGE_STATUSES)
|
||||
)
|
||||
op.execute(f"ALTER TABLE item ADD CONSTRAINT ck_item_status CHECK (status IN ({statuses}))")
|
||||
|
||||
# 2) knowledge_doc
|
||||
op.create_table(
|
||||
"knowledge_doc",
|
||||
sa.Column(
|
||||
"item_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("item.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column(
|
||||
"workspace_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("workspace.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"author_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("app_user.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"source_item_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("item.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("source_kind", sa.String(16), nullable=False, server_default="manual"),
|
||||
sa.Column("doc_type", sa.String(16), nullable=False, server_default="note"),
|
||||
sa.Column("status_hint", sa.String(16), nullable=False, server_default="draft"),
|
||||
sa.Column("summary", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("generated_by", sa.String(16), nullable=False, server_default="human"),
|
||||
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("chunk_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.CheckConstraint(
|
||||
"source_kind = 'manual' OR source_item_id IS NOT NULL",
|
||||
name="ck_knowledge_source_required",
|
||||
),
|
||||
sa.CheckConstraint("chunk_count >= 0", name="ck_knowledge_chunk_count"),
|
||||
)
|
||||
op.create_index("ix_knowledge_ws_status", "knowledge_doc", ["workspace_id", "status_hint"])
|
||||
op.create_index("ix_knowledge_ws_source", "knowledge_doc", ["workspace_id", "source_item_id"])
|
||||
|
||||
# 3) RLS + 授权
|
||||
op.execute("ALTER TABLE knowledge_doc ENABLE ROW LEVEL SECURITY")
|
||||
op.execute(
|
||||
"CREATE POLICY knowledge_doc_isolation ON knowledge_doc "
|
||||
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())"
|
||||
)
|
||||
op.execute(
|
||||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON {', '.join(TENANT_TABLES_0004)} TO {APP_ROLE}"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 旧约束容纳不下知识状态:先清掉知识 item,才能回到 0003 的约束
|
||||
op.execute("DELETE FROM item WHERE kind = 'knowledge'")
|
||||
op.execute("DROP POLICY IF EXISTS knowledge_doc_isolation ON knowledge_doc")
|
||||
op.drop_index("ix_knowledge_ws_source", table_name="knowledge_doc")
|
||||
op.drop_index("ix_knowledge_ws_status", table_name="knowledge_doc")
|
||||
op.drop_table("knowledge_doc")
|
||||
|
||||
op.execute("ALTER TABLE item DROP CONSTRAINT IF EXISTS ck_item_status")
|
||||
statuses = ",".join(f"'{s}'" for s in (*TASK_STATUSES, *TICKET_STATUSES))
|
||||
op.execute(f"ALTER TABLE item ADD CONSTRAINT ck_item_status CHECK (status IN ({statuses}))")
|
||||
@@ -0,0 +1,450 @@
|
||||
"""知识域接口(M3):草稿、审议、发布、归档,以及"从事项沉淀"。
|
||||
|
||||
一条知识的一生:
|
||||
|
||||
工单验收通过(或你手动点"沉淀成知识")
|
||||
-> draft 草稿:系统整理好了,等你核对
|
||||
-> in_review 提交审议:让同事看一遍(可选)
|
||||
-> published 发布:进团队检索、能被问答引用
|
||||
-> archived 归档:不删,退出检索(历史结论不该消失)
|
||||
|
||||
为什么要 `draft` 这一步:自动整理的东西一定有"看着像那么回事、其实漏了前提"的情况,
|
||||
让人扫一眼的成本远低于让整个团队按错的结论办事。发布权收紧到 owner/admin 也是同理——
|
||||
发布等于团队背书。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import Select, and_, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..audit import record as audit_record
|
||||
from ..deps import MemberDep, SessionDep
|
||||
from ..domain.knowledge import can_publish_role, sidebar_fields, target_status
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models_knowledge import DOC_TYPES, KNOWLEDGE_STATUSES, KnowledgeDoc
|
||||
from ..models_task import Activity, Item
|
||||
from ..schemas import clamp_limit, decode_cursor, encode_cursor, ok
|
||||
from ..serializers import item_out, tags_of
|
||||
from ..services.knowledge import create_draft_from_source, find_doc_by_source
|
||||
|
||||
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["知识库"])
|
||||
|
||||
|
||||
# ── 入参 ────────────────────────────────────────────────────────────────
|
||||
class KnowledgeIn(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
body_md: str = ""
|
||||
summary: str = Field(default="", max_length=2000)
|
||||
doc_type: str = "note"
|
||||
source_item_id: uuid.UUID | None = None
|
||||
tag_names: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class KnowledgePatch(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
body_md: str | None = None
|
||||
summary: str | None = None
|
||||
doc_type: str | None = None
|
||||
tag_names: list[str] | None = None
|
||||
version: int
|
||||
|
||||
|
||||
class StatusActionIn(BaseModel):
|
||||
action: str
|
||||
version: int
|
||||
note: str = ""
|
||||
|
||||
|
||||
# ── 内部工具 ────────────────────────────────────────────────────────────
|
||||
async def _load_doc(
|
||||
session: AsyncSession, ws_id: uuid.UUID, item_id: uuid.UUID
|
||||
) -> tuple[Item, KnowledgeDoc]:
|
||||
item = await session.get(Item, item_id)
|
||||
doc = await session.get(KnowledgeDoc, item_id)
|
||||
if item is None or doc is None or item.workspace_id != ws_id or item.kind != "knowledge":
|
||||
raise AppError(ErrorCode.KNOWLEDGE_NOT_FOUND, "这份知识不存在")
|
||||
return item, doc
|
||||
|
||||
|
||||
async def _log(
|
||||
session: AsyncSession, item: Item, actor_id: uuid.UUID, action: str, payload: dict | None = None
|
||||
) -> None:
|
||||
session.add(
|
||||
Activity(
|
||||
workspace_id=item.workspace_id,
|
||||
item_id=item.id,
|
||||
actor_id=actor_id,
|
||||
action=action,
|
||||
payload=payload or {},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _source_out(
|
||||
session: AsyncSession, ws_id: uuid.UUID, source_item_id: uuid.UUID | None
|
||||
) -> dict[str, Any] | None:
|
||||
if source_item_id is None:
|
||||
return None
|
||||
source = await session.get(Item, source_item_id)
|
||||
if source is None or source.workspace_id != ws_id:
|
||||
return None
|
||||
return {
|
||||
"id": str(source.id),
|
||||
"kind": source.kind,
|
||||
"title": source.title,
|
||||
"status": source.status,
|
||||
}
|
||||
|
||||
|
||||
def _doc_out(doc: KnowledgeDoc) -> dict[str, Any]:
|
||||
return {
|
||||
"doc_type": doc.doc_type,
|
||||
"summary": doc.summary,
|
||||
"status_hint": doc.status_hint,
|
||||
"source_kind": doc.source_kind,
|
||||
"source_item_id": str(doc.source_item_id) if doc.source_item_id else None,
|
||||
"generated_by": doc.generated_by,
|
||||
"published_at": doc.published_at,
|
||||
"archived_at": doc.archived_at,
|
||||
"chunk_count": doc.chunk_count,
|
||||
"author_id": str(doc.author_id),
|
||||
}
|
||||
|
||||
|
||||
async def _knowledge_out(
|
||||
session: AsyncSession, ws_id: uuid.UUID, item: Item, doc: KnowledgeDoc
|
||||
) -> dict[str, Any]:
|
||||
tags = (await tags_of(session, ws_id, [item.id])).get(item.id, [])
|
||||
data = item_out(item, tags)
|
||||
data["knowledge"] = _doc_out(doc)
|
||||
data["source"] = await _source_out(session, ws_id, doc.source_item_id)
|
||||
return data
|
||||
|
||||
|
||||
async def _set_tags_for(
|
||||
session: AsyncSession, ws_id: uuid.UUID, item: Item, names: list[str]
|
||||
) -> None:
|
||||
from .items import _set_tags
|
||||
|
||||
await _set_tags(session, ws_id, item, names)
|
||||
|
||||
|
||||
# ── 列表 / 详情 ─────────────────────────────────────────────────────────
|
||||
@router.get("/knowledge", summary="知识列表")
|
||||
async def list_knowledge(
|
||||
ctx: MemberDep,
|
||||
session: SessionDep,
|
||||
status: Annotated[list[str] | None, Query()] = None,
|
||||
doc_type: Annotated[str | None, Query()] = None,
|
||||
source_item_id: Annotated[uuid.UUID | None, Query()] = None,
|
||||
q: Annotated[str | None, Query(max_length=100)] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100, description="每页条数")] = 30,
|
||||
cursor: Annotated[str | None, Query(description="上一页返回的 next_cursor")] = None,
|
||||
) -> dict:
|
||||
ws_id = ctx.ws_id
|
||||
stmt: Select = (
|
||||
select(Item, KnowledgeDoc)
|
||||
.join(KnowledgeDoc, KnowledgeDoc.item_id == Item.id)
|
||||
.where(Item.workspace_id == ws_id, Item.kind == "knowledge", Item.deleted_at.is_(None))
|
||||
)
|
||||
wanted = [s for s in (status or []) if s in KNOWLEDGE_STATUSES]
|
||||
if wanted:
|
||||
stmt = stmt.where(KnowledgeDoc.status_hint.in_(wanted))
|
||||
if doc_type:
|
||||
stmt = stmt.where(KnowledgeDoc.doc_type == doc_type)
|
||||
if source_item_id:
|
||||
stmt = stmt.where(KnowledgeDoc.source_item_id == source_item_id)
|
||||
if q:
|
||||
like = f"%{q.strip()}%"
|
||||
stmt = stmt.where(
|
||||
or_(Item.title.ilike(like), Item.body_md.ilike(like), KnowledgeDoc.summary.ilike(like))
|
||||
)
|
||||
if cursor:
|
||||
cur = decode_cursor(cursor)
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Item.updated_at < cur.created_at,
|
||||
and_(Item.updated_at == cur.created_at, Item.id < cur.item_id),
|
||||
)
|
||||
)
|
||||
|
||||
limit = clamp_limit(limit)
|
||||
rows = (
|
||||
await session.execute(
|
||||
stmt.order_by(Item.updated_at.desc(), Item.id.desc()).limit(limit + 1)
|
||||
)
|
||||
).all()
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
tag_map = await tags_of(session, ws_id, [it.id for it, _ in rows])
|
||||
|
||||
items = []
|
||||
for item, doc in rows:
|
||||
data = item_out(item, tag_map.get(item.id, []))
|
||||
data["knowledge"] = _doc_out(doc)
|
||||
data["source"] = await _source_out(session, ws_id, doc.source_item_id)
|
||||
items.append(data)
|
||||
|
||||
next_cursor = (
|
||||
encode_cursor(rows[-1][0].updated_at, rows[-1][0].id) if has_more and rows else None
|
||||
)
|
||||
return ok({"items": items, "next_cursor": next_cursor, "has_more": has_more})
|
||||
|
||||
|
||||
@router.get("/knowledge/{item_id}", summary="知识详情")
|
||||
async def get_knowledge(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
|
||||
item, doc = await _load_doc(session, ctx.ws_id, item_id)
|
||||
data = await _knowledge_out(session, ctx.ws_id, item, doc)
|
||||
events = (
|
||||
await session.execute(
|
||||
select(Activity)
|
||||
.where(Activity.workspace_id == ctx.ws_id, Activity.item_id == item.id)
|
||||
.order_by(Activity.created_at.desc())
|
||||
.limit(50)
|
||||
)
|
||||
).scalars()
|
||||
data["activities"] = [
|
||||
{
|
||||
"action": e.action,
|
||||
"payload": e.payload,
|
||||
"actor_id": str(e.actor_id) if e.actor_id else None,
|
||||
"created_at": e.created_at,
|
||||
}
|
||||
for e in events
|
||||
]
|
||||
return ok(data)
|
||||
|
||||
|
||||
# ── 创建 / 修改 ─────────────────────────────────────────────────────────
|
||||
@router.post("/knowledge", summary="新建知识(手记或引用来源)")
|
||||
async def create_knowledge(body: KnowledgeIn, ctx: MemberDep, session: SessionDep) -> dict:
|
||||
ws_id = ctx.ws_id
|
||||
if body.doc_type not in DOC_TYPES:
|
||||
raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{body.doc_type}")
|
||||
|
||||
source: Item | None = None
|
||||
if body.source_item_id is not None:
|
||||
source = await session.get(Item, body.source_item_id)
|
||||
if source is None or source.workspace_id != ws_id or source.kind not in ("task", "ticket"):
|
||||
raise AppError(
|
||||
ErrorCode.KNOWLEDGE_SOURCE_REQUIRED,
|
||||
"来源必须是与本知识同一工作区的任务或工单",
|
||||
)
|
||||
|
||||
item = Item(
|
||||
workspace_id=ws_id,
|
||||
kind="knowledge",
|
||||
title=body.title.strip(),
|
||||
body_md=body.body_md,
|
||||
status="draft",
|
||||
creator_id=ctx.user.id,
|
||||
)
|
||||
session.add(item)
|
||||
await session.flush()
|
||||
|
||||
doc = KnowledgeDoc(
|
||||
item_id=item.id,
|
||||
workspace_id=ws_id,
|
||||
author_id=ctx.user.id,
|
||||
source_item_id=source.id if source else None,
|
||||
source_kind="manual" if source is None else source.kind,
|
||||
doc_type=body.doc_type,
|
||||
status_hint="draft",
|
||||
summary=body.summary,
|
||||
generated_by="human",
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
await _log(session, item, ctx.user.id, "knowledge_created", {"generated_by": "human"})
|
||||
await _set_tags_for(session, ws_id, item, body.tag_names)
|
||||
await audit_record(
|
||||
session,
|
||||
workspace_id=ws_id,
|
||||
actor_user_id=ctx.user.id,
|
||||
action="knowledge.create",
|
||||
target_type="item",
|
||||
target_id=str(item.id),
|
||||
)
|
||||
return ok(await _knowledge_out(session, ws_id, item, doc))
|
||||
|
||||
|
||||
@router.patch("/knowledge/{item_id}", summary="修改知识内容")
|
||||
async def patch_knowledge(
|
||||
item_id: uuid.UUID, body: KnowledgePatch, ctx: MemberDep, session: SessionDep
|
||||
) -> dict:
|
||||
ws_id = ctx.ws_id
|
||||
item, doc = await _load_doc(session, ws_id, item_id)
|
||||
if item.status == "published":
|
||||
raise AppError(
|
||||
ErrorCode.KNOWLEDGE_STATUS_INVALID,
|
||||
"已发布的知识不能直接改内容,先撤回成草稿",
|
||||
details={"from": item.status, "allowed_actions": ["archive"]},
|
||||
)
|
||||
if body.doc_type is not None and body.doc_type not in DOC_TYPES:
|
||||
raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{body.doc_type}")
|
||||
|
||||
values: dict[str, Any] = {}
|
||||
if body.title is not None:
|
||||
values["title"] = body.title.strip()
|
||||
if body.body_md is not None:
|
||||
values["body_md"] = body.body_md
|
||||
if body.summary is not None:
|
||||
values["summary"] = body.summary
|
||||
if body.doc_type is not None:
|
||||
values["doc_type"] = body.doc_type
|
||||
|
||||
if values:
|
||||
values["updated_at"] = datetime.now(timezone.utc)
|
||||
result = await session.execute(
|
||||
update(Item)
|
||||
.where(
|
||||
Item.id == item.id,
|
||||
Item.workspace_id == ws_id,
|
||||
Item.version == body.version,
|
||||
Item.deleted_at.is_(None),
|
||||
)
|
||||
.values(version=Item.version + 1, **values)
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
await session.refresh(item)
|
||||
raise AppError(
|
||||
ErrorCode.VERSION_CONFLICT,
|
||||
"这条内容刚被别人改过",
|
||||
details={"current_version": item.version},
|
||||
)
|
||||
await session.refresh(item)
|
||||
if body.doc_type is not None:
|
||||
doc.doc_type = body.doc_type
|
||||
if body.summary is not None:
|
||||
doc.summary = body.summary
|
||||
if body.tag_names is not None:
|
||||
await _set_tags_for(session, ws_id, item, body.tag_names)
|
||||
await session.flush()
|
||||
return ok(await _knowledge_out(session, ws_id, item, doc))
|
||||
|
||||
|
||||
# ── 状态动作 ────────────────────────────────────────────────────────────
|
||||
@router.post("/knowledge/{item_id}/status", summary="提交审议 / 发布 / 归档 / 恢复")
|
||||
async def change_status(
|
||||
item_id: uuid.UUID, body: StatusActionIn, ctx: MemberDep, session: SessionDep
|
||||
) -> dict:
|
||||
ws_id = ctx.ws_id
|
||||
item, doc = await _load_doc(session, ws_id, item_id)
|
||||
to_status = target_status(body.action, item.status)
|
||||
|
||||
# 发布等于团队背书;归档/恢复作者自己就能做
|
||||
if body.action == "publish" and not can_publish_role(ctx.role):
|
||||
raise AppError(
|
||||
ErrorCode.KNOWLEDGE_FORBIDDEN,
|
||||
"只有管理员可以发布知识",
|
||||
details={"role": ctx.role},
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await session.execute(
|
||||
update(Item)
|
||||
.where(
|
||||
Item.id == item.id,
|
||||
Item.workspace_id == ws_id,
|
||||
Item.version == body.version,
|
||||
Item.deleted_at.is_(None),
|
||||
)
|
||||
.values(version=Item.version + 1, status=to_status, updated_at=now)
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
await session.refresh(item)
|
||||
raise AppError(
|
||||
ErrorCode.VERSION_CONFLICT,
|
||||
"这条内容刚被别人改过",
|
||||
details={"current_version": item.version},
|
||||
)
|
||||
await session.refresh(item)
|
||||
# 发布时间/归档时间记在 knowledge_doc 上(item 只有 status)
|
||||
doc.status_hint = to_status
|
||||
stamps = sidebar_fields(to_status, now)
|
||||
if "published_at" in stamps:
|
||||
doc.published_at = stamps["published_at"]
|
||||
doc.archived_at = None
|
||||
if "archived_at" in stamps:
|
||||
doc.archived_at = stamps["archived_at"]
|
||||
await _log(
|
||||
session,
|
||||
item,
|
||||
ctx.user.id,
|
||||
"knowledge_status_changed",
|
||||
{"action": body.action, "to": to_status, "note": body.note},
|
||||
)
|
||||
await audit_record(
|
||||
session,
|
||||
workspace_id=ws_id,
|
||||
actor_user_id=ctx.user.id,
|
||||
action=f"knowledge.{body.action}",
|
||||
target_type="item",
|
||||
target_id=str(item.id),
|
||||
payload={"to": to_status},
|
||||
)
|
||||
await session.flush()
|
||||
return ok(await _knowledge_out(session, ws_id, item, doc))
|
||||
|
||||
|
||||
# ── 从任务/工单沉淀 ─────────────────────────────────────────────────────
|
||||
@router.post("/items/{item_id}/knowledge", summary="把这件事沉淀成知识草稿")
|
||||
async def promote_to_knowledge(
|
||||
item_id: uuid.UUID,
|
||||
ctx: MemberDep,
|
||||
session: SessionDep,
|
||||
doc_type: Annotated[str, Query()] = "postmortem",
|
||||
) -> dict:
|
||||
ws_id = ctx.ws_id
|
||||
source = await session.get(Item, item_id)
|
||||
if source is None or source.workspace_id != ws_id or source.kind not in ("task", "ticket"):
|
||||
raise AppError(ErrorCode.ITEM_NOT_FOUND, "任务不存在")
|
||||
if doc_type not in DOC_TYPES:
|
||||
raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{doc_type}")
|
||||
|
||||
existing = await find_doc_by_source(session, ws_id, source.id)
|
||||
if existing is not None:
|
||||
raise AppError(
|
||||
ErrorCode.KNOWLEDGE_ALREADY_EXISTS,
|
||||
"这件事已经沉淀过了",
|
||||
details={"knowledge_id": str(existing.item_id)},
|
||||
)
|
||||
|
||||
resolution = ""
|
||||
if source.kind == "ticket":
|
||||
from ..models_ticket import TicketExt
|
||||
|
||||
ext = await session.get(TicketExt, source.id)
|
||||
if ext is not None and ext.resolution_md:
|
||||
resolution = ext.resolution_md
|
||||
|
||||
doc_item, doc = await create_draft_from_source(
|
||||
session,
|
||||
ws_id=ws_id,
|
||||
source=source,
|
||||
author_id=ctx.user.id,
|
||||
generated_by="rule" if source.kind == "ticket" else "human",
|
||||
resolution_md=resolution,
|
||||
doc_type=doc_type,
|
||||
)
|
||||
await _log(
|
||||
session, doc_item, ctx.user.id, "knowledge_created", {"source_item_id": str(source.id)}
|
||||
)
|
||||
await audit_record(
|
||||
session,
|
||||
workspace_id=ws_id,
|
||||
actor_user_id=ctx.user.id,
|
||||
action="knowledge.promote",
|
||||
target_type="item",
|
||||
target_id=str(doc_item.id),
|
||||
payload={"source_item_id": str(source.id)},
|
||||
)
|
||||
# 与"新建知识"返回同一种形状:知识本体 + source,前端不用记两套
|
||||
return ok(await _knowledge_out(session, ws_id, doc_item, doc))
|
||||
@@ -288,6 +288,41 @@ async def promote(item_id: uuid.UUID, body: PromoteIn, ctx: MemberDep, session:
|
||||
|
||||
|
||||
# -- 办理/验收 ----------------------------------------------------------
|
||||
async def _draft_knowledge(
|
||||
session: AsyncSession,
|
||||
ctx: TenantCtx,
|
||||
ticket: Item,
|
||||
ext: TicketExt,
|
||||
resolution_md: str,
|
||||
now: datetime,
|
||||
) -> None:
|
||||
"""验收通过时自动整理一份知识草稿(M3 闭环沉淀)。
|
||||
|
||||
只落 draft 不发布:自动整理的东西得有人扫一眼才敢进团队检索。
|
||||
同一张工单只沉淀一次(重复点验收、历史数据补跑都不会重复产出)。
|
||||
"""
|
||||
from ..services.knowledge import create_draft_from_source, find_doc_by_source
|
||||
|
||||
if await find_doc_by_source(session, ctx.ws_id, ticket.id) is not None:
|
||||
return
|
||||
doc_item, _doc = await create_draft_from_source(
|
||||
session,
|
||||
ws_id=ctx.ws_id,
|
||||
source=ticket,
|
||||
author_id=ctx.user.id,
|
||||
generated_by="rule",
|
||||
resolution_md=resolution_md or ext.resolution_md or "",
|
||||
doc_type="postmortem",
|
||||
)
|
||||
await _log(
|
||||
session,
|
||||
doc_item,
|
||||
ctx.user.id,
|
||||
"knowledge_created",
|
||||
{"source_item_id": str(ticket.id), "generated_by": "rule"},
|
||||
)
|
||||
|
||||
|
||||
async def _advance(
|
||||
ctx: TenantCtx,
|
||||
session: AsyncSession,
|
||||
@@ -391,6 +426,11 @@ async def _advance(
|
||||
body="它对应的工单已验收通过",
|
||||
)
|
||||
|
||||
# 验收通过 -> 顺手整理一份知识草稿(闭环沉淀)。
|
||||
# 只落草稿不发布:自动整理的东西得有人扫一眼才敢进团队检索。
|
||||
if action == "approve":
|
||||
await _draft_knowledge(session, ctx, ticket, ext, resolution_md, now)
|
||||
|
||||
await session.flush()
|
||||
return await _ticket_out(session, ctx.ws_id, ticket, ext)
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""知识域状态机与"从事项生成草稿"的纯函数(M3)。
|
||||
|
||||
为什么单独放一个模块:这两件事都是纯逻辑,不该缠在 HTTP 层或 ORM 里。
|
||||
状态机能被 5 行测试覆盖;草稿拼装能被快照测试比对。放进 API 模块就只能靠 e2e 兜。
|
||||
|
||||
两个刻意的设计:
|
||||
|
||||
1. **草稿默认落 `draft`,不自动发布**(用户拍板)。自动生成的东西一定会有"看着像那么回事、
|
||||
其实漏了关键前提"的情况,人扫一眼的成本远低于让团队按错的结论办事。
|
||||
2. **草稿正文如实转述来源**,不写"建议""应该"这类推断。LLM 阶段(M4)接进来时,
|
||||
`generated_by` 会从 `rule` 变成 `llm`,但"只转述、不改写结论"这条不变。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from ..errors import AppError, ErrorCode
|
||||
|
||||
KNOWLEDGE_TRANSITIONS: dict[str, tuple[str, ...]] = {
|
||||
"draft": ("in_review", "published", "archived"),
|
||||
"in_review": ("draft", "published", "archived"),
|
||||
"published": ("archived",),
|
||||
"archived": ("draft",),
|
||||
}
|
||||
|
||||
KNOWLEDGE_ACTIONS: dict[str, tuple[tuple[str, ...], str]] = {
|
||||
# action: (允许的前置状态, 目标状态)
|
||||
"submit_review": (("draft",), "in_review"),
|
||||
"withdraw": (("in_review",), "draft"),
|
||||
"publish": (("draft", "in_review"), "published"),
|
||||
"archive": (("draft", "in_review", "published"), "archived"),
|
||||
"restore": (("archived",), "draft"),
|
||||
}
|
||||
|
||||
# 生成草稿时给读者的一句提示,防止有人把草稿当已确认的结论转发
|
||||
DRAFT_BANNER = "> 这份草稿由系统根据来源自动整理,**请先核对再发布**;发布后才会进入团队检索。"
|
||||
|
||||
ACTION_LABEL: dict[str, str] = {
|
||||
"created": "创建",
|
||||
"status_changed": "状态变更",
|
||||
"promoted_to_ticket": "升级成工单",
|
||||
"ticket_created": "工单创建",
|
||||
"ticket_accept": "受理",
|
||||
"ticket_start": "开始办理",
|
||||
"ticket_submit": "提交验收",
|
||||
"ticket_approve": "验收通过",
|
||||
"ticket_reject": "验收驳回",
|
||||
"ticket_reassign": "改派",
|
||||
"ticket_cancel": "取消",
|
||||
"comment_added": "评论",
|
||||
"knowledge_created": "沉淀为知识",
|
||||
}
|
||||
|
||||
|
||||
def target_status(action: str, current: str) -> str:
|
||||
"""动作 → 目标状态。非法组合抛 7003,并把"现在还能做什么"带回去。"""
|
||||
rule = KNOWLEDGE_ACTIONS.get(action)
|
||||
if rule is None:
|
||||
raise AppError(
|
||||
ErrorCode.KNOWLEDGE_STATUS_INVALID,
|
||||
message=f"未知动作 {action}",
|
||||
details={"action": action, "allowed_actions": sorted(KNOWLEDGE_ACTIONS)},
|
||||
)
|
||||
allowed_from, to_status = rule
|
||||
if current not in allowed_from:
|
||||
allowed_actions = [a for a, (src, _) in KNOWLEDGE_ACTIONS.items() if current in src]
|
||||
raise AppError(
|
||||
ErrorCode.KNOWLEDGE_STATUS_INVALID,
|
||||
message=f"当前状态({current})不能{_label(action)}",
|
||||
details={
|
||||
"action": action,
|
||||
"from": current,
|
||||
"allowed_from": list(allowed_from),
|
||||
"allowed_actions": allowed_actions,
|
||||
},
|
||||
)
|
||||
return to_status
|
||||
|
||||
|
||||
def ensure_transition(current: str, target: str) -> None:
|
||||
"""直接改状态(不经过动作)时的校验。"""
|
||||
if target not in KNOWLEDGE_TRANSITIONS.get(current, ()):
|
||||
raise AppError(
|
||||
ErrorCode.KNOWLEDGE_STATUS_INVALID,
|
||||
message=f"不能从 {current} 直接变成 {target}",
|
||||
details={
|
||||
"from": current,
|
||||
"to": target,
|
||||
"allowed": list(KNOWLEDGE_TRANSITIONS.get(current, ())),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def sidebar_fields(target_status: str, now: datetime) -> dict[str, Any]:
|
||||
"""状态跃迁时要顺手维护的时间戳。"""
|
||||
if target_status == "published":
|
||||
return {"published_at": now, "archived_at": None}
|
||||
if target_status == "archived":
|
||||
return {"archived_at": now}
|
||||
return {}
|
||||
|
||||
|
||||
def can_publish_role(role: str) -> bool:
|
||||
"""发布是"团队背书",guest/member 只能提交审议。"""
|
||||
return role in ("owner", "admin")
|
||||
|
||||
|
||||
def _label(action: str) -> str:
|
||||
return {
|
||||
"submit_review": "提交审议",
|
||||
"withdraw": "撤回审议",
|
||||
"publish": "发布",
|
||||
"archive": "归档",
|
||||
"restore": "恢复",
|
||||
}.get(action, action)
|
||||
|
||||
|
||||
def render_draft(
|
||||
*,
|
||||
source_kind: str,
|
||||
title: str,
|
||||
body_md: str = "",
|
||||
resolution_md: str = "",
|
||||
created_at: datetime | None = None,
|
||||
closed_at: datetime | None = None,
|
||||
people: dict[str, str] | None = None,
|
||||
timeline: list[dict[str, Any]] | None = None,
|
||||
comments: list[dict[str, Any]] | None = None,
|
||||
) -> str:
|
||||
"""把一件事(任务或工单)整理成知识草稿的正文。
|
||||
|
||||
只做"结构化的转述":来源里有什么就写什么,没有的段落直接省略,
|
||||
不补写"经验教训"这类需要人判断的话。
|
||||
"""
|
||||
people = people or {}
|
||||
out: list[str] = [DRAFT_BANNER, ""]
|
||||
|
||||
when = _fmt(closed_at or created_at)
|
||||
origin = "工单" if source_kind == "ticket" else "任务"
|
||||
out.append(f"自动沉淀自{origin}「{title}」{(',' + when + ' 结束') if when else ''}。")
|
||||
out.append("")
|
||||
|
||||
if people:
|
||||
line = "、".join(f"{k}:{v}" for k, v in people.items() if v)
|
||||
if line:
|
||||
out.append(f"- {line}")
|
||||
out.append("")
|
||||
|
||||
out.append("## 背景")
|
||||
out.append("")
|
||||
out.append(
|
||||
body_md.strip() if body_md.strip() else "(来源里没有正文,补充一句这件事要解决什么。)"
|
||||
)
|
||||
out.append("")
|
||||
|
||||
rows = [r for r in (timeline or []) if r.get("action") in ACTION_LABEL]
|
||||
if rows:
|
||||
out.append("## 过程")
|
||||
out.append("")
|
||||
for r in rows:
|
||||
out.append(f"- {_fmt(r.get('at'))} {ACTION_LABEL[r['action']]}")
|
||||
out.append("")
|
||||
|
||||
if resolution_md.strip():
|
||||
out.append("## 结论")
|
||||
out.append("")
|
||||
out.append(resolution_md.strip())
|
||||
out.append("")
|
||||
|
||||
said = [c for c in (comments or []) if (c.get("body") or "").strip()]
|
||||
if said:
|
||||
out.append("## 讨论")
|
||||
out.append("")
|
||||
for c in said:
|
||||
who = c.get("author") or "某人"
|
||||
out.append(f"- **{who}**:{c['body'].strip()}")
|
||||
out.append("")
|
||||
|
||||
return "\n".join(out).rstrip() + "\n"
|
||||
|
||||
|
||||
def _fmt(value: Any) -> str:
|
||||
if isinstance(value, datetime):
|
||||
return value.strftime("%Y-%m-%d %H:%M")
|
||||
return ""
|
||||
@@ -50,6 +50,13 @@ class ErrorCode(IntEnum):
|
||||
TICKET_FORBIDDEN = 6004
|
||||
NOTIFICATION_NOT_FOUND = 6005
|
||||
|
||||
# 7xxx 知识域
|
||||
KNOWLEDGE_NOT_FOUND = 7001
|
||||
KNOWLEDGE_ALREADY_EXISTS = 7002
|
||||
KNOWLEDGE_STATUS_INVALID = 7003
|
||||
KNOWLEDGE_FORBIDDEN = 7004
|
||||
KNOWLEDGE_SOURCE_REQUIRED = 7005
|
||||
|
||||
|
||||
# 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层
|
||||
_HTTP_STATUS: dict[ErrorCode, int] = {
|
||||
@@ -83,6 +90,11 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
|
||||
ErrorCode.TICKET_STATUS_INVALID: 409,
|
||||
ErrorCode.TICKET_FORBIDDEN: 403,
|
||||
ErrorCode.NOTIFICATION_NOT_FOUND: 404,
|
||||
ErrorCode.KNOWLEDGE_NOT_FOUND: 404,
|
||||
ErrorCode.KNOWLEDGE_ALREADY_EXISTS: 409,
|
||||
ErrorCode.KNOWLEDGE_STATUS_INVALID: 409,
|
||||
ErrorCode.KNOWLEDGE_FORBIDDEN: 403,
|
||||
ErrorCode.KNOWLEDGE_SOURCE_REQUIRED: 409,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+2
-1
@@ -12,7 +12,7 @@ from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from .api import auth, items, tickets, workspaces
|
||||
from .api import auth, items, knowledge, tickets, workspaces
|
||||
from .config import get_settings
|
||||
from .db import SessionFactory, current_app_role, engine
|
||||
from .errors import AppError, ErrorCode, http_status_for
|
||||
@@ -104,6 +104,7 @@ app.include_router(items.router, prefix=settings.api_prefix)
|
||||
app.include_router(items.tag_router, prefix=settings.api_prefix)
|
||||
app.include_router(tickets.router, prefix=settings.api_prefix)
|
||||
app.include_router(tickets.notify_router, prefix=settings.api_prefix)
|
||||
app.include_router(knowledge.router, prefix=settings.api_prefix)
|
||||
|
||||
|
||||
@app.get("/health", tags=["运维"], summary="存活探针")
|
||||
|
||||
@@ -135,6 +135,7 @@ class AuditLog(Base):
|
||||
#
|
||||
# 任务域的表在 models_task.py、工单域在 models_ticket.py —— 它们 import 本模块的 Base,
|
||||
# 所以只能在文件末尾反向 import,否则循环导入。
|
||||
from .models_knowledge import KNOWLEDGE_TENANT_TABLES # noqa: E402
|
||||
from .models_task import TASK_TENANT_TABLES # noqa: E402
|
||||
from .models_ticket import TICKET_TENANT_TABLES # noqa: E402
|
||||
|
||||
@@ -145,4 +146,5 @@ TENANT_TABLES: tuple[str, ...] = (
|
||||
"audit_log",
|
||||
*TASK_TENANT_TABLES,
|
||||
*TICKET_TENANT_TABLES,
|
||||
*KNOWLEDGE_TENANT_TABLES,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""知识域模型(M3):knowledge_doc。
|
||||
|
||||
三条设计约束(对应 PRODUCT-PLAN 的 D1/D3):
|
||||
|
||||
1. **知识也是 `item`**。标题、正文、标签、评论、事件流全部复用;`knowledge_doc`
|
||||
只补"从哪来的、是什么类型、什么时候被人认可过"。同一套列表、搜索、详情、权限对知识依然成立。
|
||||
2. **知识必须有来源**(D3)。`source_kind` 为 `task` / `ticket` 时必须给出 `source_item_id`,
|
||||
只有 `manual`(自己记的笔记)允许留空。这条写在数据库 CHECK 里,不靠 API 自律——
|
||||
将来接 LLM 自动归档时,没有来源的知识等于"没人能核对的断言",比没有更糟。
|
||||
3. **状态机是 `draft → in_review → published → archived`**。草稿可以随时改,发布的要有
|
||||
人来点头(`published_at` 记时间),归档的不删只藏(历史结论不该消失,只该退出检索)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID as PgUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .models import Base, Timestamps
|
||||
|
||||
# 知识状态机(与任务/工单共用 item.status 一列,取值集合不同)
|
||||
KNOWLEDGE_STATUSES: tuple[str, ...] = ("draft", "in_review", "published", "archived")
|
||||
|
||||
DOC_TYPES: tuple[str, ...] = ("note", "howto", "faq", "postmortem", "decision")
|
||||
SOURCE_KINDS: tuple[str, ...] = ("manual", "task", "ticket")
|
||||
GENERATED_BY: tuple[str, ...] = ("human", "rule")
|
||||
|
||||
|
||||
class KnowledgeDoc(Base, Timestamps):
|
||||
"""知识的附加信息,与 item 一比一。"""
|
||||
|
||||
__tablename__ = "knowledge_doc"
|
||||
__table_args__ = (
|
||||
Index("ix_knowledge_ws_status", "workspace_id", "status_hint"),
|
||||
Index("ix_knowledge_ws_source", "workspace_id", "source_item_id"),
|
||||
CheckConstraint(
|
||||
"source_kind = 'manual' OR source_item_id IS NOT NULL",
|
||||
name="ck_knowledge_source_required",
|
||||
),
|
||||
CheckConstraint("chunk_count >= 0", name="ck_knowledge_chunk_count"),
|
||||
)
|
||||
|
||||
# item_id 既是主键又是外键:一条 item 最多一条 knowledge_doc,天然 1:1
|
||||
item_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
workspace_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
author_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
# 知识的来源。NULL 只允许出现在 source_kind='manual' 上(见 CHECK)
|
||||
source_item_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="SET NULL")
|
||||
)
|
||||
source_kind: Mapped[str] = mapped_column(String(16), nullable=False, default="manual")
|
||||
doc_type: Mapped[str] = mapped_column(String(16), nullable=False, default="note")
|
||||
# 冗余一份状态:列表页要按知识自己的口径筛,不想每次都回 item 表
|
||||
status_hint: Mapped[str] = mapped_column(String(16), nullable=False, default="draft")
|
||||
summary: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
generated_by: Mapped[str] = mapped_column(String(16), nullable=False, default="human")
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
chunk_count: Mapped[int] = mapped_column(nullable=False, default=0)
|
||||
|
||||
|
||||
KNOWLEDGE_TENANT_TABLES: tuple[str, ...] = ("knowledge_doc",)
|
||||
@@ -0,0 +1,136 @@
|
||||
"""知识域的数据库配套动作(M3)。
|
||||
|
||||
放在 services 而不是 api:两个入口都要用——用户手动点"沉淀成知识",
|
||||
以及工单验收通过时自动落一份草稿。逻辑只有一份,行为才不会分叉。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..domain.knowledge import render_draft
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models import AppUser
|
||||
from ..models_knowledge import KnowledgeDoc
|
||||
from ..models_task import Activity, Comment, Item
|
||||
|
||||
|
||||
async def snapshot_source(session: AsyncSession, ws_id: uuid.UUID, item: Item) -> dict[str, Any]:
|
||||
"""把来源事项的"人、时间线、讨论"取成一份快照,供 render_draft 使用。"""
|
||||
nicknames = dict(
|
||||
(await session.execute(select(AppUser.id, AppUser.nickname))).all() # type: ignore[arg-type]
|
||||
)
|
||||
timeline = (
|
||||
await session.execute(
|
||||
select(Activity.action, Activity.created_at)
|
||||
.where(Activity.workspace_id == ws_id, Activity.item_id == item.id)
|
||||
.order_by(Activity.created_at)
|
||||
)
|
||||
).all()
|
||||
comments = (
|
||||
await session.execute(
|
||||
select(Comment.body_md, Comment.author_id, Comment.created_at)
|
||||
.where(
|
||||
Comment.workspace_id == ws_id,
|
||||
Comment.item_id == item.id,
|
||||
Comment.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(Comment.created_at)
|
||||
)
|
||||
).all()
|
||||
return {
|
||||
"people": {
|
||||
"提出人": nicknames.get(item.creator_id, ""),
|
||||
"办理人": nicknames.get(item.assignee_id, "") if item.assignee_id else "",
|
||||
},
|
||||
"timeline": [{"action": a, "at": at} for a, at in timeline],
|
||||
"comments": [
|
||||
{"body": body, "author": nicknames.get(author_id, ""), "at": at}
|
||||
for body, author_id, at in comments
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def find_doc_by_source(
|
||||
session: AsyncSession, ws_id: uuid.UUID, source_item_id: uuid.UUID
|
||||
) -> KnowledgeDoc | None:
|
||||
return (
|
||||
await session.execute(
|
||||
select(KnowledgeDoc).where(
|
||||
KnowledgeDoc.workspace_id == ws_id,
|
||||
KnowledgeDoc.source_item_id == source_item_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_draft_from_source(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
ws_id: uuid.UUID,
|
||||
source: Item,
|
||||
author_id: uuid.UUID,
|
||||
generated_by: str = "rule",
|
||||
resolution_md: str = "",
|
||||
doc_type: str = "note",
|
||||
title: str | None = None,
|
||||
) -> tuple[Item, KnowledgeDoc]:
|
||||
"""从一条任务/工单生成知识草稿(draft)。同一个来源只沉淀一次。"""
|
||||
existing = await find_doc_by_source(session, ws_id, source.id)
|
||||
if existing is not None:
|
||||
raise AppError(
|
||||
ErrorCode.KNOWLEDGE_ALREADY_EXISTS,
|
||||
message="这件事已经沉淀过一份知识了",
|
||||
details={"knowledge_id": str(existing.item_id)},
|
||||
)
|
||||
|
||||
snap = await snapshot_source(session, ws_id, source)
|
||||
body = render_draft(
|
||||
source_kind=source.kind,
|
||||
title=source.title,
|
||||
body_md=source.body_md,
|
||||
resolution_md=resolution_md,
|
||||
created_at=source.created_at,
|
||||
closed_at=source.completed_at,
|
||||
people=snap["people"],
|
||||
timeline=snap["timeline"],
|
||||
comments=snap["comments"],
|
||||
)
|
||||
|
||||
doc_item = Item(
|
||||
workspace_id=ws_id,
|
||||
kind="knowledge",
|
||||
title=title or source.title,
|
||||
body_md=body,
|
||||
status="draft",
|
||||
priority=0,
|
||||
creator_id=author_id,
|
||||
)
|
||||
session.add(doc_item)
|
||||
await session.flush()
|
||||
|
||||
doc = KnowledgeDoc(
|
||||
item_id=doc_item.id,
|
||||
workspace_id=ws_id,
|
||||
author_id=author_id,
|
||||
source_item_id=source.id,
|
||||
source_kind="ticket" if source.kind == "ticket" else "task",
|
||||
doc_type=doc_type,
|
||||
status_hint="draft",
|
||||
summary=_first_line(resolution_md) or source.title,
|
||||
generated_by=generated_by,
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
return doc_item, doc
|
||||
|
||||
|
||||
def _first_line(text: str) -> str:
|
||||
for line in text.strip().splitlines():
|
||||
if line.strip():
|
||||
return line.strip()[:200]
|
||||
return ""
|
||||
@@ -0,0 +1,342 @@
|
||||
"""M3 知识域测试:草稿、来源、审议发布、隔离。
|
||||
|
||||
重点不是"接口能通",而是这几条容易做错的:
|
||||
|
||||
1. **知识的来源不可丢**:从工单沉淀出来的知识必须指回那张工单(`source_kind='ticket'`),
|
||||
否则半年后没人能核对这条结论从哪来;
|
||||
2. **自动沉淀只落草稿**:验收通过后知识是 `draft`,不是 `published`;
|
||||
3. **同一件事只沉淀一次**:重复点"沉淀成知识"要报 7002,而不是悄悄生成两份;
|
||||
4. **发布要管理员**:普通成员的发布请求必须是 403(7004);
|
||||
5. **跨租户不可见**:别人的知识列表为空、直接按 id 取是 7001。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from .conftest import create_workspace, login, unwrap
|
||||
|
||||
V1 = "/api/v1"
|
||||
|
||||
|
||||
async def _member(
|
||||
client: AsyncClient, owner: dict[str, Any], ws: dict[str, Any], role: str = "member"
|
||||
) -> dict[str, Any]:
|
||||
user = await login(client)
|
||||
inv = unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/invitations", json={"role": role}, headers=owner["headers"]
|
||||
)
|
||||
)
|
||||
unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/join", json={"code": inv["code"]}, headers=user["headers"]
|
||||
)
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
async def _task(client: AsyncClient, user: dict[str, Any], ws_id: str, title: str) -> dict:
|
||||
return unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws_id}/items",
|
||||
json={"title": title, "status": "todo"},
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _finish_ticket(
|
||||
client: AsyncClient, owner: dict[str, Any], ws_id: str, helper: dict[str, Any], task: dict
|
||||
) -> dict:
|
||||
"""把任务升级成工单并走完到验收通过,返回工单。"""
|
||||
ticket = unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws_id}/items/{task['id']}/promote",
|
||||
json={"assignee_id": helper["user_id"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)["ticket"]
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws_id}/items/{ticket['id']}/ticket/accept", headers=helper["headers"]
|
||||
)
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws_id}/items/{ticket['id']}/ticket/start", headers=helper["headers"]
|
||||
)
|
||||
unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws_id}/items/{ticket['id']}/ticket/submit",
|
||||
json={"note": "已经开了只读账号"},
|
||||
headers=helper["headers"],
|
||||
)
|
||||
)
|
||||
unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws_id}/items/{ticket['id']}/ticket/verify",
|
||||
json={"approve": True, "resolution_md": "账号权限已开通,走的是 A 方案"},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
return ticket
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_manual_knowledge_is_draft(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "知识手记")
|
||||
|
||||
data = unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge",
|
||||
json={
|
||||
"title": "报销单要贴发票原件",
|
||||
"body_md": "财务只认纸质发票,扫描件不行。",
|
||||
"doc_type": "note",
|
||||
"tag_names": ["财务"],
|
||||
},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
assert data["kind"] == "knowledge"
|
||||
assert data["status"] == "draft"
|
||||
assert data["knowledge"]["source_kind"] == "manual"
|
||||
assert data["knowledge"]["source_item_id"] is None
|
||||
assert [t["name"] for t in data["tags"]] == ["财务"]
|
||||
|
||||
listed = unwrap(
|
||||
await client.get(f"{V1}/workspaces/{ws['id']}/knowledge", headers=owner["headers"])
|
||||
)
|
||||
assert [i["id"] for i in listed["items"]] == [data["id"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ticket_approval_creates_draft_with_source(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "闭环沉淀")
|
||||
helper = await _member(client, owner, ws)
|
||||
task = await _task(client, owner, ws["id"], "开通测试账号权限")
|
||||
ticket = await _finish_ticket(client, owner, ws["id"], helper, task)
|
||||
|
||||
listed = unwrap(
|
||||
await client.get(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge",
|
||||
params={"status": "draft"},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
assert listed["has_more"] is False
|
||||
docs = [i for i in listed["items"] if i["knowledge"]["source_item_id"] == ticket["id"]]
|
||||
assert len(docs) == 1, "验收通过应当恰好沉淀一份草稿"
|
||||
doc = docs[0]
|
||||
assert doc["status"] == "draft", "自动沉淀只能是草稿,不能直接发布"
|
||||
assert doc["knowledge"]["source_kind"] == "ticket"
|
||||
assert doc["knowledge"]["generated_by"] == "rule"
|
||||
# 草稿正文必须带结论和"请先核对"的提示,否则用户会把它当已确认结论转发
|
||||
assert "账号权限已开通" in doc["body_md"]
|
||||
assert "请先核对再发布" in doc["body_md"]
|
||||
assert doc["source"]["kind"] == "ticket"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_promote_twice_conflicts(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "重复沉淀")
|
||||
task = await _task(client, owner, ws["id"], "写周报")
|
||||
|
||||
first = unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/knowledge",
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
assert first["knowledge"]["source_kind"] == "task"
|
||||
assert first["knowledge"]["generated_by"] == "human"
|
||||
|
||||
resp = await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/knowledge", headers=owner["headers"]
|
||||
)
|
||||
assert resp.json()["code"] == 7002
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_requires_admin(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "发布权限")
|
||||
member = await _member(client, owner, ws)
|
||||
doc = unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge",
|
||||
json={"title": "值班手册", "body_md": "先重启再上报。"},
|
||||
headers=member["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
reviewed = unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
|
||||
json={"action": "submit_review", "version": doc["version"]},
|
||||
headers=member["headers"],
|
||||
)
|
||||
)
|
||||
assert reviewed["status"] == "in_review"
|
||||
|
||||
denied = await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
|
||||
json={"action": "publish", "version": reviewed["version"]},
|
||||
headers=member["headers"],
|
||||
)
|
||||
assert denied.json()["code"] == 7004
|
||||
|
||||
published = unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
|
||||
json={"action": "publish", "version": reviewed["version"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
assert published["status"] == "published"
|
||||
assert published["knowledge"]["published_at"] is not None
|
||||
|
||||
# 已发布的内容不许直接改,先撤回
|
||||
blocked = await client.patch(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}",
|
||||
json={"title": "值班手册 v2", "version": published["version"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
assert blocked.json()["code"] == 7003
|
||||
|
||||
archived = unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
|
||||
json={"action": "archive", "version": published["version"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
assert archived["status"] == "archived"
|
||||
assert archived["knowledge"]["archived_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_illegal_action_reports_allowed(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "非法动作")
|
||||
doc = unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge",
|
||||
json={"title": "草稿"},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
|
||||
json={"action": "archive", "version": doc["version"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
resp = await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
|
||||
json={"action": "publish", "version": doc["version"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
body = resp.json()
|
||||
assert body["code"] == 7003
|
||||
assert body["data"]["allowed_from"] == ["draft", "in_review"]
|
||||
assert "restore" in body["data"]["allowed_actions"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_knowledge_is_tenant_scoped(client: AsyncClient) -> None:
|
||||
alice = await login(client)
|
||||
ws_a = await create_workspace(client, alice, "A 的知识")
|
||||
doc = unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws_a['id']}/knowledge",
|
||||
json={"title": "A 的内部结论"},
|
||||
headers=alice["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
bob = await login(client)
|
||||
ws_b = await create_workspace(client, bob, "B 的工作区")
|
||||
|
||||
listed = unwrap(
|
||||
await client.get(f"{V1}/workspaces/{ws_b['id']}/knowledge", headers=bob["headers"])
|
||||
)
|
||||
assert listed["items"] == []
|
||||
|
||||
cross = await client.get(
|
||||
f"{V1}/workspaces/{ws_b['id']}/knowledge/{doc['id']}", headers=bob["headers"]
|
||||
)
|
||||
assert cross.json()["code"] == 7001
|
||||
|
||||
not_member = await client.get(f"{V1}/workspaces/{ws_a['id']}/knowledge", headers=bob["headers"])
|
||||
assert not_member.json()["code"] == 3003
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_and_filters(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "检索")
|
||||
for title, doc_type in (
|
||||
("周报模板", "howto"),
|
||||
("客户投诉复盘", "postmortem"),
|
||||
("周报提醒", "note"),
|
||||
):
|
||||
unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge",
|
||||
json={"title": title, "body_md": f"{title} 的正文", "doc_type": doc_type},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
hit = unwrap(
|
||||
await client.get(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge",
|
||||
params={"q": "周报"},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
assert sorted(i["title"] for i in hit["items"]) == ["周报提醒", "周报模板"]
|
||||
|
||||
by_type = unwrap(
|
||||
await client.get(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge",
|
||||
params={"doc_type": "postmortem"},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
assert [i["title"] for i in by_type["items"]] == ["客户投诉复盘"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_uses_optimistic_lock(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "并发改")
|
||||
doc = unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge",
|
||||
json={"title": "初稿", "version": 0},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
updated = unwrap(
|
||||
await client.patch(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}",
|
||||
json={"body_md": "补一段", "version": doc["version"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
assert updated["version"] == doc["version"] + 1
|
||||
|
||||
stale = await client.patch(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}",
|
||||
json={"body_md": "又一段", "version": doc["version"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
assert stale.json()["code"] == 4009
|
||||
assert stale.json()["data"]["current_version"] == updated["version"]
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 知识闭环 e2e:一件事 → 知识草稿 → 核对 → 发布。
|
||||
*
|
||||
* 走的是真实浏览器 + 真实后端(8100)。前面造数据用接口,
|
||||
* 后面验证界面,这样脚本短、失败点也清楚。
|
||||
*/
|
||||
import { chromium } from "playwright-core";
|
||||
|
||||
const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
|
||||
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
|
||||
const stamp = String(Date.now()).slice(-7);
|
||||
const phone = `135${stamp}0`;
|
||||
|
||||
let failures = 0;
|
||||
function check(name, ok, extra = "") {
|
||||
console.log(`${ok ? "PASS" : "FAIL"} ${name}${extra ? ` — ${extra}` : ""}`);
|
||||
if (!ok) failures += 1;
|
||||
}
|
||||
|
||||
async function call(method, path, body, token) {
|
||||
const res = await fetch(`${API}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
...(method === "GET" ? {} : { "idempotency-key": `e2e-${Math.random()}` }),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
const json = text ? JSON.parse(text) : {};
|
||||
if (json.code !== 0) throw new Error(`${method} ${path} -> ${json.code} ${json.message}`);
|
||||
return json.data;
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({
|
||||
executablePath:
|
||||
process.env.CHROME ??
|
||||
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`,
|
||||
});
|
||||
const consoleErrors = [];
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error") consoleErrors.push(m.text());
|
||||
});
|
||||
page.on("response", (r) => {
|
||||
if (r.status() >= 400) consoleErrors.push(`${r.status()} ${r.url()}`);
|
||||
});
|
||||
|
||||
try {
|
||||
await call("POST", "/auth/sms/send", { phone });
|
||||
const login = await call("POST", "/auth/login", { phone, code: "123456" });
|
||||
const token = login.tokens.access_token;
|
||||
const ws = await call("POST", "/workspaces", { name: `知识闭环 ${stamp}` }, token);
|
||||
const task = await call(
|
||||
"POST",
|
||||
`/workspaces/${ws.id}/items`,
|
||||
{ title: "发票原件必须一起寄", body_md: "电子发票打印件不算,财务退回过一次。" },
|
||||
token,
|
||||
);
|
||||
const moved = await call(
|
||||
"POST",
|
||||
`/workspaces/${ws.id}/items/${task.id}/status`,
|
||||
{ status: "todo", version: task.version },
|
||||
token,
|
||||
);
|
||||
await call(
|
||||
"POST",
|
||||
`/workspaces/${ws.id}/items/${task.id}/status`,
|
||||
{ status: "done", version: moved.version },
|
||||
token,
|
||||
);
|
||||
|
||||
await page.addInitScript(
|
||||
([access, refresh]) => {
|
||||
localStorage.setItem("dadao.access", access);
|
||||
localStorage.setItem("dadao.refresh", refresh);
|
||||
},
|
||||
[token, login.tokens.refresh_token],
|
||||
);
|
||||
await page.goto(`${WEB}/w/${ws.id}`, { waitUntil: "networkidle" });
|
||||
await page.getByRole("button", { name: "已完成", exact: true }).click();
|
||||
await page.getByText("发票原件必须一起寄", { exact: false }).first().click();
|
||||
await page.getByRole("button", { name: "沉淀成知识", exact: true }).click();
|
||||
await page.getByText("已生成知识草稿", { exact: false }).waitFor({ timeout: 10_000 });
|
||||
check("任务详情能把这件事沉淀成草稿", true);
|
||||
|
||||
await page.getByRole("button", { name: "关闭", exact: true }).click();
|
||||
|
||||
await page.getByRole("link", { name: "知识", exact: true }).click();
|
||||
const card = page.getByText("发票原件必须一起寄", { exact: false }).first();
|
||||
await card.waitFor({ timeout: 10_000 });
|
||||
const draftLabelVisible = await page.getByText("草稿", { exact: true }).first().isVisible();
|
||||
check("草稿出现在「待核对」", draftLabelVisible);
|
||||
await page.screenshot({ path: "e2e/artifacts/knowledge-draft.png", fullPage: false });
|
||||
|
||||
await page.getByRole("button", { name: "打开", exact: true }).first().click();
|
||||
// 等这一个请求落地再断言,不然会和抽屉的刷新抢跑("已发布"这四个字在 tab 上也有一份)
|
||||
const [publishResp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.url().includes("/knowledge/") && r.url().endsWith("/status") && r.status() === 200,
|
||||
),
|
||||
page.getByRole("button", { name: "发布", exact: true }).click(),
|
||||
]);
|
||||
check("发布请求成功", publishResp.ok());
|
||||
await page.getByText(/已发布 · 版本 \d+/, { exact: false }).waitFor({ timeout: 10_000 });
|
||||
const published = await page.getByText(/已发布 · 版本 \d+/, { exact: false }).first().isVisible();
|
||||
check("核对后可以发布", published);
|
||||
|
||||
const again = await fetch(`${API}/workspaces/${ws.id}/knowledge`, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
}).then((r) => r.json());
|
||||
// 同标题可能有多条(每次跑脚本都会新建工作区,但标题固定在两处),取已发布的那条
|
||||
const doc = again.data.items.find(
|
||||
(d) => d.title === "发票原件必须一起寄" && d.knowledge.status_hint === "published",
|
||||
);
|
||||
check(
|
||||
"发布后接口里 published 且有发布时间",
|
||||
doc?.knowledge.status_hint === "published" && Boolean(doc?.knowledge.published_at),
|
||||
);
|
||||
check("正文带「请先核对再发布」的提示", String(doc?.body_md ?? "").includes("请先核对再发布"));
|
||||
await page.screenshot({ path: "e2e/artifacts/knowledge-published.png", fullPage: false });
|
||||
|
||||
check("浏览器控制台与网络无报错", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | "));
|
||||
} catch (err) {
|
||||
failures += 1;
|
||||
console.log(`FAIL 脚本异常 — ${err.message}`);
|
||||
await page.screenshot({ path: "e2e/artifacts/knowledge-failure.png" }).catch(() => {});
|
||||
console.log((await page.textContent("body"))?.slice(0, 600));
|
||||
} finally {
|
||||
await browser.close();
|
||||
console.log(failures === 0 ? "\nALL PASS" : `\n${failures} FAILED`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { WorkspacePage } from "./pages/Workspace";
|
||||
import { TasksPage } from "./pages/Tasks";
|
||||
import { BoardPage } from "./pages/Board";
|
||||
import { TicketsPage } from "./pages/Tickets";
|
||||
import { KnowledgePage } from "./pages/Knowledge";
|
||||
import { JoinPage } from "./pages/Join";
|
||||
|
||||
interface AuthState {
|
||||
@@ -86,6 +87,7 @@ export function App() {
|
||||
<Route path="/w/:wsId" element={<TasksPage />} />
|
||||
<Route path="/w/:wsId/board" element={<BoardPage />} />
|
||||
<Route path="/w/:wsId/tickets" element={<TicketsPage />} />
|
||||
<Route path="/w/:wsId/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
|
||||
<Route path="/join/:wsId/:code" element={<JoinPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -201,6 +201,25 @@ export function TaskDetail({
|
||||
}
|
||||
}
|
||||
|
||||
/** 把这件事整理成知识草稿。草稿默认待核对,不直接发布。 */
|
||||
async function toKnowledge() {
|
||||
if (!item) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.post(`${base}/knowledge?doc_type=${item.kind === "ticket" ? "postmortem" : "howto"}`);
|
||||
setNotice("已生成知识草稿。它现在在「知识 → 待核对」里,核对无误后再发布,才会进入团队检索。");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.code === 7002) {
|
||||
setNotice("这件事已经有一份知识草稿了,去「知识」页找它。");
|
||||
} else {
|
||||
setError(err instanceof ApiError ? err.message : "生成失败");
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function ticketAction(action: TicketAction, extra: Record<string, unknown> = {}) {
|
||||
if (!item) return;
|
||||
setBusy(true);
|
||||
@@ -412,6 +431,20 @@ export function TaskDetail({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.kind !== "knowledge" && item.status !== "canceled" && (
|
||||
<div className="mt-4 rounded-lg border border-ink-100 p-3">
|
||||
<p className="text-sm font-medium">这件事值得留下来?沉淀成知识</p>
|
||||
<p className="mt-1 text-xs text-ink-500">
|
||||
会把标题、备注、时间线和评论整理成一份草稿,默认进「待核对」,不会直接对外发布。
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<button className="btn-ghost px-3 py-1 text-sm" disabled={busy} onClick={() => void toKnowledge()}>
|
||||
{item.kind === "ticket" ? "沉淀成复盘" : "沉淀成知识"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notice && <p className="mt-3 text-sm text-emerald-700">{notice}</p>}
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
{busy && <p className="mt-3 text-xs text-ink-500">处理中…</p>}
|
||||
|
||||
@@ -6,6 +6,7 @@ const TABS = [
|
||||
{ path: "", label: "任务" },
|
||||
{ path: "/board", label: "看板" },
|
||||
{ path: "/tickets", label: "工单" },
|
||||
{ path: "/knowledge", label: "知识" },
|
||||
{ path: "/members", label: "成员" },
|
||||
];
|
||||
|
||||
|
||||
@@ -112,3 +112,31 @@ export interface Notification {
|
||||
read_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type KnowledgeStatus = "draft" | "in_review" | "published" | "archived";
|
||||
|
||||
export interface KnowledgeInfo {
|
||||
doc_type: string;
|
||||
summary: string;
|
||||
status_hint: KnowledgeStatus;
|
||||
source_kind: "manual" | "task" | "ticket";
|
||||
source_item_id: string | null;
|
||||
generated_by: "human" | "rule";
|
||||
published_at: string | null;
|
||||
archived_at: string | null;
|
||||
chunk_count: number;
|
||||
author_id: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeSource {
|
||||
id: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface Knowledge extends Item {
|
||||
knowledge: KnowledgeInfo;
|
||||
source: KnowledgeSource | null;
|
||||
activities?: Activity[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Layout } from "../components/Layout";
|
||||
import { WorkspaceNav } from "../components/WorkspaceNav";
|
||||
import { ApiError, api } from "../lib/client";
|
||||
import type { Knowledge, KnowledgeStatus } from "../lib/types";
|
||||
|
||||
const TABS = [
|
||||
{ key: "draft", label: "待核对", statuses: ["draft", "in_review"] },
|
||||
{ key: "published", label: "已发布", statuses: ["published"] },
|
||||
{ key: "archived", label: "已归档", statuses: ["archived"] },
|
||||
{ key: "all", label: "全部", statuses: [] },
|
||||
] as const;
|
||||
|
||||
const DOC_TYPE_LABEL: Record<string, string> = {
|
||||
note: "笔记",
|
||||
howto: "怎么做",
|
||||
faq: "常见问题",
|
||||
postmortem: "复盘",
|
||||
decision: "决策",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<KnowledgeStatus, string> = {
|
||||
draft: "草稿",
|
||||
in_review: "待审议",
|
||||
published: "已发布",
|
||||
archived: "已归档",
|
||||
};
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
manual: "手记",
|
||||
task: "来自任务",
|
||||
ticket: "来自工单",
|
||||
};
|
||||
|
||||
const ACTION_LABEL: Record<string, string> = {
|
||||
submit_review: "提交审议",
|
||||
withdraw: "撤回审议",
|
||||
publish: "发布",
|
||||
archive: "归档",
|
||||
restore: "恢复成草稿",
|
||||
};
|
||||
|
||||
export function KnowledgePage() {
|
||||
const { wsId = "" } = useParams();
|
||||
const [tab, setTab] = useState<(typeof TABS)[number]["key"]>("draft");
|
||||
const [q, setQ] = useState("");
|
||||
const [docs, setDocs] = useState<Knowledge[]>([]);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [draftTitle, setDraftTitle] = useState("");
|
||||
const reqId = useRef(0);
|
||||
|
||||
const load = useCallback(
|
||||
async (nextCursor: string | null) => {
|
||||
// 切 tab / 改搜索会连发请求,慢的回来会盖掉新结果
|
||||
const mine = ++reqId.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const qs = new URLSearchParams();
|
||||
const statuses = TABS.find((t) => t.key === tab)!.statuses;
|
||||
for (const s of statuses) qs.append("status", s);
|
||||
if (q.trim()) qs.set("q", q.trim());
|
||||
if (nextCursor) qs.set("cursor", nextCursor);
|
||||
const page = await api.get<{
|
||||
items: Knowledge[];
|
||||
next_cursor: string | null;
|
||||
has_more: boolean;
|
||||
}>(`/workspaces/${wsId}/knowledge?${qs.toString()}`);
|
||||
if (mine !== reqId.current) return;
|
||||
setDocs((prev) => (nextCursor ? [...prev, ...page.items] : page.items));
|
||||
setCursor(page.has_more ? page.next_cursor : null);
|
||||
} catch (err) {
|
||||
if (mine !== reqId.current) return;
|
||||
setError(err instanceof ApiError ? err.message : "知识加载失败");
|
||||
} finally {
|
||||
if (mine === reqId.current) setLoading(false);
|
||||
}
|
||||
},
|
||||
[wsId, tab, q],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void load(null);
|
||||
}, [load]);
|
||||
|
||||
async function addNote() {
|
||||
const title = draftTitle.trim();
|
||||
if (!title) return;
|
||||
try {
|
||||
const doc = await api.post<Knowledge>(`/workspaces/${wsId}/knowledge`, {
|
||||
title,
|
||||
doc_type: "note",
|
||||
});
|
||||
setDraftTitle("");
|
||||
setAdding(false);
|
||||
setTab("draft");
|
||||
setOpenId(doc.id);
|
||||
void load(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "保存失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">知识</h1>
|
||||
<p className="mt-0.5 text-xs text-ink-500">
|
||||
工单验收通过会自动整理一份草稿;核对无误再发布,发布后才会进入团队检索
|
||||
</p>
|
||||
</div>
|
||||
<WorkspaceNav wsId={wsId} />
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`rounded-full px-3 py-1 text-sm transition ${
|
||||
tab === t.key ? "bg-ink-900 text-white" : "bg-ink-100 text-ink-700 hover:bg-ink-100/70"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
className="input ml-auto w-56"
|
||||
placeholder="搜标题或正文…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<button className="btn-primary px-3 py-1 text-sm" onClick={() => setAdding((v) => !v)}>
|
||||
记一条
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{adding && (
|
||||
<div className="card mb-3">
|
||||
<p className="mb-2 text-xs text-ink-500">
|
||||
手记的知识没有来源,它是你自己的笔记;想沉淀某件事,去那件事的详情里点「沉淀成知识」。
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
placeholder="一句话标题,比如「报销单要贴发票原件」"
|
||||
value={draftTitle}
|
||||
onChange={(e) => setDraftTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void addNote();
|
||||
}}
|
||||
/>
|
||||
<button className="btn-primary px-3 py-1 text-sm" onClick={() => void addNote()}>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="mb-3 rounded-lg bg-red-50 p-2 text-sm text-red-700">{error}</p>}
|
||||
|
||||
{docs.length === 0 && !loading && (
|
||||
<div className="card text-center text-sm text-ink-500">
|
||||
{tab === "draft"
|
||||
? "还没有待核对的知识。把工单验收通过,或者在这里「记一条」。"
|
||||
: "这里还空着。"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="space-y-2">
|
||||
{docs.map((d) => (
|
||||
<li key={d.id} className="card">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<button className="min-w-0 flex-1 text-left" onClick={() => setOpenId(d.id)}>
|
||||
<p className="truncate text-sm font-medium">{d.title}</p>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-2 text-xs text-ink-500">
|
||||
<span className="rounded bg-ink-100 px-1.5 py-0.5">
|
||||
{DOC_TYPE_LABEL[d.knowledge.doc_type] ?? d.knowledge.doc_type}
|
||||
</span>
|
||||
<span>{STATUS_LABEL[d.knowledge.status_hint]}</span>
|
||||
<span>{SOURCE_LABEL[d.knowledge.source_kind] ?? d.knowledge.source_kind}</span>
|
||||
{d.source && <span className="truncate">「{d.source.title}」</span>}
|
||||
<span>{new Date(d.updated_at).toLocaleString("zh-CN")}</span>
|
||||
</p>
|
||||
</button>
|
||||
<button className="btn-ghost shrink-0 px-2 py-1 text-xs" onClick={() => setOpenId(d.id)}>
|
||||
打开
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{cursor && (
|
||||
<div className="mt-4 text-center">
|
||||
<button className="btn-ghost" disabled={loading} onClick={() => void load(cursor)}>
|
||||
{loading ? "加载中…" : "加载更多"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openId && (
|
||||
<KnowledgeDrawer
|
||||
wsId={wsId}
|
||||
itemId={openId}
|
||||
onClose={() => setOpenId(null)}
|
||||
onChanged={() => void load(null)}
|
||||
/>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
/** 知识详情抽屉:能改、能提交审议、能发布/归档。 */
|
||||
function KnowledgeDrawer({
|
||||
wsId,
|
||||
itemId,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
wsId: string;
|
||||
itemId: string;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [doc, setDoc] = useState<Knowledge | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`);
|
||||
setDoc(data);
|
||||
setTitle(data.title);
|
||||
setBody(data.body_md);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "加载失败");
|
||||
}
|
||||
}, [wsId, itemId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function save() {
|
||||
if (!doc) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await api.patch<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`, {
|
||||
title,
|
||||
body_md: body,
|
||||
version: doc.version,
|
||||
});
|
||||
setDoc(next);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.code === 4009) {
|
||||
setError("刚被别人改过,已刷新为最新内容");
|
||||
await load();
|
||||
} else {
|
||||
setError(err instanceof ApiError ? err.message : "保存失败");
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function act(action: string) {
|
||||
if (!doc) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await api.post<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}/status`, {
|
||||
action,
|
||||
version: doc.version,
|
||||
});
|
||||
setDoc(next);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "操作失败");
|
||||
await load();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const status = doc?.knowledge.status_hint;
|
||||
const canEdit = status === "draft" || status === "in_review";
|
||||
const actions: { key: string; label: string; show: boolean }[] = [
|
||||
{ key: "submit_review", label: ACTION_LABEL.submit_review, show: status === "draft" },
|
||||
{ key: "withdraw", label: ACTION_LABEL.withdraw, show: status === "in_review" },
|
||||
{
|
||||
key: "publish",
|
||||
label: ACTION_LABEL.publish,
|
||||
show: status === "draft" || status === "in_review",
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
label: ACTION_LABEL.archive,
|
||||
show: status === "draft" || status === "in_review" || status === "published",
|
||||
},
|
||||
{ key: "restore", label: ACTION_LABEL.restore, show: status === "archived" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-20 flex justify-end bg-ink-900/20" onClick={onClose}>
|
||||
<aside
|
||||
className="flex h-full w-full max-w-xl flex-col overflow-y-auto bg-white p-6 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{!doc ? (
|
||||
<p className="text-sm text-ink-500">{error ?? "加载中…"}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between text-xs text-ink-500">
|
||||
<span>
|
||||
{DOC_TYPE_LABEL[doc.knowledge.doc_type] ?? doc.knowledge.doc_type} ·{" "}
|
||||
{STATUS_LABEL[doc.knowledge.status_hint]} · 版本 {doc.version}
|
||||
</span>
|
||||
<button className="btn-ghost px-2 py-1 text-xs" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 rounded-lg border border-ink-100 bg-ink-50 p-3 text-xs text-ink-700">
|
||||
{doc.source ? (
|
||||
<p>
|
||||
来源:{doc.source.kind === "ticket" ? "工单" : "任务"}「{doc.source.title}」
|
||||
</p>
|
||||
) : (
|
||||
<p>来源:手记(没有关联的事项)</p>
|
||||
)}
|
||||
<p className="mt-1">
|
||||
{doc.knowledge.generated_by === "rule"
|
||||
? "这份草稿由系统整理,请核对后再发布。"
|
||||
: "手动创建。"}
|
||||
{doc.knowledge.published_at &&
|
||||
` 发布于 ${new Date(doc.knowledge.published_at).toLocaleString("zh-CN")}。`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
className="input mb-3"
|
||||
value={title}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className="input mb-3 min-h-[280px] font-mono text-xs"
|
||||
value={body}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
/>
|
||||
|
||||
{canEdit && (
|
||||
<button className="btn-primary mb-4 self-start" disabled={busy} onClick={() => void save()}>
|
||||
{busy ? "保存中…" : "保存"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{actions
|
||||
.filter((a) => a.show)
|
||||
.map((a) => (
|
||||
<button
|
||||
key={a.key}
|
||||
className={a.key === "publish" ? "btn-primary px-3 py-1 text-sm" : "btn-ghost px-3 py-1 text-sm"}
|
||||
disabled={busy}
|
||||
onClick={() => void act(a.key)}
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-700">{error}</p>}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user