Compare commits

...
Author SHA1 Message Date
steven 2a118ad407 fix(deploy): venv 引导(缺 ensurepip)与国内镜像
CI / backend (push) Canceled after 0s
2026-10-30 21:53:42 +08:00
steven ea5d28304e fix(db): 0006/0007 迁移补上 APP_ROLE 导入 2026-10-30 21:53:42 +08:00
steven 57767c77a1 feat(deploy): openclaw-01 生产部署脚本、systemd 单元与部署文档 2026-10-30 18:49:31 +08:00
steven 9ac3b46430 feat(auth): 内测登录改为显式开关,并支持后端单端口托管前端产物 2026-10-30 18:49:30 +08:00
steven 2dbe4cf675 feat(db): 迁移里的数据库角色名改为环境变量注入 2026-10-30 18:49:30 +08:00
Steven 7c84ff3b8d feat(knowledge): 知识能挂附件,新建时就能传
CI / backend (push) Canceled after 0s
知识是备忘录,备忘录不能只有字:手册、日志、导出表格都得能挂上去。
附件本来就落在 item 上(`ALLOWED_KINDS` 早含 knowledge),缺的是"新建时
还没有条目 id"这一段。

- 懒建草稿:`KnowledgeComposer` 第一次传附件(或往正文贴图)时先 `POST /knowledge`
  存一条草稿拿到 id,`draftingRef` 去重防止贴图与上传同时打出两条草稿;
  之后的保存走 `PATCH /knowledge/{id}`。没传过附件就取消,库里不留空条目。
- `RichTextEditor` 增 `ensureItemId`:没有 itemId 时先把草稿存下来再传图,
  于是"新建时不能贴图"这条限制也没了;工具栏上传按钮按是否有落点显隐。
- 新建面板加了附件区:有草稿 id 就是正常的 `AttachmentPanel`(上传/下载/删),
  没有就先给一个「添加附件」按钮,点一下建草稿。
- 安卓知识详情加附件区:列表(文件名 + 大小)、`OpenDocument` 上传任意文件、
  点「打开」用浏览器看签名直链。编辑器原本只能插图,现在手册/日志也能传。
- 文档:API.md 说明附件三类事项共用同一套接口,以及懒建草稿的先后顺序;
  ROADMAP 加 3.13。

验证:后端 pytest 94 passed、ruff 全绿;Web `tsc --noEmit` 通过、`vite build` 通过;
`knowledge-notebook.mjs` 15 项全绿(新增「新建面板里能上传附件」「详情抽屉里能看到
刚传的附件」「附件挂在知识上,详情回读得到」)。安卓端仍是 JDK 25 + Gradle 8.7
不兼容,本次未编译验证。
2026-10-30 16:18:32 +08:00
Steven ffae7e380f fix(knowledge): PATCH 知识时带上 doc_type 直接 500
`patch_knowledge` 把 `doc_type`/`summary` 也塞进了给 `items` 表的 UPDATE,
于是 SQLAlchemy 报 `CompileError: Unconsumed column names: doc_type`——改类型的
请求一律 500。这两个字段本来就属于 `knowledge_doc`,下面几行已有赋值,重复写
进 items 的 values 是错的。

现在 `values` 只放 items 的列(title/body_md),`doc_changed`(doc_type/summary)
单独参与"要不要推进 version"的判断,语义不变:只要这条知识的样子变了,
version 就得 +1,否则下一次保存会拿旧 version 撞 4009。

补一个用例:PATCH 同时改 title/body_md/doc_type/tag_names 应该 200,
doc_type 生效、version 前进一步。这个 bug 之前没暴露,是因为 Web 抽屉只改
标签和分类,没人用 PATCH 改过类型。
2026-10-30 16:18:05 +08:00
Steven 9a382e4e8c fix(knowledge): 新建知识的正文换成富文本编辑器,不再是纯文本框
CI / backend (push) Canceled after 0s
「记一条」面板里正文是个 textarea,排版、表格、贴图都没有——创建时反而比
详情页更寒酸。现在新建面板直接用详情同款 Vditor 编辑器,创建与修改一个模样。

- `RichTextEditor` 的 `itemId` 改为可选:新建时还没有条目 id,正文照写,
  只是上传按钮不摆出来;真去贴图会提示「先保存这条内容,再回来贴图」。
- `KnowledgeComposer` 的正文换用 `RichTextEditor`(minHeight 320),页脚提示
  按有没有条目 id 给不同文案。
- 安卓:新建对话框不再放纯文本正文框,保存后直接进 `RichTextEditorScreen`
  写正文(排版、表格、贴图都在那儿),写完回到列表并展开详情。
- e2e 增加一条断言:新建面板里的正文就是富文本编辑器(aria-label=正文 的
  contenteditable),正文按真实键盘输入后 Markdown 正常落库。

验证:`tsc --noEmit` 通过、`vite build` 通过、`knowledge-notebook.mjs` 11 项
全绿(新增「新建面板的正文是富文本编辑器」)。安卓端本机仍是 JDK 25 +
Gradle 8.7/AGP 8.7.3 不兼容,本次未编译验证。
2026-10-30 15:52:09 +08:00
Steven ed31af3f46 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 不支持,本次未做编译验证。
2026-10-30 03:51:43 +08:00
Steven 91b3dde670 feat(notify): 站内通知与手工开单,指派/改状态/评论/验收都有人收到
CI / backend (push) Canceled after 0s
M2 最后一块:让"该我办了"能出现在系统里,并且允许不经过任务直接开单。

通知(backend/app/services/notify.py 统一出口)
- 七类触发:task_assigned、task_status_changed、item_commented、
  ticket_assigned、ticket_submitted、ticket_approved/rejected、task_unblocked
- 不通知自己:notify() 按 actor_id 跳过,调用方不用各自判断
- 已读:GET /notifications、/unread-count、POST /read、/read-all

手工开单
- POST /tickets:直接指定人、初始 open,与任务升级单共用同一条 kind='ticket' item,
  ticket_ext.parent_item_id 为空,验收通过不回流任务
- 指派前 _ensure_member 校验在册成员,越界指派返回 1001(避免"挂在看不到的名字上")

前端
- Web:通知页 /w/:wsId/notifications(未读/全部,点开标已读并跳目标);
  工单页"新建"面板;AppShell 每 30 秒对未读数,标已读后 dispatch notifications:changed 立刻刷新;
  任务与工单支持 ?open= 深链落地即展开
- 安卓:五个 tab 加"通知",未读数 30 秒刷新;工单动作按需求人/办理人/管理员显隐;手工开单对话框

验证
- pytest 85 passed(新增 backend/tests/test_notifications.py 6 个用例)
- ruff check / format --check 全 0
- tsc --noEmit 通过;vite build 成功
- web/e2e/notifications.mjs 12 项断言全绿(手工开单 → B 收到 → 点开跳转 → 未读归零)
- 安卓:本机 JDK 25 与 Gradle 8.7 / AGP 8.7.3 不兼容,本次未做编译验证,
  装回 JDK 17 后第一件事是 assembleDebug
2026-10-30 02:19:42 +08:00
dadao d079564d6c feat(knowledge): 语义检索打通——切片、混合打分、只搜已发布
CI / backend (push) Canceled after 0s
之前知识只能"存",不能"找"。这一版把链路补上:切片 → 向量 → 打分 → 带来源返回。

- chunk 表 + 迁移 0006:正文按标题归段切、代码块整块保留、超长按句切、
  相邻片重叠 80 字;RLS 与 GRANT 与其它租户表一致
- 向量提供方做成协议:内置 hash(字符二元组,零依赖可复现,作为基线)
  与 OpenAI 兼容端点两种,换实现不动业务代码
- 检索是混合打分:向量 0.6 + pg_trgm 0.4。专有编号、单号这类必须原样命中的词
  靠词法兜,换个说法表达的同一件事靠向量兜
- 三条纪律:只搜 published(过滤写在 SQL 里)、低于阈值宁可不返回、
  每条命中都带来源标题与 item_id
- 发布时自动建索引,改完正文用 POST /knowledge/{id}/reindex 重建
- Web 知识页加检索框与命中卡片;新增 e2e/retrieval.mjs 6 项

为什么不是 pgvector:本机 Docker 起不来、fnsvr-01 没有该扩展且明确不动。
先用 real[] + SQL 余弦把链路跑通,将来换列类型与索引即可,接口不变。

后端 79 passed,e2e smoke/knowledge/richtext/retrieval 全绿。
2026-10-29 04:28:09 +08:00
dadao d509f0c335 fix(android): 编辑器改成整页显示,保存后回到刚才那条详情
原来编辑器是叠在工作区上的 Box,而详情抽屉/对话框是独立窗口,永远盖在它上面——用户只能看到编辑器的一条顶栏。改成编辑器替换整页;保存成功后父层记下 itemId,回到工作区由对应页面刷新并重新打开那条详情。取消则什么都不变。
2026-10-29 04:01:28 +08:00
66 changed files with 5618 additions and 310 deletions
+117 -8
View File
@@ -227,6 +227,20 @@ canceled→ todo (恢复)
工单**不是**另一张表里的另一种东西,它是一条 `kind='ticket'` 的 item 加一行 `ticket_ext`。
所以详情仍然走 `GET /items/{item_id}`,响应里多一个 `ticket` 字段。
### 4.0 `POST /tickets`(手工开单)
```json
{ "title": "核对 9 月发票", "body_md": "可选说明",
"assignee_id": "uuid", "priority": 2, "due_at": "2026-10-30T10:00:00+08:00" }
```
返回与详情同构的工单对象(item + `ticket`)。与 4.1 的区别只有一处:**没有来源任务**,
`ticket.parent_item_id` 为 `null`,因此验收通过后不会回流任何任务(但同样会沉淀一份知识草稿)。
- `title` 必填、`assignee_id` 必须是工作区内 `active` 成员,否则 `1001`。
- 初始状态 `open`,被指派人立刻收到 `ticket_assigned` 通知。
- 手工单与升级单共用同一条 `item`,所以看板、评论、验收、知识沉淀全部复用,不需要另一套接口。
### 4.1 `POST /items/{item_id}/promote`(任务升级成工单)
```json
@@ -286,8 +300,26 @@ canceled→ todo (恢复)
| POST | `/notifications/{id}/read` | 标一条 |
| POST | `/notifications/read-all` | 全标已读 |
`kind`:`ticket_assigned` / `ticket_submitted` / `ticket_approved` / `ticket_rejected` / `task_unblocked`。
通知只发给当事人,同工作区里其他人看不到——这是"协同"与"广播"的区别。
`kind` 一共七种,都是"某人在某件事上做了什么":
| kind | 什么时候发 | 发给谁 |
|---|---|---|
| `task_assigned` | 建任务/改任务时指派了人 | 被指派人 |
| `task_status_changed` | 任务状态变化 | 创建人 + 办理人(除操作者本人) |
| `item_commented` | 有人评论 | 创建人 + 办理人(除评论者本人) |
| `ticket_assigned` | 升级成工单 / 手工开单 / 改派 | 办理人 |
| `ticket_submitted` | 办理人提交验收 | 需求人 |
| `ticket_approved` / `ticket_rejected` | 验收通过 / 退回 | 办理人 |
| `task_unblocked` | 工单验收通过、原任务回流待办 | 原任务创建人 |
三条硬规则:
1. **不通知自己**:谁操作就跳过谁,否则每个人都会收到自己刚做的事。
2. 通知里必须写清"谁 + 做了什么 + 在哪件事上",否则点开等于重新看一遍列表。
3. 通知只是提示,不是凭证——点开之后目标接口会重新校验权限、重新取数据。
Web 端未读数由 `AppShell` 每 30 秒轮询一次;在通知页标已读后立刻派发 `notifications:changed` 事件刷新角标,不必等下一轮。
安卓端同样按 30 秒/切页刷新。
### 4.6 工单的错误码
@@ -310,22 +342,30 @@ canceled→ todo (恢复)
| `status` | 可重复;`draft`/`in_review`/`published`/`archived`,不传为全部 |
| `doc_type` | `note`/`howto`/`faq`/`postmortem`/`decision` |
| `source_item_id` | 按来源事项筛 |
| `category_id` | 按分类筛,**包含子分类** |
| `uncategorized` | `true` 只看未分类 |
| `tag` | 按标签名筛(不区分大小写) |
| `q` | 标题 / 正文 / 摘要子串 |
| `limit` | 1–100,默认 30 |
| `cursor` | 上一页的 `next_cursor` |
返回 `{items, next_cursor, has_more}`。每条是 `item` 字段 + `knowledge` + `source`:
返回 `{items, next_cursor, has_more}`。每条是 `item` 字段 + `knowledge` + `category` + `source`:
```json
{
"id": "…", "kind": "knowledge", "title": "发票原件必须一起寄",
"body_md": "…", "status": "published", "tags": [],
"body_md": "…", "status": "published", "tags": [{"id": "…", "name": "财务"}],
"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": "…"
},
"category": {
"id": "…", "parent_id": "…", "name": "回滚", "depth": 3,
"sort_order": 0, "version": 1,
"path": ["研发", "部署", "回滚"], "full_path": "研发 / 部署 / 回滚"
},
"source": {"id": "…", "kind": "task", "title": "发票原件必须一起寄", "status": "done"}
}
```
@@ -342,19 +382,43 @@ canceled→ todo (恢复)
```json
{"title": "报销单要贴发票原件", "body_md": "…", "doc_type": "note",
"tag_names": ["财务", "报销"], "category_id": "可选的分类 id",
"source_item_id": "可选的来源事项 id"}
```
- 不带 `source_item_id` → `source_kind=manual`(手记),允许没有来源。
- 带 → 来源必须是同工作区的 `task`/`ticket`,否则 `7005`。
- `category_id` 可省(未分类);跨工作区的分类一律按不存在处理(`7006`)。
- `tag_names` 按名字去重创建,空格/逗号分隔由前端拆好再传。
- 新知识一律 `draft`,不会直接对外可见。
### 5.4 `PATCH /knowledge/{item_id}` — 修改内容
必须带 `version`(乐观锁,冲突 `4009`)。可改 `title`/`body_md`/`summary`/`doc_type`。
必须带 `version`(乐观锁,冲突 `4009`)。可改 `title`/`body_md`/`summary`/`doc_type`/`tag_names`/`category_id`;
传 `clear_category: true` 表示移到未分类。
**已发布的不许直接改**(`7003`,先撤回审议或归档),避免"发布后被悄悄改写"。
### 5.5 `POST /knowledge/{item_id}/status` — 状态动作
### 5.5 知识分类 `/knowledge/categories`
分类是工作区级的三级小树,用来给知识安一个位置;它**不是权限边界**,只是归类。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/knowledge/categories` | 扁平列表(按 depth/sort_order 排好),每条带 `doc_count`、`path`、`full_path`;另有 `max_depth=3` |
| POST | `/knowledge/categories` | `{name, parent_id?, sort_order?}` |
| PATCH | `/knowledge/categories/{id}` | `{name?, parent_id?, move?, sort_order?, version}` |
| DELETE | `/knowledge/categories/{id}?force=false` | 有子分类不让删;有知识要 `force=true`,删后知识变未分类 |
三条规则:
1. **最多三级**。第四级不是"更细",是"找不到",创建/移动越界一律 `7007`。
2. **同一层不能重名**(不区分大小写),否则 `7008`;不同层可以同名。
3. **移动带着子树走**:把一棵三层树挪到第二层下面会让最深节点到第五层,这种移动直接拒绝(`7007`),
不静默改层级。
`PATCH` 里 `move=true` 才处理 `parent_id`,避免"只改名字"被误当成移动。改名的 `version` 冲突是 `4009`。
### 5.6 `POST /knowledge/{item_id}/status` — 状态动作
| action | 允许的前置状态 | 结果 | 谁能做 |
|---|---|---|---|
@@ -369,7 +433,7 @@ canceled→ todo (恢复)
**发布要 owner/admin 的理由**:发布等于团队为这条结论背书,不能谁都能推。
### 5.6 `POST /items/{item_id}/knowledge` — 把一件事沉淀成知识
### 5.7 `POST /items/{item_id}/knowledge` — 把一件事沉淀成知识
Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;工单会带出验收结论。
返回与 5.2 相同的知识对象;同一来源重复沉淀 → `7002`。
@@ -377,7 +441,45 @@ Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;
**工单验收通过时,后端会自动调用这条逻辑**(`generated_by=rule`,`doc_type=postmortem`,
正文开头固定一句"这份草稿由系统根据来源自动整理,请先核对再发布"),所以用户不需要记得点。
### 5.7 知识的错误码
### 5.8 检索 `GET /knowledge/search`
Query:
| 参数 | 说明 |
|---|---|
| `q` | 必填,1–200 字 |
| `limit` | 1–30,默认 10 |
| `mode` | `hybrid`(默认)/ `vector` / `keyword`,后两个用于对比效果 |
返回 `{query, mode, items[]}`,每项:
| 字段 | 说明 |
|---|---|
| `item_id` / `title` | 来源知识,前端"打开原文"用 |
| `doc_type` / `heading` | 类型与所在小节 |
| `idx` / `content` | 第几片、切片正文 |
| `score` | 加权总分,低于 `SEARCH_MIN_SCORE`(0.06)的结果**不返回** |
| `vector_score` / `lexical_score` | 向量分与 trigram 词法分,便于排查"为什么没搜到" |
三条约定:
1. **只检索 `published`**。草稿是还没被人认可的话,进检索等于把猜测当结论引用出去——过滤写在 SQL 里,调用方改不了。
2. **结果必须带来源**,能点回原文。这是 D3(知识必须有来源)的延伸:不给"出处不存在"的答案。
3. **宁可返回空,也不凑数**。检索失败比误导便宜。
发布(`5.6` 的 `publish`)时会自动重建索引,用户发布完立刻能搜到;
但改完正文不会自动重建,需要调用下面这条。
### `POST /knowledge/{item_id}/reindex` — 重建切片
返回 `{item_id, chunk_count, searchable}`。`searchable=false` 表示内容已建好切片但还不是 `published`,
搜不到——这不是错误。
实现:切片存 `chunk` 表(`content` + `embedding real[]`),相似度用 SQL 函数 `app_cosine()` 计算。
本机没有可用的 pgvector 实例(Docker 起不来,fnsvr-01 明确不动),所以先用 `real[]` 把链路跑通;
将来换 pgvector 只改列类型与索引,接口不变。换 embedding 模型(`EMBEDDING_PROVIDER`)后
**必须整体重建**,否则新旧向量混在一张表里,分数没法比较。
### 5.9 知识的错误码
| 码 | HTTP | 含义 |
|---|---|---|
@@ -386,6 +488,9 @@ Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;
| 7003 | 409 | 当前状态不允许这个动作 |
| 7004 | 403 | 权限不足(如非管理员发布) |
| 7005 | 409 | 来源事项无效或缺失 |
| 7006 | 404 | 分类不存在,或不是本工作区的 |
| 7007 | 422 | 分类层级超过三级,或移动后会让子树超过三级 |
| 7008 | 409 | 分类重名 / 还有子分类 / 分类下还有知识(需 `force`) |
---
@@ -412,8 +517,12 @@ Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;
附件对象的字段:`id / item_id / filename / content_type / size / sha256 / uploader_id / created_at / raw_url / can_delete`。
`raw_url` 是**后端签发的短期直链**(默认 30 分钟),客户端直接用它渲染,不需要自己拼令牌。
附件可挂在 `kind` 为 `task / ticket / knowledge` 的事项上(`ALLOWED_KINDS`),知识与任务、工单共用同一套接口;
事项与知识的列表接口会附带 `attachment_count`,详情接口附带 `attachments` 全量。
新建知识时条目还不存在,前端用「懒建草稿」:点上传(或往正文里贴图)时先 `POST /knowledge` 存一条草稿拿到 `item_id`,
再上传到 `/items/{item_id}/attachments`,之后的保存一律走 `PATCH /knowledge/{item_id}`。没传过附件就取消的人,库里不会留下空条目。
### 6.2 图片直链:短期签名,而不是把令牌写进正文
`<img>` 发不出 `Authorization` 头。两种解法:把图片读成 base64 内联(正文膨胀几十倍,破坏检索),
+118
View File
@@ -0,0 +1,118 @@
# 部署(生产 · openclaw-01)
> 适用于当前唯一的线上环境:`openclaw-01`(192.168.8.188),访问入口 `http://192.168.8.188:8200`。
> 本地开发见 [`DEVELOPMENT.md`](DEVELOPMENT.md)。部署铁律见仓库根 [`AGENTS.md`](../AGENTS.md) 第 2 节。
## 1. 生产拓扑
```
浏览器 ──HTTP──▶ openclaw-01:8200
└ gunicorn(2 × UvicornWorker) ── app.main:app
├ /api/v1/** 业务 API
└ / 前端产物 web/dist(单页应用,404 回退 index.html)
│
┌────────────────────────┼────────────────────────┐
▼ ▼ ▼
fnsvr-01:8432 PG 17.6 fnsvr-01:6379 Redis 本机 backend/var/uploads
库 dadaotask_prod db 6(独立库号) 附件文件
角色 dadao_prod_app(RLS)
角色 dadao_prod_owner(迁移)
```
为什么是这个形状:
- **生产库和开发库在同一个 PG 实例**(fnsvr-01),所以生产用**独立的库 + 独立的角色**。复用 `dadao_app` 等于拿到开发口令就能连生产。迁移里的 `GRANT`/`DEFAULT PRIVILEGES` 按 `APP_DB_ROLE` / `OWNER_DB_ROLE` 生成,靠 `backend/app/roles.py` 注入。
- **Redis 用 6 号库**隔离:共享实例没有密码,靠库号避免和别的项目撞键(验证码、幂等键)。
- **没有 nginx**:openclaw 账号无 sudo 密码,改不了系统 nginx,所以后端单端口同时托管 API 与前端产物(同源,免 CORS)。root 愿意配合时按 `deploy/prod/nginx.dadaotask.conf.example` 加反代,不用改应用。
- **用户级 systemd**:openclaw-01 上所有服务(easymemo、portal-site…)都是 `systemctl --user` 起的,本方案沿用,全程不需要 root。
## 2. 首次部署(一次性)
前置:能 SSH 到 openclaw-01;fnsvr-01 的 root 权限(只在建库这一步需要)。
### 2.1 建生产库与角色(在 fnsvr-01 上,root)
```bash
# 口令自己生成,别用示例里的
OWNER_PW='<dadao_prod_owner 的口令>' APP_PW='<dadao_prod_app 的口令>' \
bash deploy/prod/provision-db.sh
```
会创建:库 `dadaotask_prod`(owner `dadao_prod_owner`)、角色 `dadao_prod_owner` / `dadao_prod_app`(`NOSUPERUSER NOBYPASSRLS`,否则 RLS 会被跳过)、`public` schema 归 owner、`REVOKE CREATE ... FROM PUBLIC`。建表与 RLS 策略由 alembic 负责(2.4)。
### 2.2 代码落地(在 openclaw-01 上,openclaw 身份)
```bash
git clone --branch main --single-branch http://gitea.tatta.cn/Tatta/DadaoTask.git ~/app/dadaotask
```
### 2.3 配置(不进仓库)
```bash
mkdir -p ~/.config/dadaotask && chmod 700 ~/.config/dadaotask
cp ~/app/dadaotask/deploy/prod/env.production.example ~/.config/dadaotask/env.production
chmod 600 ~/.config/dadaotask/env.production
vim ~/.config/dadaotask/env.production # 替换全部 __CHANGE_ME__
```
必填项:`DATABASE_URL` / `MIGRATION_DATABASE_URL`(2.1 的口令)、`JWT_SECRET`(≥32 位随机串)、`REDIS_URL`、`WEB_DIST_DIR`、`UPLOAD_DIR`。
**当前状态是内测模式**:短信通道还没接(非 dev provider 直接 501),所以 `SMS_PROVIDER=dev` + `INTERNAL_TEST_LOGIN=true`,固定验证码 `123456`。启动日志会打 WARNING。**只允许内网访问**;短信通道接好后删掉 `INTERNAL_TEST_LOGIN` 并把 `SMS_PROVIDER` 换成真实供应商。
### 2.4 装服务单元
```bash
mkdir -p ~/.config/systemd/user
cp ~/app/dadaotask/deploy/prod/dadaotask-api.service ~/.config/systemd/user/
systemctl --user daemon-reload
```
### 2.5 首次上线
```bash
bash ~/app/dadaotask/deploy/prod/deploy.sh
```
## 3. 日常发布(每次上线)
```bash
# 本地:提交并 push 到 main(生产只跑仓库 HEAD)
# 服务器:
bash ~/app/dadaotask/deploy/prod/deploy.sh
```
脚本做六件事,任何一步失败即中止:代码对齐 `origin/main`(`git reset --hard`,禁止服务器手工改)→ 装 Python 依赖 → `alembic upgrade head`(用迁移角色)→ **在服务器上** `npm ci && npm run build` → 写 `env.build` 并重启 → 校验 `/health` 与 `/version` 的 commit 等于 HEAD。
前端产物只在服务器上构建,**不允许**本地上传 dist(AGENTS.md 铁律 3)。
## 4. 部署后校验
```bash
curl -s http://192.168.8.188:8200/health # {"status":"ok","db":true,...}
curl -s http://192.168.8.188:8200/version # commit 必须等于 git rev-parse --short HEAD
curl -sI http://192.168.8.188:8200/ | head -1 # 200,text/html(前端产物)
```
浏览器验收:打开 `http://192.168.8.188:8200` → 手机号任意 + 验证码 `123456` 登录 → 任务/工单/知识各点一遍。
## 5. 运维
```bash
systemctl --user status dadaotask-api # 状态
systemctl --user restart dadaotask-api # 重启
journalctl --user -u dadaotask-api -f # 日志(gunicorn 输出到 stdout)
```
- 附件落盘:`backend/var/uploads`(按工作区分子目录),删库不删文件,备份时一起打包
- 回滚:`cd ~/app/dadaotask && git reset --hard <旧 commit> && bash deploy/prod/deploy.sh`;**数据库迁移不回滚**,回滚代码前确认旧代码兼容新表结构
- 端口:`8200`(systemd 单元与 `deploy.sh` 的 `PORT` 默认值,两处要一致)
## 6. 已知限制(当前阶段)
| 限制 | 影响 | 解除方式 |
|---|---|---|
| 没有 HTTPS / 域名 | 只能内网 IP 访问,令牌明文传输 | 域名 + nginx + 证书(需 root) |
| 内测固定验证码 | 谁都能用任意手机号登录 | 接短信通道,关 `INTERNAL_TEST_LOGIN` |
| 无 pgvector | 语义检索走 `real[]` 全表扫描,数据量大了会慢 | 上独立 pgvector 实例(`deploy/docker-compose.vector.yml`) |
| 上传目录在本机磁盘 | 机器坏了附件就没了 | 备份 `backend/var/uploads`,或将来到对象存储 |
| 共享 PG / Redis | 别人占满连接或内存会波及本站 | 独立实例 |
+39 -12
View File
@@ -189,8 +189,8 @@
- [ ] 下拉刷新、只读缓存(Room)未做
### 1.6 通知与提醒(0.8d)
- [ ] 站内通知表与未读角标 —— **主动推迟到 M2**:通知的绝大多数触发源是工单(被指派、被催、验收),
M1 只有自己的任务,做出来也没人收到东西。表随 M2 的工单一起建,避免建两次。
- [x] 站内通知表与未读角标 —— 随 M2 一起落地,七类触发(指派、改状态、评论、工单全流程);
未读角标 30 秒轮询,标已读立刻刷新。原计划"推迟到 M2",M2 已兑现。
- [ ] 每日摘要邮件(同上推迟)
### 1.7 测试与验收(0.7d)
@@ -230,6 +230,8 @@
- [x] `POST /items/{id}/promote`、`/ticket/{accept,start,submit,verify,reassign,cancel}`
- [x] `GET /tickets?role=assigned|requested|all&status=`
- [x] `GET|POST /items/{id}/links`、`DELETE /items/{id}/links/{link_id}`
- [x] `POST /tickets` 手工开单:不经过任务直接指定人,初始 `open`,与升级单共用同一条 item
- [x] 指派前校验"在册成员"(`_ensure_member`),否则任务会挂在一个没人看得到的名字上
- [x] `GET /notifications`、`/unread-count`、`/read`、`/read-all`
- [x] 抽出 `app/serializers.py`:任务与工单共用一份序列化,**根除"工单响应缺 tags 导致前端白屏"**
@@ -238,17 +240,27 @@
- [x] 详情抽屉:按角色显示受理、开工、提交、验收、驳回、改派、取消;工单态顶部显示需求人/办理人/退回次数
- [x] 任务详情底部"升级成工单"面板(从成员里选人,成功后提示"这条任务现在处于被阻塞")
- [x] 导航加未读角标(30 秒对齐一次,操作后立刻刷新)
- [x] 通知页 `/w/:wsId/notifications`:未读 / 全部两档,点开即标已读并跳到目标;深链 `?open=` 落地即展开那一条
- [x] 工单页"新建"面板:手工开单(选人、优先级、说明),成功切到"我发起的"并展开
- [x] 登录支持 `?next=` 回跳,邀请链接不再"登录完掉回首页"
### 2.5 安卓(1.5d)
- [ ] 工单列表与动作 —— 只做到任务列表;本机无 JDK/Gradle,仍是**未编译**状态
- [x] 工单列表与动作 —— 仅按"我是需求人 / 办理人 / 管理员"显隐动作按钮,不再人人可见
- [x] 通知页与手工开单:五个 tab、未读数 30 秒刷新、通知点开跳列表并清标记
- [ ] 本次未编译验证:本机 JDK 已升级到 25,Gradle 8.7 / AGP 8.7.3 不兼容,需装回 JDK 17 再 `assembleDebug`
### 2.6 测试与验收(0.7d)
- [x] `backend/tests/test_tickets.py` 11 个用例:升级建单+原任务阻塞+通知、全流程验收回流、驳回、
自验 403、非法流转、重复升级、非成员指派、角色作用域、跨租户、关系挂载、通知已读、**响应必带 tags**
- [x] 全量 `pytest -q` → **57 passed**;`ruff check` / `format --check` 全 0
- [x] `backend/tests/test_notifications.py` 6 个用例:改状态只通知对方(自己不动自己)、指派必须是成员、
手工单全流程(开出→被指派人收到→受理/提交→验收,且没有来源任务)、越界指派 400/1001、
评论通知对方、未读数与全部已读
- [x] 全量 `pytest -q` → **85 passed**;`ruff check` / `format --check` 全 0
- [x] `web/e2e/tickets.mjs` 双浏览器上下文双用户 e2e,8 项断言全绿:
升级 → B 受理/开工/提交 → A 验收通过 → 原任务回流"待办" → 两端控制台零报错
- [x] `web/e2e/notifications.mjs` 12 项断言全绿:A 建工作区邀 B → B 自动加入 → A 手工开单 →
这张单没有来源任务 → B 收到"派给你的工单" → 通知列表有条目 → 点通知跳到工单并展开 →
未读数归零 → 两端控制台零报错
- [x] `web/e2e/smoke.mjs` 回归复跑(抽屉多了"选人"下拉后修正选择器)7 项全绿
### 2.7 通知真实投递
@@ -272,21 +284,36 @@
| 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 项断言绿 |
| 3.8 | 切片与向量索引(`chunk` 表、pgvector HNSW) | 2.0 | [x] **用 `real[]` + SQL 余弦先跑通**(见下方说明) |
| 3.8.1 | 切片:按标题归段、代码块整块保留、超长按句切、相邻片重叠 80 字 | 0.5 | [x] `app/domain/chunking.py` |
| 3.8.2 | 向量:`hash`(字符二元组)与 `openai` 两种提供方,换实现不动业务 | 0.5 | [x] `app/services/embedding.py` |
| 3.8.3 | 混合检索:向量 0.6 + trigram 0.4,低于阈值不返回,只认 `published` | 1.0 | [x] 5 个用例 + `e2e/retrieval.mjs` 6 项 |
| 3.8.4 | 索引生命周期:发布自动建、改正文需 `reindex`、换模型整体重建 | 0.5 | [x] `POST /knowledge/{id}/reindex` |
| 3.9 | 安卓知识列表 | 1.0 | [x] 只读列表 + 详情动作(编辑正文走安卓编辑器) |
| 3.10 | 文档与 e2e | 1.0 | [x] `knowledge.mjs` 7 项 + `retrieval.mjs` 6 项 |
| 3.11 | 知识分类:工作区三级小树(`knowledge_category` + 迁移 0007 + RLS),知识可归一类,列表按分类子树 / 未分类 / 标签筛选 | 1.0 | [x] 增删改移 + 重名 `7008`、超三级 `7007`、删分类要 `force` |
| 3.12 | 备忘录形态:新建知识一次写全(标题/类型/分类/标签/正文),不再是"填个标题就完成";抽屉加宽 + 可整屏 | 1.0 | [x] Web `KnowledgeComposer` + 安卓新建后直进编辑器;正文就是详情同款富文本编辑器(Vditor / Compose 编辑器);`Sheet` 增 `size="md/lg/xl"` 与展开按钮;截图 `2.Design/screens/knowledge-composer.png`、`knowledge-categories.png` |
| 3.13 | 知识能挂附件:新建面板里直接上传(懒建草稿拿到条目 id 再传),详情里能看、能下载/打开 | 1.0 | [x] Web `AttachmentPanel` + `RichTextEditor.ensureItemId`(贴图/传附件前自动存草稿);安卓详情页附件列表 + `OpenDocument` 上传 + 浏览器打开;附件仍落在 `item` 上(`ALLOWED_KINDS` 含 knowledge) |
### M3 完成定义(DOD)
- [x] 一件事办完 → 自动出现一份草稿 → 核对 → 发布,全程不用手动复制粘贴
- [x] 每条知识都能追到来源(除手记),点开能看到来源事项
- [x] 非管理员发布被拒;跨租户看不到别人的知识
- [ ] 向量切片与语义检索(需要独立 pgvector 实例真正跑起来)
- [x] 知识是备忘录:标题、正文、标签、三级分类、搜索;新建时就能把这些一次填完(Web 与安卓一致)
- [x] 切片与语义检索跑通(`real[]` + SQL 余弦;pgvector 仍待独立实例)
- [ ] 真实用两周:新同事靠知识库自己解决过一次问题(需要真实团队)
**关于 3.8 的说明**:本机 Docker 起不来、fnsvr-01 上也没有 pgvector 扩展,
所以 `deploy/docker-compose.vector.yml` 与 `VECTOR_DATABASE_URL` 只有配置,**没有跑过**。
按"写完不算完成"的规矩,它在真跑起来之前保持未勾。切片表设计要点已写进
`2.Design/PRODUCT-PLAN.md` 的 D8,落地时不要改 `EMBEDDING_DIM`(改了要重算全量)。
**关于 3.8 的说明(2026-10-29 已落地,但与计划不同)**:计划里写的是 pgvector + HNSW,
实际本机 Docker 起不来、fnsvr-01 上也没有 pgvector 扩展且用户明确要求别动那台机器,
所以先用 `chunk` 表(`embedding real[]`)+ SQL 函数 `app_cosine()` 把整条链路跑通:
切片 → 向量(内置 hash 提供方)→ 混合打分(向量 0.6 + trigram 0.4)→ 阈值过滤 → 带来源返回。
`deploy/docker-compose.vector.yml` 与 `VECTOR_DATABASE_URL` 保留,等向量实例真起来之后再切过去,
换 pgvector 只改列类型与索引,应用层接口不变。
两条必须记住的约束:
1. **内置 hash 向量不是语义模型**,只是"零依赖、可复现"的基线;上线前换 `EMBEDDING_PROVIDER=openai`
并整体重建(`reindex_workspace`),否则新旧向量混表、分数不可比。
2. **检索只认 `published`**,草稿永远搜不到——这条写在 SQL 的 WHERE 里,不靠调用方自觉。
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+3 -2
View File
@@ -6,6 +6,7 @@
- M0–M1 实施清单(含真实进度):[`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md)
- 接口约定(已实现部分):[`2.Design/API.md`](2.Design/API.md)
- 本地开发:[`2.Design/DEVELOPMENT.md`](2.Design/DEVELOPMENT.md)
- 生产部署(openclaw-01):[`2.Design/DEPLOY.md`](2.Design/DEPLOY.md)
- 中文命名方案:[`2.Design/NAMING.md`](2.Design/NAMING.md)
## 技术栈
@@ -29,8 +30,8 @@
| 知识:工单验收自动生成草稿、核对后发布、来源可追、手记 | ✅ 后端 + Web + 安卓已通,e2e 7 项全绿 |
| 界面:两端统一设计系统(纸白 / 墨绿 / 陶土橙,语义色、字号阶、组件库) | ✅ 已重做,见 [`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md) 第 4 节 |
| 富文本:备注 / 工单说明 / 知识正文支持图文混排与附件 | ✅ Web 用 Vditor 编辑、安卓用 Markwon 预览编辑(e2e 15 项全绿);接口见 [`2.Design/API.md`](2.Design/API.md) 第 6 节 |
| 后端测试 | ✅ 74 passed,ruff 干净 |
| 知识切片与语义检索(pgvector) | ⬜ 配置已就绪(`deploy/docker-compose.vector.yml`),本机 Docker 起不来,未跑过 |
| 知识切片与语义检索(混合打分、只搜已发布、带来源) | ✅ 已跑通:`real[]` + SQL 余弦(pgvector 待独立实例),`pytest` 5 个用例 + `e2e/retrieval.mjs` 6 项 |
| 后端测试 | ✅ 79 passed,ruff 干净 |
| LLM 能力(结构化录入、问答带引用、人物画像) | ⬜ M4 |
## 快速开始
@@ -27,6 +27,7 @@ import com.dadao.task.ui.HomeScreen
import com.dadao.task.ui.KnowledgeScreen
import com.dadao.task.ui.LoginScreen
import com.dadao.task.ui.MembersScreen
import com.dadao.task.ui.NotificationsScreen
import com.dadao.task.ui.RichTextEditorScreen
import com.dadao.task.ui.TaskListScreen
import com.dadao.task.ui.TicketsScreen
@@ -71,8 +72,14 @@ private fun AppRoot(joinUri: String?) {
var current by remember { mutableStateOf<Workspace?>(null) }
var tab by remember { mutableStateOf(WorkspaceTab.Tasks) }
var unread by remember { mutableStateOf(0) }
// 通知页标了已读之后要立刻刷新角标:改这个数会触发下面的 LaunchedEffect
var unreadRefresh by remember { mutableStateOf(0) }
// 打开富文本编辑器时非空;关掉就恢复原来的界面
var editing by remember { mutableStateOf<EditRequest?>(null) }
// 编辑器保存成功的那条:回到工作区后重新打开它的详情,然后清掉
var reopenItem by remember { mutableStateOf<String?>(null) }
// 上面那条是任务还是知识:决定回哪个页面去重开
var reopenKnowledge by remember { mutableStateOf(false) }
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
LaunchedEffect(screen) {
@@ -101,8 +108,8 @@ private fun AppRoot(joinUri: String?) {
.onFailure { pendingJoin = null }
}
// 工单未读数:进工作区就拉一次,切页面时再刷。
LaunchedEffect(current?.id, tab) {
// 未读数:进工作区、切页面、或通知页标了已读(unreadRefresh)时刷新
LaunchedEffect(current?.id, tab, unreadRefresh) {
val ws = current ?: return@LaunchedEffect
unread = api.unreadCount(ws.id)
}
@@ -124,64 +131,88 @@ private fun AppRoot(joinUri: String?) {
)
Screen.Workspace -> {
val ws = current
val request = editing
if (ws == null) {
screen = Screen.Home
} else {
Box {
WorkspaceScaffold(
workspace = ws,
tab = tab,
unread = unread,
onTab = { tab = it },
onBackHome = { screen = Screen.Home },
) {
when (tab) {
WorkspaceTab.Tasks -> TaskListScreen(
api = api,
workspace = ws,
onEdit = { editing = it },
} else if (request != null) {
// 编辑器**替换**整页,而不是叠在上面:抽屉/对话框都是独立窗口,
// 叠一层会被它们盖住,用户只能看到编辑器的一条边。
RichTextEditorScreen(
api = api,
workspaceId = ws.id,
itemId = request.itemId,
initialTitle = request.title,
initialBody = request.body,
titleEnabled = request.titleEnabled,
save = { title, body ->
if (request.knowledge) {
api.updateKnowledge(
workspaceId = ws.id,
itemId = request.itemId,
version = request.version,
title = title,
body = body,
)
WorkspaceTab.Tickets -> TicketsScreen(api = api, workspace = ws)
WorkspaceTab.Knowledge -> KnowledgeScreen(
api = api,
workspace = ws,
onEdit = { editing = it },
} else {
api.updateItem(
workspaceId = ws.id,
itemId = request.itemId,
version = request.version,
title = title,
body = body,
)
WorkspaceTab.Members -> MembersScreen(api = api, workspace = ws)
}
}
// 保存成功才标记"回到哪条":取消退出不该改变任何界面状态。
reopenItem = request.itemId
reopenKnowledge = request.knowledge
},
// saved 参数由编辑器给:true 时上面已经设过 reopenItem,
// 这里只负责把编辑器收起来,回到工作区后由对应页面重开详情。
onClose = { _ -> editing = null },
)
} else {
WorkspaceScaffold(
workspace = ws,
tab = tab,
unread = unread,
onTab = { tab = it },
onBackHome = { screen = Screen.Home },
) {
when (tab) {
WorkspaceTab.Tasks -> TaskListScreen(
api = api,
workspace = ws,
onEdit = { editing = it },
reopenItem = if (reopenItem != null && !reopenKnowledge) reopenItem else null,
onReopened = { reopenItem = null },
)
val request = editing
if (request != null) {
RichTextEditorScreen(
WorkspaceTab.Tickets -> TicketsScreen(
api = api,
workspace = ws,
focusId = reopenItem,
onFocusHandled = { reopenItem = null },
)
WorkspaceTab.Knowledge -> KnowledgeScreen(
api = api,
workspace = ws,
onEdit = { editing = it },
reopenItem = if (reopenItem != null && reopenKnowledge) reopenItem else null,
onReopened = { reopenItem = null },
)
WorkspaceTab.Members -> MembersScreen(api = api, workspace = ws)
WorkspaceTab.Notifications -> NotificationsScreen(
api = api,
workspaceId = ws.id,
itemId = request.itemId,
initialTitle = request.title,
initialBody = request.body,
titleEnabled = request.titleEnabled,
save = { title, body ->
if (request.knowledge) {
api.updateKnowledge(
workspaceId = ws.id,
itemId = request.itemId,
version = request.version,
title = title,
body = body,
)
} else {
api.updateItem(
workspaceId = ws.id,
itemId = request.itemId,
version = request.version,
title = title,
body = body,
)
}
onOpenItem = { itemId, isTicket ->
// 通知点开后跳到对应列表并展开那一条;通知本身不带数据,页面自己回源
reopenItem = itemId
reopenKnowledge = false
tab = if (isTicket) WorkspaceTab.Tickets else WorkspaceTab.Tasks
},
onClose = { editing = null },
onUnreadChanged = { unreadRefresh += 1 },
)
}
}
@@ -236,6 +236,26 @@ data class SourceRef(
val status: String = "",
)
/** 知识分类:工作区级的三级小树,fullPath 形如「研发 / 部署 / 回滚」。 */
@Serializable
data class KnowledgeCategory(
val id: String,
val name: String = "",
@SerialName("parent_id") val parentId: String? = null,
val depth: Int = 1,
@SerialName("sort_order") val sortOrder: Int = 0,
val version: Int = 1,
val path: List<String> = emptyList(),
@SerialName("full_path") val fullPath: String = "",
@SerialName("doc_count") val docCount: Int = 0,
)
@Serializable
data class KnowledgeCategoryPage(
val items: List<KnowledgeCategory> = emptyList(),
@SerialName("max_depth") val maxDepth: Int = 3,
)
@Serializable
data class KnowledgeDoc(
val id: String,
@@ -245,6 +265,7 @@ data class KnowledgeDoc(
val version: Int = 1,
val tags: List<Tag> = emptyList(),
val knowledge: KnowledgeInfo = KnowledgeInfo(),
val category: KnowledgeCategory? = null,
val source: SourceRef? = null,
val attachments: List<Attachment> = emptyList(),
)
@@ -258,6 +279,25 @@ data class KnowledgePage(
// ---- 请求体。每个都要 @Serializable,否则 encodeToString 找不到序列化器 ----
@Serializable
data class AppNotification(
val id: String,
val kind: String = "",
@SerialName("item_id") val itemId: String? = null,
@SerialName("item_title") val itemTitle: String? = null,
val title: String = "",
val body: String = "",
@SerialName("read_at") val readAt: String? = null,
@SerialName("created_at") val createdAt: String = "",
)
@Serializable
data class NotificationPage(
val notifications: List<AppNotification> = emptyList(),
@SerialName("next_cursor") val nextCursor: String? = null,
@SerialName("has_more") val hasMore: Boolean = false,
)
@Serializable
private data class UnreadCount(@SerialName("unread") val unread: Int = 0)
@@ -306,11 +346,22 @@ private data class KnowledgeCreateRequest(
val title: String,
@SerialName("body_md") val bodyMd: String,
@SerialName("doc_type") val docType: String,
@SerialName("tag_names") val tagNames: List<String> = emptyList(),
@SerialName("category_id") val categoryId: String? = null,
)
@Serializable
private data class KnowledgeActionRequest(val action: String)
@Serializable
private data class TicketCreateRequest(
val title: String,
@SerialName("body_md") val bodyMd: String? = null,
@SerialName("assignee_id") val assigneeId: String,
val priority: Int = 1,
@SerialName("due_at") val dueAt: String? = null,
)
@Serializable
private data class ItemUpdateRequest(
val version: Int,
@@ -323,6 +374,10 @@ private data class KnowledgeUpdateRequest(
val version: Int,
val title: String? = null,
@SerialName("body_md") val bodyMd: String? = null,
@SerialName("tag_names") val tagNames: List<String>? = null,
@SerialName("category_id") val categoryId: String? = null,
// 想把分类清成"未分类"就传 clear_category=true:只传 null 会被服务端当成"不改"
@SerialName("clear_category") val clearCategory: Boolean = false,
)
/**
@@ -427,6 +482,48 @@ class Api(private val tokens: TokenStore) {
auth = true,
)
// ---- M4 通知 ----
/** 通知默认只看未读;"全部"是查历史用的。 */
suspend fun notifications(
workspaceId: String,
unreadOnly: Boolean = true,
): NotificationPage = call(
path = "/workspaces/$workspaceId/notifications?unread_only=$unreadOnly&limit=50",
auth = true,
)
// OkHttp 的 POST 必须带 body,这里发个空对象,服务端不需要入参
suspend fun markNotificationRead(workspaceId: String, id: String): JsonElement = call(
path = "/workspaces/$workspaceId/notifications/$id/read",
body = "{}",
auth = true,
)
suspend fun markAllNotificationsRead(workspaceId: String): JsonElement = call(
path = "/workspaces/$workspaceId/notifications/read-all",
body = "{}",
auth = true,
)
// ---- M2 工单(手工开单)----
/** 不经过任务,直接开一张工单交给别人。 */
suspend fun createTicket(
workspaceId: String,
title: String,
assigneeId: String,
bodyMd: String? = null,
priority: Int = 1,
dueAt: String? = null,
): Ticket = call(
path = "/workspaces/$workspaceId/tickets",
body = json.encodeToString(
TicketCreateRequest(title, bodyMd?.ifBlank { null }, assigneeId, priority, dueAt),
),
auth = true,
)
/** 未读工单通知数,导航角标用。失败就当 0,不要因为角标让整页报错。 */
suspend fun unreadCount(workspaceId: String): Int = runCatching {
call<UnreadCount>(path = "/workspaces/$workspaceId/notifications/unread-count", auth = true).unread
@@ -596,20 +693,39 @@ class Api(private val tokens: TokenStore) {
workspaceId: String,
statuses: List<String> = listOf("draft", "in_review"),
query: String? = null,
categoryId: String? = null,
uncategorized: Boolean = false,
): KnowledgePage {
val params = buildString {
append("?limit=30")
statuses.forEach { append("&status=").append(it) }
if (!query.isNullOrBlank()) append("&q=").append(java.net.URLEncoder.encode(query, "UTF-8"))
if (!query.isNullOrBlank()) {
append("&q=").append(java.net.URLEncoder.encode(query, "UTF-8"))
}
if (categoryId != null) append("&category_id=").append(categoryId)
if (uncategorized) append("&uncategorized=true")
}
return call(path = "/workspaces/$workspaceId/knowledge$params", auth = true)
}
/** 工作区三级分类小树(扁平返回,depth 表示层级)。 */
suspend fun knowledgeCategories(workspaceId: String): KnowledgeCategoryPage =
call(path = "/workspaces/$workspaceId/knowledge/categories", auth = true)
/** 手记:没有来源的知识,得自己写清楚。 */
suspend fun createKnowledge(workspaceId: String, title: String, body: String = ""): KnowledgeDoc =
suspend fun createKnowledge(
workspaceId: String,
title: String,
body: String = "",
docType: String = "note",
tags: List<String> = emptyList(),
categoryId: String? = null,
): KnowledgeDoc =
call(
path = "/workspaces/$workspaceId/knowledge",
body = json.encodeToString(KnowledgeCreateRequest(title, body, "note")),
body = json.encodeToString(
KnowledgeCreateRequest(title, body, docType, tags, categoryId),
),
auth = true,
)
@@ -639,9 +755,14 @@ class Api(private val tokens: TokenStore) {
version: Int,
title: String? = null,
body: String? = null,
tags: List<String>? = null,
categoryId: String? = null,
clearCategory: Boolean = false,
): KnowledgeDoc = call(
path = "/workspaces/$workspaceId/knowledge/$itemId",
body = json.encodeToString(KnowledgeUpdateRequest(version, title, body)),
body = json.encodeToString(
KnowledgeUpdateRequest(version, title, body, tags, categoryId, clearCategory),
),
auth = true,
method = "PATCH",
)
@@ -29,11 +29,12 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.dadao.task.data.Workspace
/** 手机端的五个去处。看板不是独立入口,而是任务页顶部的一个切换(屏幕太窄,塞不下六个 tab)。 */
/** 手机端的去处。看板不是独立入口,而是任务页顶部的一个切换(屏幕太窄,塞不下更多 tab)。 */
enum class WorkspaceTab(val label: String) {
Tasks("任务"),
Tickets("工单"),
Knowledge("知识"),
Notifications("通知"),
Members("成员"),
}
@@ -1,9 +1,18 @@
package com.dadao.task.ui
import android.content.Intent
import android.net.Uri
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -31,13 +40,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.dadao.task.data.Api
import com.dadao.task.data.ApiException
import com.dadao.task.data.Attachment
import com.dadao.task.data.KnowledgeCategory
import com.dadao.task.data.KnowledgeDoc
import com.dadao.task.data.Workspace
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private val knowledgeTabs = listOf(
"draft" to "待核对",
@@ -46,25 +62,51 @@ private val knowledgeTabs = listOf(
"all" to "全部",
)
private fun docTypeLabel(type: String): String = when (type) {
"howto" -> "怎么做"
"faq" -> "问答"
"postmortem" -> "复盘"
"decision" -> "决策"
else -> "笔记"
}
private val docTypes = listOf(
"note" to "笔记",
"howto" to "怎么做",
"faq" to "问答",
"postmortem" to "复盘",
"decision" to "决策",
)
private fun docTypeLabel(type: String): String =
docTypes.firstOrNull { it.first == type }?.second ?: "笔记"
private fun parseTags(raw: String): List<String> =
raw.split(' ', '\n', ',', ',', '、')
.map { it.trim().removePrefix("#") }
.filter { it.isNotBlank() }
.distinct()
/** 分类在筛选条里的显示名:用点前缀表达层级,手机上比缩进更稳。 */
private fun catLabel(cat: KnowledgeCategory): String =
"·".repeat((cat.depth - 1).coerceAtLeast(0)) + cat.name
@Composable
fun KnowledgeScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Unit = {}) {
fun KnowledgeScreen(
api: Api,
workspace: Workspace,
onEdit: (EditRequest) -> Unit = {},
/** 编辑器保存后要重新打开的那条。父层持有:编辑器打开时本页会被整体卸载。 */
reopenItem: String? = null,
onReopened: () -> Unit = {},
) {
val scope = rememberCoroutineScope()
var tab by remember { mutableStateOf("draft") }
var docs by remember { mutableStateOf<List<KnowledgeDoc>>(emptyList()) }
var categories by remember { mutableStateOf<List<KnowledgeCategory>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var message by remember { mutableStateOf<String?>(null) }
var open by remember { mutableStateOf<KnowledgeDoc?>(null) }
var adding by remember { mutableStateOf(false) }
var newTitle by remember { mutableStateOf("") }
var newBody by remember { mutableStateOf("") }
// null = 全部;"none" = 未分类;其它是分类 id(服务端含子分类)
var catFilter by remember { mutableStateOf<String?>(null) }
suspend fun loadCategories() {
runCatching { api.knowledgeCategories(workspace.id) }
.onSuccess { categories = it.items }
}
suspend fun reload() {
loading = true
@@ -74,7 +116,15 @@ fun KnowledgeScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Uni
"all" -> listOf("draft", "in_review", "published", "archived")
else -> listOf("draft", "in_review")
}
runCatching { api.knowledge(workspace.id, statuses) }
val filter = catFilter
runCatching {
api.knowledge(
workspace.id,
statuses,
categoryId = if (filter != null && filter != "none") filter else null,
uncategorized = filter == "none",
)
}
.onSuccess {
docs = it.items
message = null
@@ -83,7 +133,16 @@ fun KnowledgeScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Uni
loading = false
}
LaunchedEffect(tab) { reload() }
LaunchedEffect(tab, catFilter) { reload() }
LaunchedEffect(Unit) { loadCategories() }
// 从编辑器保存回来:本页是新建出来的,刷完列表再把那条详情重新打开。
LaunchedEffect(reopenItem) {
val target = reopenItem ?: return@LaunchedEffect
reload()
open = docs.firstOrNull { it.id == target }
onReopened()
}
fun fail(e: Throwable) {
val code = (e as? ApiException)?.code ?: 0
@@ -110,11 +169,30 @@ fun KnowledgeScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Uni
}
}
Spacer(Modifier.height(10.dp))
GhostButton("记一条", onClick = {
adding = true
newTitle = ""
newBody = ""
})
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
SurfaceChip(
text = "全部分类",
selected = catFilter == null,
onClick = { catFilter = null },
)
SurfaceChip(
text = "未分类",
selected = catFilter == "none",
onClick = { catFilter = "none" },
)
categories.forEach { cat ->
SurfaceChip(
text = catLabel(cat),
selected = catFilter == cat.id,
onClick = { catFilter = cat.id },
)
}
}
Spacer(Modifier.height(10.dp))
GhostButton("记一条", onClick = { adding = true })
}
}
@@ -126,7 +204,7 @@ fun KnowledgeScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Uni
if (!loading && docs.isEmpty()) {
item {
EmptyState(
"还没有待核对的知识",
if (catFilter == null) "还没有待核对的知识" else "这个分类下还没有知识",
"把工单验收通过,或在任务详情里点「沉淀成知识」",
)
}
@@ -150,6 +228,18 @@ fun KnowledgeScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Uni
fontWeight = FontWeight.Medium,
)
Spacer(Modifier.height(6.dp))
val meta = buildList {
doc.category?.let { add(it.fullPath.ifBlank { it.name }) }
doc.tags.forEach { add("#${it.name}") }
}
if (meta.isNotEmpty()) {
Text(
meta.joinToString(" "),
style = MaterialTheme.typography.labelSmall,
color = Primary,
)
Spacer(Modifier.height(4.dp))
}
Text(
sourceLine(doc),
style = MaterialTheme.typography.labelSmall,
@@ -167,68 +257,44 @@ fun KnowledgeScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Uni
api = api,
workspace = workspace,
doc = target,
categories = categories,
onClose = { open = null },
onEdit = onEdit,
onChanged = {
scope.launch { reload() }
scope.launch {
reload()
loadCategories()
}
},
onCategoriesChanged = { scope.launch { loadCategories() } },
onFail = { fail(it) },
)
}
if (adding) {
AlertDialog(
onDismissRequest = { adding = false },
title = { Text("记一条", style = MaterialTheme.typography.titleMedium) },
text = {
Column {
Text(
"手记没有来源,想沉淀某件具体的事,去那条任务的详情里点「沉淀成知识」。",
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
)
Spacer(Modifier.height(10.dp))
OutlinedTextField(
value = newTitle,
onValueChange = { newTitle = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("一句话说清这条知识是什么", color = InkMuted) },
shape = RoundedCornerShape(10.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = newBody,
onValueChange = { newBody = it },
modifier = Modifier.fillMaxWidth().heightIn(min = 90.dp),
placeholder = { Text("正文(可空,之后再补)", color = InkMuted) },
shape = RoundedCornerShape(10.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
KnowledgeComposerDialog(
api = api,
workspace = workspace,
categories = categories,
onDismiss = { adding = false },
onCreated = { created ->
adding = false
scope.launch {
loadCategories()
reload()
// 正文交给富文本编辑器写:先落一条草稿,再直接进去写,不让用户对着纯文本框凑排版
onEdit(
EditRequest(
itemId = created.id,
title = created.title,
body = created.bodyMd,
version = created.version,
knowledge = true,
),
)
}
},
confirmButton = {
TextButton(onClick = {
scope.launch {
adding = false
runCatching { api.createKnowledge(workspace.id, newTitle.trim(), newBody) }
.onSuccess { reload() }
.onFailure { fail(it) }
}
}) { Text("保存草稿") }
},
dismissButton = { TextButton(onClick = { adding = false }) { Text("取消") } },
containerColor = Paper,
onFail = { fail(it) },
)
}
}
@@ -242,18 +308,208 @@ private fun sourceLine(doc: KnowledgeDoc): String {
}
}
/**
* 新建知识:手机上也要能一次写全(标题/类型/分类/标签/正文)。
* 用整屏 94% 宽的 Dialog 而不是 AlertDialog —— AlertDialog 的宽度上限装不下一篇备忘录。
*/
@Composable
private fun KnowledgeComposerDialog(
api: Api,
workspace: Workspace,
categories: List<KnowledgeCategory>,
onDismiss: () -> Unit,
onCreated: (KnowledgeDoc) -> Unit,
onFail: (Throwable) -> Unit,
) {
val scope = rememberCoroutineScope()
var title by remember { mutableStateOf("") }
var docType by remember { mutableStateOf("note") }
var categoryId by remember { mutableStateOf<String?>(null) }
var tags by remember { mutableStateOf("") }
var saving by remember { mutableStateOf(false) }
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Column(
Modifier
.fillMaxWidth(0.94f)
.fillMaxHeight(0.88f)
.background(Paper, RoundedCornerShape(16.dp))
.padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"记一条知识",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
GhostButton("取消", onClick = onDismiss)
Spacer(Modifier.width(8.dp))
PrimaryButton(
"保存",
enabled = !saving && title.isNotBlank(),
onClick = {
scope.launch {
saving = true
runCatching {
api.createKnowledge(
workspace.id,
title.trim(),
"",
docType,
parseTags(tags),
categoryId,
)
}
.onSuccess { onCreated(it) }
.onFailure { onFail(it) }
saving = false
}
},
)
}
Spacer(Modifier.height(12.dp))
Column(
Modifier.weight(1f).verticalScroll(rememberScrollState()),
) {
OutlinedTextField(
value = title,
onValueChange = { title = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("一句话说清这条知识是什么", color = InkMuted) },
shape = RoundedCornerShape(10.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
Spacer(Modifier.height(10.dp))
SectionLabel("类型")
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
docTypes.forEach { (key, label) ->
SurfaceChip(
text = label,
selected = docType == key,
onClick = { docType = key },
)
}
}
Spacer(Modifier.height(10.dp))
SectionLabel("分类")
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
SurfaceChip(
text = "未分类",
selected = categoryId == null,
onClick = { categoryId = null },
)
categories.forEach { cat ->
SurfaceChip(
text = catLabel(cat),
selected = categoryId == cat.id,
onClick = { categoryId = cat.id },
)
}
}
if (categories.isEmpty()) {
Text(
"还没有分类,Web 端可以建三级分类",
style = MaterialTheme.typography.labelSmall,
color = InkMuted,
modifier = Modifier.padding(top = 4.dp),
)
}
Spacer(Modifier.height(10.dp))
OutlinedTextField(
value = tags,
onValueChange = { tags = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = { Text("标签,空格或逗号分隔", color = InkMuted) },
shape = RoundedCornerShape(10.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
Spacer(Modifier.height(10.dp))
SectionLabel("正文")
Text(
"点右上「保存」先把这张备忘存成草稿,随后会直接打开编辑器写正文——排版、表格、贴图都在编辑器里。",
style = MaterialTheme.typography.labelSmall,
color = InkMuted,
)
Spacer(Modifier.height(12.dp))
}
}
}
}
@Composable
private fun KnowledgeDetailDialog(
api: Api,
workspace: Workspace,
doc: KnowledgeDoc,
categories: List<KnowledgeCategory>,
onClose: () -> Unit,
onChanged: () -> Unit,
onCategoriesChanged: () -> Unit,
onFail: (Throwable) -> Unit,
onEdit: (EditRequest) -> Unit,
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
var action by remember { mutableStateOf(doc.status) }
var version by remember { mutableStateOf(doc.version) }
var categoryId by remember { mutableStateOf(doc.category?.id) }
var tags by remember { mutableStateOf(doc.tags.joinToString(" ") { it.name }) }
var savingMeta by remember { mutableStateOf(false) }
var metaNotice by remember { mutableStateOf<String?>(null) }
/**
* 附件:备忘录不只文字,手册、日志、表格都要能挂上来。
* 附件必须落在条目上,所以这里只在已有的知识详情里传(新建时由正文里的编辑器负责)。
*/
var attachments by remember(doc.id) { mutableStateOf(doc.attachments) }
var uploading by remember { mutableStateOf(false) }
LaunchedEffect(doc.id) {
runCatching { api.attachments(workspace.id, doc.id) }.onSuccess { attachments = it }
}
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
scope.launch {
uploading = true
try {
val bytes = withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}
if (bytes == null) {
onFail(IllegalStateException("读不到这个文件,换个来源试试"))
return@launch
}
val name = attachmentName(context, uri) ?: "file-${System.currentTimeMillis()}"
val mime = context.contentResolver.getType(uri) ?: "application/octet-stream"
val created = api.uploadAttachment(workspace.id, doc.id, name, mime, bytes)
attachments = attachments + created
} catch (e: Exception) {
onFail(e)
} finally {
uploading = false
}
}
}
val actions = when (action) {
"draft" -> listOf("submit_review" to "提交审议", "publish" to "发布", "archive" to "归档")
"in_review" -> listOf("withdraw" to "撤回审议", "publish" to "发布", "archive" to "归档")
@@ -277,15 +533,105 @@ private fun KnowledgeDetailDialog(
},
text = {
Column(
Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()),
Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
) {
Text(sourceLine(doc), style = MaterialTheme.typography.labelSmall, color = InkMuted)
Spacer(Modifier.height(10.dp))
Text(
doc.bodyMd.ifBlank { "(还没有正文,点「补一句」在 Web 端补齐更快)" },
doc.bodyMd.ifBlank { "(还没有正文,点「编辑正文」补齐)" },
style = MaterialTheme.typography.bodySmall,
color = InkSoft,
)
// 附件:手册、日志、导出表格都能挂在这条知识下,点一下用浏览器打开
Spacer(Modifier.height(14.dp))
SectionLabel("附件")
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
if (attachments.isEmpty()) "还没有附件" else "${attachments.size} 个",
style = MaterialTheme.typography.labelSmall,
color = InkMuted,
)
Spacer(Modifier.width(10.dp))
TextButton(
enabled = !uploading,
onClick = { picker.launch(arrayOf("*/*")) },
) { Text(if (uploading) "上传中…" else "上传附件") }
}
attachments.forEach { row ->
Row(
Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
row.filename,
style = MaterialTheme.typography.bodySmall,
color = Ink,
modifier = Modifier.weight(1f),
)
Text(
humanSize(row.size),
style = MaterialTheme.typography.labelSmall,
color = InkMuted,
)
Spacer(Modifier.width(4.dp))
TextButton(
onClick = {
// raw_url 是带短时签名的相对路径,拼上当前后端地址才能在浏览器里打开
val url = row.rawUrl?.let { api.currentOrigin() + it } ?: return@TextButton
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}.onFailure { onFail(it) }
},
) { Text("打开") }
}
}
// 已发布的内容不能直接改,分类/标签也跟着锁住,避免"发布后又悄悄换归属"
if (action != "published") {
Spacer(Modifier.height(14.dp))
SectionLabel("分类")
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
SurfaceChip(
text = "未分类",
selected = categoryId == null,
onClick = { categoryId = null },
)
categories.forEach { cat ->
SurfaceChip(
text = catLabel(cat),
selected = categoryId == cat.id,
onClick = { categoryId = cat.id },
)
}
}
Spacer(Modifier.height(10.dp))
OutlinedTextField(
value = tags,
onValueChange = { tags = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = { Text("标签,空格或逗号分隔", color = InkMuted) },
shape = RoundedCornerShape(10.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
if (metaNotice != null) {
Text(
metaNotice.orEmpty(),
style = MaterialTheme.typography.labelSmall,
color = Primary,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
},
confirmButton = {
@@ -297,7 +643,7 @@ private fun KnowledgeDetailDialog(
itemId = doc.id,
title = doc.title,
body = doc.bodyMd,
version = doc.version,
version = version,
titleEnabled = action == "draft",
knowledge = true,
),
@@ -305,6 +651,34 @@ private fun KnowledgeDetailDialog(
onClose()
},
) { Text("编辑正文") }
if (action != "published") {
TextButton(
enabled = !savingMeta,
onClick = {
scope.launch {
savingMeta = true
runCatching {
api.updateKnowledge(
workspace.id,
doc.id,
version,
tags = parseTags(tags),
categoryId = categoryId,
clearCategory = categoryId == null,
)
}
.onSuccess {
version = it.version
metaNotice = "分类与标签已保存"
onChanged()
onCategoriesChanged()
}
.onFailure { onFail(it) }
savingMeta = false
}
},
) { Text("保存分类标签") }
}
actions.forEach { (key, label) ->
TextButton(onClick = {
scope.launch {
@@ -328,3 +702,20 @@ private fun KnowledgeDetailDialog(
containerColor = Paper,
)
}
/** 附件列表里显示的大小:人与手机都不需要精确到字节。 */
private fun humanSize(bytes: Long): String = when {
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
else -> String.format("%.1f MB", bytes / 1024.0 / 1024.0)
}
/** 从 content:// 里问出文件名:微信、文件管理器给的 uri 形态各不相同,能问就问。 */
private fun attachmentName(context: android.content.Context, uri: Uri): String? =
runCatching {
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null
}
}.getOrNull() ?: uri.lastPathSegment?.substringAfterLast('/')
@@ -0,0 +1,205 @@
package com.dadao.task.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.dadao.task.data.Api
import com.dadao.task.data.ApiException
import com.dadao.task.data.AppNotification
import kotlinx.coroutines.launch
/**
* 站内通知。
*
* 只做三件事:看有没有新动静、点开跳过去、标为已读。
* 通知本身不是凭证——点开之后目标页面会重新拉数据,因为通知生成后事情可能又被改过。
*/
@Composable
fun NotificationsScreen(
api: Api,
workspaceId: String,
onOpenItem: (itemId: String, isTicket: Boolean) -> Unit,
onUnreadChanged: () -> Unit,
refreshKey: Int = 0,
) {
var unreadOnly by remember { mutableStateOf(true) }
var rows by remember { mutableStateOf<List<AppNotification>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
suspend fun reload() {
loading = true
error = null
runCatching { api.notifications(workspaceId, unreadOnly) }
.onSuccess { rows = it.notifications }
.onFailure { error = verifyStatusCode((it as? ApiException)?.code ?: 0) }
loading = false
}
LaunchedEffect(workspaceId, unreadOnly, refreshKey) { reload() }
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("通知", fontSize = 22.sp, fontWeight = FontWeight.SemiBold, color = Ink)
Box(Modifier.width(2.dp))
SoftPill(
text = "未读",
fg = if (unreadOnly) Primary else InkMuted,
bg = if (unreadOnly) PrimarySoft else PaperSunken,
modifier = Modifier.clickable { unreadOnly = true },
)
SoftPill(
text = "全部",
fg = if (unreadOnly) InkMuted else Primary,
bg = if (unreadOnly) PaperSunken else PrimarySoft,
modifier = Modifier.clickable { unreadOnly = false },
)
}
Text(
text = if (unreadOnly) "有人派活、或你关注的事有进展时,会出现在这里" else "历史通知,方便回头找",
fontSize = 12.sp,
color = InkMuted,
modifier = Modifier.padding(top = 4.dp, bottom = 10.dp),
)
error?.let {
Text(it, fontSize = 12.sp, color = Rust, modifier = Modifier.padding(bottom = 8.dp))
}
if (loading) {
Box(Modifier.fillMaxWidth().padding(top = 40.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = Primary, strokeWidth = 2.dp, modifier = Modifier.size(22.dp))
}
return@Column
}
if (rows.isEmpty()) {
SoftCard(Modifier.fillMaxWidth()) {
EmptyState(
title = if (unreadOnly) "没有未读通知" else "还没有通知",
desc = if (unreadOnly) "有动静时这里会提醒你。" else "所有历史通知都会留在这里。",
)
}
return@Column
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 20.dp),
) {
items(rows, key = { it.id }) { n ->
val isTicket = n.kind.startsWith("ticket_")
SoftCard(
Modifier.fillMaxWidth(),
onClick = {
scope.launch {
if (n.readAt == null) {
runCatching { api.markNotificationRead(workspaceId, n.id) }
rows = rows.map { if (it.id == n.id) it.copy(readAt = "read") else it }
onUnreadChanged()
}
n.itemId?.let { onOpenItem(it, isTicket) }
}
},
) {
Row(verticalAlignment = Alignment.Top) {
Box(
modifier = Modifier
.size(26.dp)
.background(if (n.readAt == null) PrimarySoft else PaperSunken, CircleShape),
contentAlignment = Alignment.Center,
) {
Text(
text = if (isTicket) "工" else "记",
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
color = if (n.readAt == null) Primary else InkMuted,
)
}
Box(Modifier.width(10.dp))
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = n.title,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = Ink,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (n.readAt == null) {
Box(
Modifier
.padding(start = 6.dp)
.size(6.dp)
.background(Clay, CircleShape),
)
}
}
if (n.body.isNotBlank()) {
Text(
text = n.body,
fontSize = 12.sp,
color = InkSoft,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp),
)
}
Row(
modifier = Modifier.padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
n.itemTitle?.let {
Text(
text = it,
fontSize = 11.sp,
color = InkMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
}
Text(formatTime(n.createdAt), fontSize = 11.sp, color = InkMuted)
}
}
}
}
}
}
}
}
@@ -68,6 +68,8 @@ fun TaskDetailSheet(
onChanged: () -> Unit,
/** 进富文本编辑器改正文。标题与状态留在抽屉里,只有正文要更大地方。 */
onEdit: (ItemDetail) -> Unit = {},
/** 编辑器保存回来后变一次,抽屉据此重新拉数据——否则还显示保存前的旧正文。 */
refreshKey: Int = 0,
) {
val sheet = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
@@ -93,7 +95,7 @@ fun TaskDetailSheet(
runCatching { api.me() }.onSuccess { meId = it.user.id }
}
LaunchedEffect(itemId) { reload() }
LaunchedEffect(itemId, refreshKey) { reload() }
fun fail(e: Throwable) {
val code = (e as? ApiException)?.code ?: 0
@@ -51,7 +51,14 @@ private val views = listOf(
)
@Composable
fun TaskListScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Unit = {}) {
fun TaskListScreen(
api: Api,
workspace: Workspace,
onEdit: (EditRequest) -> Unit = {},
/** 编辑器保存后要重新打开的那条。父层持这个状态,因为编辑器打开时本页会被整体卸载。 */
reopenItem: String? = null,
onReopened: () -> Unit = {},
) {
val scope = rememberCoroutineScope()
var view by remember { mutableStateOf("active") }
var mode by remember { mutableStateOf("list") }
@@ -60,6 +67,7 @@ fun TaskListScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Unit
var draft by remember { mutableStateOf("") }
var query by remember { mutableStateOf("") }
var openId by remember { mutableStateOf<String?>(null) }
var detailRefresh by remember { mutableStateOf(0) }
var error by remember { mutableStateOf<String?>(null) }
suspend fun reload() {
@@ -75,6 +83,15 @@ fun TaskListScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Unit
LaunchedEffect(view, query) { reload() }
// 从编辑器保存回来:本页是新建出来的,先刷数据再把那一条详情重新打开。
LaunchedEffect(reopenItem) {
val target = reopenItem ?: return@LaunchedEffect
reload()
detailRefresh += 1
openId = target
onReopened()
}
fun fail(e: Throwable) {
val code = (e as? ApiException)?.code ?: 0
error = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" }
@@ -193,10 +210,12 @@ fun TaskListScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Unit
itemId = id,
onClose = { openId = null },
onChanged = { scope.launch { reload() } },
// 抽屉是独立窗口,关掉才能让编辑器显示在上层;保存回来后由父层重新打开。
onEdit = {
openId = null
onEdit(EditRequest(it.id, it.title, it.bodyMd, it.version))
},
refreshKey = detailRefresh,
)
}
}
@@ -1,5 +1,6 @@
package com.dadao.task.ui
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
@@ -31,6 +33,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.dadao.task.data.Api
import com.dadao.task.data.ApiException
import com.dadao.task.data.Member
import com.dadao.task.data.Ticket
import com.dadao.task.data.Workspace
import kotlinx.coroutines.launch
@@ -42,7 +45,12 @@ private val ticketRoles = listOf(
)
@Composable
fun TicketsScreen(api: Api, workspace: Workspace) {
fun TicketsScreen(
api: Api,
workspace: Workspace,
focusId: String? = null,
onFocusHandled: () -> Unit = {},
) {
val scope = rememberCoroutineScope()
var role by remember { mutableStateOf("assigned") }
var tickets by remember { mutableStateOf<List<Ticket>>(emptyList()) }
@@ -52,6 +60,16 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
var note by remember { mutableStateOf("") }
var resolution by remember { mutableStateOf("") }
// 手工开单:不经过任务,人得自己选
var members by remember { mutableStateOf<List<Member>>(emptyList()) }
var creating by remember { mutableStateOf(false) }
var newTitle by remember { mutableStateOf("") }
var newAssignee by remember { mutableStateOf("") }
var newPriority by remember { mutableStateOf(1) }
// 动作按钮按"我是谁"决定显隐:需求人不去接收自己的单,办理人也不该自验
var meId by remember { mutableStateOf("") }
suspend fun reload() {
loading = true
runCatching { api.tickets(workspace.id, role) }
@@ -65,6 +83,18 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
LaunchedEffect(role) { reload() }
// 从通知点进来:切到"全部"才看得见那张单,然后清掉待处理标记
LaunchedEffect(focusId) {
if (focusId == null) return@LaunchedEffect
role = "all"
onFocusHandled()
}
LaunchedEffect(Unit) {
runCatching { api.members(workspace.id) }.onSuccess { members = it }
runCatching { api.me() }.onSuccess { meId = it.user.id }
}
fun fail(e: Throwable) {
val code = (e as? ApiException)?.code ?: 0
message = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" }
@@ -95,10 +125,20 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
modifier = Modifier.padding(top = 4.dp),
)
Spacer(Modifier.height(10.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
ticketRoles.forEach { (key, label) ->
SurfaceChip(text = label, selected = role == key, onClick = { role = key })
}
Spacer(Modifier.weight(1f))
GhostButton("新建", onClick = {
newTitle = ""
newAssignee = ""
newPriority = 1
creating = true
})
}
}
}
@@ -140,21 +180,23 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
}
}
Spacer(Modifier.height(10.dp))
val isAssignee = ticket.ticket.assigneeId == meId
val isRequester = ticket.ticket.requesterId == meId
val isAdmin = workspace.role == "owner" || workspace.role == "admin"
val canWork = isAssignee || isAdmin
val canVerify = isRequester || isAdmin
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
when (ticket.status) {
"open" -> PrimaryButton("接收", onClick = { act(ticket, "accept") })
"accepted" -> PrimaryButton("开始办理", onClick = { act(ticket, "start") })
"in_progress" -> PrimaryButton("提交验收", onClick = {
scope.launch {
runCatching { api.ticketAction(workspace.id, ticket.id, "submit") }
.onSuccess {
message = null
reload()
}
.onFailure { fail(it) }
}
})
"pending_verify" -> {
"open" -> if (canWork) {
PrimaryButton("接收", onClick = { act(ticket, "accept") })
}
"accepted" -> if (canWork) {
PrimaryButton("开始办理", onClick = { act(ticket, "start") })
}
"in_progress" -> if (canWork) {
PrimaryButton("提交验收", onClick = { act(ticket, "submit") })
}
"pending_verify" -> if (canVerify) {
PrimaryButton("通过验收", onClick = {
verifyTarget = ticket
note = ""
@@ -174,6 +216,17 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
})
}
}
// 谁都不能替别人取消,但需求人/办理人/管理员可以中止
if (ticket.status != "done" && ticket.status != "canceled" && (isRequester || isAssignee || isAdmin)) {
GhostButton("取消", onClick = { act(ticket, "cancel") })
}
if (!canWork && !canVerify && ticket.status != "done" && ticket.status != "canceled") {
Text(
if (isRequester) "等对方处理" else "这条不是派给你的",
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
)
}
}
}
}
@@ -181,6 +234,89 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
item { Spacer(Modifier.height(20.dp)) }
}
if (creating) {
AlertDialog(
onDismissRequest = { creating = false },
title = { Text("新建工单", style = MaterialTheme.typography.titleMedium) },
text = {
Column {
Text(
"不经过任务也能开单。写清楚要办什么,指定一个人,对方会收到通知。",
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
)
Spacer(Modifier.height(10.dp))
OutlinedTextField(
value = newTitle,
onValueChange = { newTitle = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("要办什么,例如:核对 9 月发票", color = InkMuted) },
singleLine = true,
shape = RoundedCornerShape(10.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
Spacer(Modifier.height(10.dp))
Text("交给谁", style = MaterialTheme.typography.labelMedium, color = InkMuted)
Spacer(Modifier.height(6.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.horizontalScroll(rememberScrollState()),
) {
members.forEach { m ->
SurfaceChip(
text = m.nickname.ifBlank { m.phone.takeLast(4) },
selected = newAssignee == m.userId,
onClick = { newAssignee = m.userId },
)
}
}
Spacer(Modifier.height(10.dp))
Text("优先级", style = MaterialTheme.typography.labelMedium, color = InkMuted)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf(0 to "无", 1 to "低", 2 to "中", 3 to "高").forEach { (v, label) ->
SurfaceChip(
text = label,
selected = newPriority == v,
onClick = { newPriority = v },
)
}
}
}
},
confirmButton = {
TextButton(
enabled = newTitle.isNotBlank() && newAssignee.isNotBlank(),
onClick = {
scope.launch {
runCatching {
api.createTicket(
workspace.id,
newTitle.trim(),
newAssignee,
priority = newPriority,
)
}
.onSuccess {
creating = false
message = null
role = "requested"
}
.onFailure { fail(it) }
}
},
) { Text("开单并通知对方") }
},
dismissButton = { TextButton(onClick = { creating = false }) { Text("取消") } },
containerColor = Paper,
)
}
val target = verifyTarget
if (target != null) {
AlertDialog(
+7 -1
View File
@@ -24,14 +24,20 @@ JWT_SECRET=dev-only-change-me
ACCESS_TOKEN_MINUTES=30
REFRESH_TOKEN_DAYS=14
# 生产环境禁止 SMS_PROVIDER=dev(否则任意验证码可登录),启动即失败
# 生产环境禁止 SMS_PROVIDER=dev(否则任意验证码可登录),启动即失败;
# 内网试用确需固定验证码时显式设置 INTERNAL_TEST_LOGIN=true(启动会打 WARNING)
SMS_PROVIDER=dev
SMS_DEV_CODE=123456
INTERNAL_TEST_LOGIN=false
# 同一手机号两次发码的最小间隔(秒),测试里置 0 可关闭
SMS_THROTTLE_SECONDS=60
CORS_ORIGINS=http://localhost:5180,http://127.0.0.1:5180
# 生产单端口部署:后端直接托管前端构建产物(同源,可留空 CORS)
# 留空则只提供 API;部署方式见 2.Design/DEPLOY.md
# WEB_DIST_DIR=/home/openclaw/app/dadaotask/web/dist
# ── 知识库向量库(独立的 PG + pgvector,M3 起用)──────────────────────
# 单独实例,不动 fnsvr-01:那台没有 pgvector,且向量检索吃内存,不该和业务库挤。
# 起法见 deploy/docker-compose.vector.yml。不配则 M3 的语义检索能力直接不可用。
+3 -3
View File
@@ -12,13 +12,13 @@ import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
from app.roles import APP_ROLE, OWNER_ROLE
revision: str = "0001"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
APP_ROLE = "dadao_app"
# 说明:asyncpg 走"预备语句"执行,**一条 execute 里只能有一条 SQL**,
# 多条语句会报 `cannot insert multiple commands into a prepared statement`。
# 所以这里按语句拆成元组,逐条执行。
@@ -76,7 +76,7 @@ POLICY_STATEMENTS: tuple[str, ...] = (
f"workspace, app_user, member, invitation, audit_log TO {APP_ROLE};"
),
(
"ALTER DEFAULT PRIVILEGES FOR ROLE dadao_owner IN SCHEMA public "
f"ALTER DEFAULT PRIVILEGES FOR ROLE {OWNER_ROLE} IN SCHEMA public "
f"GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO {APP_ROLE};"
),
)
+2 -2
View File
@@ -15,13 +15,13 @@ import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
from app.roles import APP_ROLE
revision: str = "0002"
down_revision: str | None = "0001"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
APP_ROLE = "dadao_app"
TENANT_TABLES_0002: tuple[str, ...] = ("item", "tag", "item_tag", "comment", "activity")
_TIMESTAMP_COLUMNS = (
+2 -2
View File
@@ -19,13 +19,13 @@ import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
from app.roles import APP_ROLE
revision: str = "0003"
down_revision: str | None = "0002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
APP_ROLE = "dadao_app"
TENANT_TABLES_0003: tuple[str, ...] = ("ticket_ext", "item_link", "notification")
TASK_STATUSES = ("inbox", "todo", "doing", "done", "blocked", "canceled")
+2 -2
View File
@@ -21,13 +21,13 @@ import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
from app.roles import APP_ROLE
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")
+3 -1
View File
@@ -11,6 +11,8 @@ from sqlalchemy.dialects.postgresql import UUID as PgUUID
from alembic import op
from app.roles import APP_ROLE
revision = "0005"
down_revision = "0004"
branch_labels = None
@@ -55,7 +57,7 @@ def upgrade() -> None:
"ALTER TABLE attachment ENABLE ROW LEVEL SECURITY",
"CREATE POLICY attachment_isolation ON attachment "
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
"GRANT SELECT, INSERT, UPDATE, DELETE ON attachment TO dadao_app",
f"GRANT SELECT, INSERT, UPDATE, DELETE ON attachment TO {APP_ROLE}",
):
op.execute(statement)
+104
View File
@@ -0,0 +1,104 @@
"""知识切片与检索(M3)
Revision ID: 0006
Revises: 0005
Create Date: 2026-10-29
建 `chunk` 表:知识正文按段切开,每片一行,带向量数组。
两个刻意的取舍:
1. **向量存 `real[]`,不依赖 pgvector**。本机拿不到带 pgvector 的实例(Docker 起不来,
fnsvr-01 明确不动),但检索链路不能一直停在纸面上。`real[]` + 一条 SQL 余弦函数
在小数据量下够用;将来换上 pgvector 只需改列类型、加 HNSW 索引,应用层接口不变。
2. **检索范围只能是 `published`**。草稿是"还没被人认可的话",进检索等于让 AI 把
猜测当结论引用出去——这条过滤写在查询里,不靠调用方自觉。
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
from app.roles import APP_ROLE
revision: str = "0006"
down_revision: str = "0005"
branch_labels: Sequence[str] | None = None
depends_on: Sequence[str] | None = None
# 余弦相似度。写成 SQL 函数而不是在 Python 里算,是为了让过滤与排序留在数据库里做——
# 把整表向量捞回应用算相似度,数据一多就会拖垮 API。
COSINE_SQL = """
CREATE OR REPLACE FUNCTION app_cosine(a real[], b real[]) RETURNS double precision
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE AS $fn$
DECLARE
n int;
dot double precision := 0;
na double precision := 0;
nb double precision := 0;
BEGIN
n := LEAST(array_length(a, 1), array_length(b, 1));
IF n IS NULL THEN
RETURN NULL;
END IF;
FOR i IN 1..n LOOP
dot := dot + a[i] * b[i];
na := na + a[i] * a[i];
nb := nb + b[i] * b[i];
END LOOP;
IF na = 0 OR nb = 0 THEN
RETURN 0;
END IF;
RETURN dot / sqrt(na * nb);
END
$fn$;
"""
CREATE_SQL = """
CREATE TABLE chunk (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
item_id uuid NOT NULL REFERENCES item(id) ON DELETE CASCADE,
idx integer NOT NULL,
heading varchar(200),
content text NOT NULL,
char_count integer NOT NULL DEFAULT 0,
embedding real[],
embed_model varchar(64),
version integer NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_chunk_item_idx UNIQUE (item_id, idx)
)
"""
# asyncpg 的执行器不接受多语句,一条 `execute` 只能带一句 SQL(0001 踩过同样的坑)
INDEX_STATEMENTS = (
"CREATE INDEX ix_chunk_ws_item ON chunk (workspace_id, item_id)",
"COMMENT ON TABLE chunk IS '知识正文切片,检索的最小单位'",
)
POLICY_SQL = (
"ALTER TABLE chunk ENABLE ROW LEVEL SECURITY",
"CREATE POLICY chunk_isolation ON chunk "
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
f"GRANT SELECT, INSERT, UPDATE, DELETE ON chunk TO {APP_ROLE}",
)
def upgrade() -> None:
op.execute(COSINE_SQL)
op.execute(CREATE_SQL)
for statement in INDEX_STATEMENTS:
op.execute(statement)
for statement in POLICY_SQL:
op.execute(statement)
def downgrade() -> None:
op.execute("DROP POLICY IF EXISTS chunk_isolation ON chunk")
op.execute("DROP TABLE IF EXISTS chunk")
# 函数被别的东西引用时先别删;本轮只有切片在用,留一条注释说明为什么还留着。
# op.execute("DROP FUNCTION IF EXISTS app_cosine(real[], real[])")
@@ -0,0 +1,81 @@
"""知识分类(M3 补充)
Revision ID: 0007
Revises: 0006
Create Date: 2026-10-30
知识库的基本形态是"备忘录",不是"任务":一条知识要有标题、正文、标签,
还要能归类。这里补上分类树(一级大类 / 二级 / 三级,深不过三)。
两个取舍:
1. **分类独立成表,不塞进 item**。分类是工作区级的字典,一条知识挂一个分类;
塞进 item 会让"分类"变成每条知识自己的一份副本,改个名字要改几百行。
2. **删分类不删知识**。外键是 `ON DELETE SET NULL`:知识退回"未分类",
分类只是入口,不是知识的容器。真要连内容一起删,那是删知识,得单独确认。
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
from app.roles import APP_ROLE
revision: str = "0007"
down_revision: str = "0006"
branch_labels: Sequence[str] | None = None
depends_on: Sequence[str] | None = None
CREATE_SQL = """
CREATE TABLE knowledge_category (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
parent_id uuid REFERENCES knowledge_category(id) ON DELETE CASCADE,
name varchar(40) NOT NULL,
depth integer NOT NULL DEFAULT 1,
sort_order integer NOT NULL DEFAULT 0,
version integer NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ck_kcat_depth CHECK (depth BETWEEN 1 AND 3),
CONSTRAINT ck_kcat_name CHECK (length(btrim(name)) > 0)
)
"""
INDEX_STATEMENTS = (
"CREATE INDEX ix_kcat_ws_parent ON knowledge_category (workspace_id, parent_id)",
# 同一父级下不许重名。根分类的 parent_id 是 NULL,而 PG 里 NULL 互不相等,
# 所以根要单独一条 `WHERE parent_id IS NULL` 的部分索引,否则可以建出两个同名大类。
"CREATE UNIQUE INDEX uq_kcat_ws_parent_name ON knowledge_category "
"(workspace_id, parent_id, lower(name)) WHERE parent_id IS NOT NULL",
"CREATE UNIQUE INDEX uq_kcat_ws_root_name ON knowledge_category "
"(workspace_id, lower(name)) WHERE parent_id IS NULL",
"COMMENT ON TABLE knowledge_category IS '知识分类树,深不过三'",
"ALTER TABLE knowledge_doc ADD COLUMN category_id uuid "
"REFERENCES knowledge_category(id) ON DELETE SET NULL",
"CREATE INDEX ix_knowledge_ws_category ON knowledge_doc (workspace_id, category_id)",
)
POLICY_SQL = (
"ALTER TABLE knowledge_category ENABLE ROW LEVEL SECURITY",
"CREATE POLICY knowledge_category_isolation ON knowledge_category "
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
f"GRANT SELECT, INSERT, UPDATE, DELETE ON knowledge_category TO {APP_ROLE}",
)
def upgrade() -> None:
op.execute(CREATE_SQL)
for statement in INDEX_STATEMENTS:
op.execute(statement)
for statement in POLICY_SQL:
op.execute(statement)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_knowledge_ws_category")
op.execute("ALTER TABLE knowledge_doc DROP COLUMN IF EXISTS category_id")
op.execute("DROP POLICY IF EXISTS knowledge_category_isolation ON knowledge_category")
op.execute("DROP TABLE IF EXISTS knowledge_category")
+72
View File
@@ -27,10 +27,12 @@ from ..domain.task import (
view_statuses,
)
from ..errors import AppError, ErrorCode
from ..models import Member
from ..models_task import Activity, Comment, Item, ItemTag, Tag
from ..models_ticket import TicketExt
from ..schemas import Pagination, encode_cursor, ok
from ..serializers import item_out, tag_out, tags_of
from ..services.notify import TASK_STATUS_CN, notify
from .attachments import count_for_items, list_for_item
router = APIRouter(prefix="/workspaces/{ws_id}/items", tags=["任务"])
@@ -98,6 +100,25 @@ async def _load_item(
return item
async def _ensure_member(session: AsyncSession, ws_id: uuid.UUID, user_id: uuid.UUID) -> None:
"""被指派的人必须是这个工作区的在册成员。
不校验的话可以指派给任意 uuid:任务从此挂在"不存在的人"名下,
既不会出现在任何人的列表里,也不会有人收到通知。
"""
row = (
await session.execute(
select(Member.id).where(
Member.workspace_id == ws_id,
Member.user_id == user_id,
Member.status == "active",
)
)
).first()
if row is None:
raise AppError(ErrorCode.BAD_REQUEST, "被指派的人不在这个工作区里")
async def _tags_of(
session: AsyncSession, ws_id: uuid.UUID, item_ids: list[uuid.UUID]
) -> dict[uuid.UUID, list[Tag]]:
@@ -452,6 +473,9 @@ async def create_item(body: CreateItemIn, ctx: MemberDep, session: SessionDep) -
fields: dict[str, Any] = {"status": body.status, "completed_at": None}
apply_status(fields, body.status, now=datetime.now(timezone.utc))
if body.assignee_id is not None:
await _ensure_member(session, ctx.ws_id, body.assignee_id)
item = Item(
id=uuid.uuid4(),
workspace_id=ctx.ws_id,
@@ -470,6 +494,17 @@ async def create_item(body: CreateItemIn, ctx: MemberDep, session: SessionDep) -
if body.tag_names:
await _set_tags(session, ctx.ws_id, item, body.tag_names)
await _log(session, item, ctx.user.id, "created", {"status": item.status})
if item.assignee_id is not None:
await notify(
session,
ws_id=ctx.ws_id,
user_ids=(item.assignee_id,),
kind="task_assigned",
item_id=item.id,
title=f"有人把「{item.title}」交给你了",
body=f"{ctx.user.nickname or '同事'} 指派给你,注意截止时间",
actor_id=ctx.user.id,
)
tags = await _tags_of(session, ctx.ws_id, [item.id])
return ok(_item_out(item, tags.get(item.id)), message="已创建")
@@ -576,6 +611,7 @@ async def update_item(
values["assignee_id"] = None
changes["assignee_id"] = None
elif body.assignee_id is not None and body.assignee_id != item.assignee_id:
await _ensure_member(session, ctx.ws_id, body.assignee_id)
values["assignee_id"] = body.assignee_id
changes["assignee_id"] = str(body.assignee_id)
if body.clear_due_at:
@@ -605,6 +641,18 @@ async def update_item(
)
item.version += 1
if values.get("assignee_id") is not None:
await notify(
session,
ws_id=ctx.ws_id,
user_ids=(values["assignee_id"],),
kind="task_assigned",
item_id=item.id,
title=f"「{item.title}」转给你了",
body=f"{ctx.user.nickname or '同事'} 把这件事交给你,原来的办理人已经卸下",
actor_id=ctx.user.id,
)
tags = await _tags_of(session, ctx.ws_id, [item.id])
return ok(_item_out(item, tags.get(item.id)), message="已保存")
@@ -634,6 +682,20 @@ async def change_status(
payload={"from": previous, "to": body.status},
)
tags = await _tags_of(session, ctx.ws_id, [item.id])
await notify(
session,
ws_id=ctx.ws_id,
user_ids=(item.creator_id, item.assignee_id),
kind="task_status_changed",
item_id=item.id,
title=f"「{item.title}」变成{TASK_STATUS_CN.get(body.status, body.status)}了",
body=(
f"{ctx.user.nickname or '同事'} 把它从 "
f"{TASK_STATUS_CN.get(previous, previous)} 改成了 "
f"{TASK_STATUS_CN.get(body.status, body.status)}"
),
actor_id=ctx.user.id,
)
return ok(_item_out(item, tags.get(item.id)), message="状态已更新")
@@ -671,6 +733,16 @@ async def add_comment(
session.add(comment)
await session.flush()
await _log(session, item, ctx.user.id, "commented", {"comment_id": str(comment.id)})
await notify(
session,
ws_id=ctx.ws_id,
user_ids=(item.creator_id, item.assignee_id),
kind="item_commented",
item_id=item.id,
title=f"「{item.title}」有新讨论",
body=f"{ctx.user.nickname or '同事'}:{body.body_md[:120]}",
actor_id=ctx.user.id,
)
return ok(
{
"id": str(comment.id),
+384 -8
View File
@@ -21,18 +21,25 @@ 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 import Select, and_, func, 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 ..models_knowledge import (
DOC_TYPES,
KNOWLEDGE_STATUSES,
MAX_CATEGORY_DEPTH,
KnowledgeCategory,
KnowledgeDoc,
)
from ..models_task import Activity, Item, ItemTag, Tag
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
from ..services.knowledge_index import reindex_document, search_knowledge
from .attachments import count_for_items, list_for_item
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["知识库"])
@@ -46,6 +53,7 @@ class KnowledgeIn(BaseModel):
doc_type: str = "note"
source_item_id: uuid.UUID | None = None
tag_names: list[str] = Field(default_factory=list)
category_id: uuid.UUID | None = None
class KnowledgePatch(BaseModel):
@@ -54,6 +62,24 @@ class KnowledgePatch(BaseModel):
summary: str | None = None
doc_type: str | None = None
tag_names: list[str] | None = None
category_id: uuid.UUID | None = None
# 单独一个开关:`category_id=None` 既可能表示"没改",也可能表示"清空分类",
# 靠 pydantic 的未传/传 null 区分太隐晦,不如显式给一个。
clear_category: bool = False
version: int
class CategoryIn(BaseModel):
name: str = Field(min_length=1, max_length=40)
parent_id: uuid.UUID | None = None
sort_order: int = 0
class CategoryPatch(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=40)
parent_id: uuid.UUID | None = None
move: bool = False
sort_order: int | None = None
version: int
@@ -111,6 +137,7 @@ def _doc_out(doc: KnowledgeDoc) -> dict[str, Any]:
"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,
"category_id": str(doc.category_id) if doc.category_id else None,
"generated_by": doc.generated_by,
"published_at": doc.published_at,
"archived_at": doc.archived_at,
@@ -119,12 +146,96 @@ def _doc_out(doc: KnowledgeDoc) -> dict[str, Any]:
}
# ── 分类 ────────────────────────────────────────────────────────────────
async def _categories(
session: AsyncSession, ws_id: uuid.UUID
) -> dict[uuid.UUID, KnowledgeCategory]:
"""一次把分类全捞出来。分类是工作区级的小字典(几十条),比按需查祖先便宜。"""
rows = (
await session.execute(
select(KnowledgeCategory).where(KnowledgeCategory.workspace_id == ws_id)
)
).scalars()
return {row.id: row for row in rows}
def _category_payload(
cat: KnowledgeCategory, by_id: dict[uuid.UUID, KnowledgeCategory]
) -> dict[str, Any]:
"""分类带上完整路径:`研发 / 部署 / 回滚`。客户端不用自己去拼。"""
names: list[str] = []
node: KnowledgeCategory | None = cat
while node is not None and len(names) < MAX_CATEGORY_DEPTH:
names.append(node.name)
node = by_id.get(node.parent_id) if node.parent_id else None
names.reverse()
return {
"id": str(cat.id),
"parent_id": str(cat.parent_id) if cat.parent_id else None,
"name": cat.name,
"depth": cat.depth,
"sort_order": cat.sort_order,
"version": cat.version,
"path": names,
"full_path": " / ".join(names),
}
def _subtree_ids(root_id: uuid.UUID, by_id: dict[uuid.UUID, KnowledgeCategory]) -> list[uuid.UUID]:
children: dict[uuid.UUID, list[uuid.UUID]] = {}
for cat in by_id.values():
if cat.parent_id:
children.setdefault(cat.parent_id, []).append(cat.id)
out = [root_id]
stack = [root_id]
while stack:
for child in children.get(stack.pop(), []):
out.append(child)
stack.append(child)
return out
async def _resolve_category(
session: AsyncSession, ws_id: uuid.UUID, category_id: uuid.UUID | None
) -> KnowledgeCategory | None:
if category_id is None:
return None
cat = await session.get(KnowledgeCategory, category_id)
if cat is None or cat.workspace_id != ws_id:
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在")
return cat
async def _category_name_taken(
session: AsyncSession,
ws_id: uuid.UUID,
parent_id: uuid.UUID | None,
name: str,
exclude: uuid.UUID | None = None,
) -> bool:
stmt = select(KnowledgeCategory.id).where(
KnowledgeCategory.workspace_id == ws_id,
func.lower(KnowledgeCategory.name) == name.strip().lower(),
)
stmt = (
stmt.where(KnowledgeCategory.parent_id.is_(None))
if parent_id is None
else stmt.where(KnowledgeCategory.parent_id == parent_id)
)
if exclude is not None:
stmt = stmt.where(KnowledgeCategory.id != exclude)
return (await session.execute(stmt)).first() is not None
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)
by_id = await _categories(session, ws_id)
cat = by_id.get(doc.category_id) if doc.category_id else None
data["category"] = _category_payload(cat, by_id) if cat else None
data["source"] = await _source_out(session, ws_id, doc.source_item_id)
data["attachments"] = await list_for_item(session, ws_id, item.id)
data["attachment_count"] = len(data["attachments"])
@@ -147,6 +258,9 @@ async def list_knowledge(
status: Annotated[list[str] | None, Query()] = None,
doc_type: Annotated[str | None, Query()] = None,
source_item_id: Annotated[uuid.UUID | None, Query()] = None,
category_id: Annotated[uuid.UUID | None, Query(description="含子分类")] = None,
uncategorized: Annotated[bool, Query(description="只看未分类")] = False,
tag: Annotated[str | None, Query(max_length=32)] = 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,
@@ -164,6 +278,21 @@ async def list_knowledge(
stmt = stmt.where(KnowledgeDoc.doc_type == doc_type)
if source_item_id:
stmt = stmt.where(KnowledgeDoc.source_item_id == source_item_id)
by_id: dict[uuid.UUID, KnowledgeCategory] = {}
if uncategorized:
stmt = stmt.where(KnowledgeDoc.category_id.is_(None))
elif category_id is not None:
by_id = await _categories(session, ws_id)
cat = by_id.get(category_id)
if cat is None:
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在")
stmt = stmt.where(KnowledgeDoc.category_id.in_(_subtree_ids(category_id, by_id)))
if tag:
stmt = (
stmt.join(ItemTag, ItemTag.item_id == Item.id)
.join(Tag, Tag.id == ItemTag.tag_id)
.where(Tag.workspace_id == ws_id, func.lower(Tag.name) == tag.strip().lower())
)
if q:
like = f"%{q.strip()}%"
stmt = stmt.where(
@@ -187,12 +316,16 @@ async def list_knowledge(
has_more = len(rows) > limit
rows = rows[:limit]
tag_map = await tags_of(session, ws_id, [it.id for it, _ in rows])
if not by_id:
by_id = await _categories(session, ws_id)
items = []
counts = await count_for_items(session, ws_id, [it.id for it, _ in rows])
for item, doc in rows:
data = item_out(item, tag_map.get(item.id, []))
data["knowledge"] = _doc_out(doc)
cat = by_id.get(doc.category_id) if doc.category_id else None
data["category"] = _category_payload(cat, by_id) if cat else None
data["source"] = await _source_out(session, ws_id, doc.source_item_id)
data["attachment_count"] = counts.get(str(item.id), 0)
items.append(data)
@@ -203,6 +336,221 @@ async def list_knowledge(
return ok({"items": items, "next_cursor": next_cursor, "has_more": has_more})
# ── 分类的增删改查 ──────────────────────────────────────────────────────
# 路由顺序:`/knowledge/categories` 必须排在 `/knowledge/{item_id}` 之前,
# 否则 "categories" 会被当成 uuid 解析,直接 422。
@router.get("/knowledge/categories", summary="知识分类")
async def list_categories(ctx: MemberDep, session: SessionDep) -> dict:
by_id = await _categories(session, ctx.ws_id)
counts = dict(
(
await session.execute(
select(KnowledgeDoc.category_id, func.count())
.join(Item, Item.id == KnowledgeDoc.item_id)
.where(
KnowledgeDoc.workspace_id == ctx.ws_id,
KnowledgeDoc.category_id.is_not(None),
KnowledgeDoc.status_hint != "archived",
Item.deleted_at.is_(None),
)
.group_by(KnowledgeDoc.category_id)
)
).all()
)
items = []
for cat in sorted(by_id.values(), key=lambda c: (c.depth, c.sort_order, c.name)):
data = _category_payload(cat, by_id)
data["doc_count"] = counts.get(cat.id, 0)
items.append(data)
return ok({"items": items, "max_depth": MAX_CATEGORY_DEPTH})
@router.post("/knowledge/categories", summary="新建分类")
async def create_category(body: CategoryIn, ctx: MemberDep, session: SessionDep) -> dict:
ws_id = ctx.ws_id
depth = 1
if body.parent_id is not None:
parent = await _resolve_category(session, ws_id, body.parent_id)
depth = parent.depth + 1
if depth > MAX_CATEGORY_DEPTH:
raise AppError(
ErrorCode.KNOWLEDGE_CATEGORY_DEPTH,
f"分类最多 {MAX_CATEGORY_DEPTH} 级,「{parent.name}」下面不能再建了",
details={"parent_depth": parent.depth},
)
name = body.name.strip()
if await _category_name_taken(session, ws_id, body.parent_id, name):
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT, "同一层已经有同名的分类")
cat = KnowledgeCategory(
workspace_id=ws_id,
parent_id=body.parent_id,
name=name,
depth=depth,
sort_order=body.sort_order,
)
session.add(cat)
await session.flush()
await audit_record(
session,
workspace_id=ws_id,
actor_user_id=ctx.user.id,
action="knowledge.category.create",
target_type="knowledge_category",
target_id=str(cat.id),
)
by_id = await _categories(session, ws_id)
return ok(_category_payload(cat, by_id))
@router.patch("/knowledge/categories/{category_id}", summary="改名或移动分类")
async def patch_category(
category_id: uuid.UUID, body: CategoryPatch, ctx: MemberDep, session: SessionDep
) -> dict:
ws_id = ctx.ws_id
cat = await session.get(KnowledgeCategory, category_id)
if cat is None or cat.workspace_id != ws_id:
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在")
by_id = await _categories(session, ws_id)
now = datetime.now(timezone.utc)
new_parent_id = cat.parent_id
new_depth = cat.depth
shift = 0
subtree: list[uuid.UUID] = []
if body.move:
new_parent_id = body.parent_id
if new_parent_id is not None and new_parent_id not in by_id:
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在")
if new_parent_id == category_id:
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_DEPTH, "不能把分类挂到自己下面")
subtree = _subtree_ids(category_id, by_id)
if new_parent_id is not None and new_parent_id in subtree:
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_DEPTH, "不能把分类移到它自己的子分类下")
new_depth = by_id[new_parent_id].depth + 1 if new_parent_id else 1
deepest = max(by_id[cid].depth for cid in subtree)
shift = new_depth - cat.depth
if deepest + shift > MAX_CATEGORY_DEPTH:
raise AppError(
ErrorCode.KNOWLEDGE_CATEGORY_DEPTH,
f"移动后子树最深到第 {deepest + shift} 级,超过 {MAX_CATEGORY_DEPTH} 级",
details={"deepest_after_move": deepest + shift},
)
name = body.name.strip() if body.name is not None else cat.name
if name != cat.name and await _category_name_taken(
session, ws_id, new_parent_id, name, exclude=category_id
):
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT, "同一层已经有同名的分类")
values: dict[str, Any] = {"name": name, "updated_at": now}
if body.move:
values.update(parent_id=new_parent_id, depth=new_depth)
if body.sort_order is not None:
values["sort_order"] = body.sort_order
result = await session.execute(
update(KnowledgeCategory)
.where(
KnowledgeCategory.id == category_id,
KnowledgeCategory.workspace_id == ws_id,
KnowledgeCategory.version == body.version,
)
.values(version=KnowledgeCategory.version + 1, **values)
)
if result.rowcount == 0:
await session.refresh(cat)
raise AppError(
ErrorCode.VERSION_CONFLICT,
"这个分类刚被改过",
details={"current_version": cat.version},
)
# 子树跟着整体升降;只改被移动的那棵子树以外的行会留下 depth 不一致
if body.move and shift:
for cid in subtree:
if cid == category_id:
continue
await session.execute(
update(KnowledgeCategory)
.where(KnowledgeCategory.id == cid, KnowledgeCategory.workspace_id == ws_id)
.values(
depth=KnowledgeCategory.depth + shift,
updated_at=now,
version=KnowledgeCategory.version + 1,
)
)
await session.flush()
fresh = await _categories(session, ws_id)
return ok(_category_payload(fresh[category_id], fresh))
@router.delete("/knowledge/categories/{category_id}", summary="删除分类")
async def delete_category(
category_id: uuid.UUID,
ctx: MemberDep,
session: SessionDep,
force: Annotated[bool, Query(description="真删:里面的知识变成未分类")] = False,
) -> dict:
ws_id = ctx.ws_id
cat = await session.get(KnowledgeCategory, category_id)
if cat is None or cat.workspace_id != ws_id:
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在")
children = (
await session.execute(
select(func.count())
.select_from(KnowledgeCategory)
.where(
KnowledgeCategory.workspace_id == ws_id,
KnowledgeCategory.parent_id == category_id,
)
)
).scalar_one()
if children:
raise AppError(
ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT,
f"「{cat.name}」下面还有 {children} 个子分类,先把它们移走或删掉",
details={"children": children},
)
docs = (
await session.execute(
select(func.count())
.select_from(KnowledgeDoc)
.where(KnowledgeDoc.workspace_id == ws_id, KnowledgeDoc.category_id == category_id)
)
).scalar_one()
if docs and not force:
raise AppError(
ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT,
f"「{cat.name}」里还有 {docs} 条知识,先移走,或者带 force 删成未分类",
details={"doc_count": docs},
)
await session.delete(cat)
await audit_record(
session,
workspace_id=ws_id,
actor_user_id=ctx.user.id,
action="knowledge.category.delete",
target_type="knowledge_category",
target_id=str(category_id),
payload={"orphaned_docs": docs},
)
return ok({"deleted": str(category_id), "uncategorized_docs": docs})
# 注意路由顺序:`/knowledge/search` 必须排在 `/knowledge/{item_id}` 之前,
# 否则 "search" 会被当成 uuid 解析,返回 422。
@router.get("/knowledge/search", summary="检索已发布知识")
async def search(
ctx: MemberDep,
session: SessionDep,
q: Annotated[str, Query(min_length=1, max_length=200, description="检索词")],
limit: Annotated[int, Query(ge=1, le=30)] = 10,
mode: Annotated[str, Query(pattern="^(hybrid|vector|keyword)$")] = "hybrid",
) -> dict:
hits = await search_knowledge(session, ctx.ws_id, q, limit=limit, mode=mode)
return ok({"query": q, "mode": mode, "items": hits, "has_more": False})
@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)
@@ -242,6 +590,7 @@ async def create_knowledge(body: KnowledgeIn, ctx: MemberDep, session: SessionDe
ErrorCode.KNOWLEDGE_SOURCE_REQUIRED,
"来源必须是与本知识同一工作区的任务或工单",
)
category = await _resolve_category(session, ws_id, body.category_id)
item = Item(
workspace_id=ws_id,
@@ -263,6 +612,7 @@ async def create_knowledge(body: KnowledgeIn, ctx: MemberDep, session: SessionDe
doc_type=body.doc_type,
status_hint="draft",
summary=body.summary,
category_id=category.id if category else None,
generated_by="human",
)
session.add(doc)
@@ -295,17 +645,19 @@ async def patch_knowledge(
if body.doc_type is not None and body.doc_type not in DOC_TYPES:
raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{body.doc_type}")
# 这里只放 Item 表自己的列:doc_type / summary 属于 knowledge_doc,
# 混进来会直接 CompileError: Unconsumed column names
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:
# 分类/标签/类型也改的是这条知识的"样子",所以同样要推进 version:
# 否则只改了标签的知识,下一次保存还会拿着旧 version 撞 4009。
doc_changed = body.doc_type is not None or body.summary is not None
meta_changed = body.clear_category or body.category_id is not None or body.tag_names is not None
if values or doc_changed or meta_changed:
values["updated_at"] = datetime.now(timezone.utc)
result = await session.execute(
update(Item)
@@ -329,6 +681,11 @@ async def patch_knowledge(
doc.doc_type = body.doc_type
if body.summary is not None:
doc.summary = body.summary
if body.clear_category:
doc.category_id = None
elif body.category_id is not None:
category = await _resolve_category(session, ws_id, body.category_id)
doc.category_id = category.id if category else None
if body.tag_names is not None:
await _set_tags_for(session, ws_id, item, body.tag_names)
await session.flush()
@@ -396,9 +753,28 @@ async def change_status(
payload={"to": to_status},
)
await session.flush()
if body.action == "publish":
# 发布后立刻建索引:检索只认 published,不在这里建就要等定时任务,
# 而"刚发布却搜不到"是最容易被当成 bug 的体验。
await reindex_document(session, ws_id, item.id)
return ok(await _knowledge_out(session, ws_id, item, doc))
@router.post("/knowledge/{item_id}/reindex", summary="重建这份知识的检索切片")
async def reindex(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
# 改完正文不重建,检索命中的还是旧句子——留一个能手动触发的入口
_item, doc = await _load_doc(session, ctx.ws_id, item_id)
count = await reindex_document(session, ctx.ws_id, item_id)
await session.flush()
return ok(
{
"item_id": item_id,
"chunk_count": count,
"searchable": doc.status_hint == "published",
}
)
# ── 从任务/工单沉淀 ─────────────────────────────────────────────────────
@router.post("/items/{item_id}/knowledge", summary="把这件事沉淀成知识草稿")
async def promote_to_knowledge(
+83 -9
View File
@@ -39,6 +39,7 @@ from ..models_task import Activity, Item
from ..models_ticket import ItemLink, Notification, TicketExt
from ..schemas import Pagination, encode_cursor, ok
from ..serializers import item_out, tags_of
from ..services.notify import notify
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["工单"])
notify_router = APIRouter(prefix="/workspaces/{ws_id}/notifications", tags=["通知"])
@@ -60,6 +61,16 @@ class ReassignIn(BaseModel):
assignee_id: uuid.UUID
class TicketCreateIn(BaseModel):
"""手工开单:不经过任务升级,直接指定谁去办。"""
title: str = Field(min_length=1, max_length=200)
body_md: str | None = Field(default=None, max_length=20000)
assignee_id: uuid.UUID
due_at: datetime | None = None
priority: int = Field(default=0, ge=0, le=3)
class VerifyIn(BaseModel):
approve: bool
note: str = Field(default="", max_length=MAX_REJECT_NOTE)
@@ -139,16 +150,18 @@ async def _notify(
item_id: uuid.UUID | None,
title: str,
body: str = "",
actor_id: uuid.UUID | None = None,
) -> None:
session.add(
Notification(
workspace_id=ws_id,
user_id=user_id,
kind=kind,
item_id=item_id,
title=title[:120],
body=body[:300],
)
"""单人便捷入口,实际写入交给 services.notify(去重与跳过自己在那里做)。"""
await notify(
session,
ws_id=ws_id,
user_ids=(user_id,),
kind=kind,
item_id=item_id,
title=title,
body=body,
actor_id=actor_id,
)
@@ -520,6 +533,67 @@ async def reassign(
return await _ticket_out(session, ctx.ws_id, ticket, ext)
# -- 手工开单 -----------------------------------------------------------
@router.post("/tickets", summary="手工开一张工单(不依赖任务升级)")
async def create_ticket(body: TicketCreateIn, ctx: MemberDep, session: SessionDep) -> dict:
"""开会、口头上被派活、临时找同事帮忙——这些事从来没有过"任务"。
工单不是任务的衍生物,只是"要别人办的事"的一种形态:它可以凭空存在,
也可以由任务升级而来(那条路径见 promote)。两条路都落在 kind=ticket 的
同一条 item 上,所以看板、评论、验收、沉淀知识全都共用。
"""
await _active_member_id(session, ctx.ws_id, body.assignee_id)
ticket = Item(
workspace_id=ctx.ws_id,
kind="ticket",
title=body.title.strip(),
body_md=body.body_md,
status="open",
priority=body.priority,
creator_id=ctx.user.id,
assignee_id=body.assignee_id,
due_at=body.due_at,
)
session.add(ticket)
await session.flush()
ext = TicketExt(
item_id=ticket.id,
workspace_id=ctx.ws_id,
requester_id=ctx.user.id,
assignee_id=body.assignee_id,
parent_item_id=None,
)
session.add(ext)
await session.flush()
await _log(session, ticket, ctx.user.id, "ticket_created", {"manual": True})
await _notify(
session,
ws_id=ctx.ws_id,
user_id=body.assignee_id,
kind="ticket_assigned",
item_id=ticket.id,
title=f"有人请你办一件事:{ticket.title}",
body=f"{ctx.user.nickname or '同事'} 直接给你开了张工单,办完提交验收",
actor_id=ctx.user.id,
)
await audit_record(
session,
workspace_id=ctx.ws_id,
actor_user_id=ctx.user.id,
action="ticket.create",
target_type="item",
target_id=str(ticket.id),
payload={"manual": True},
)
await session.flush()
ticket_out = await _item_out(session, ctx.ws_id, ticket)
return ok({**ticket_out, "ticket": _ticket_ext_out(ext)}, message="已创建")
# -- 工单列表 -----------------------------------------------------------
@router.get("/tickets", summary="工单列表:我指派的 / 我发起的")
async def list_tickets(
+34 -2
View File
@@ -57,6 +57,9 @@ class Settings(BaseSettings):
sms_throttle_seconds: int = 60
sms_dev_code: str = "123456"
sms_provider: Literal["dev", "aliyun", "tencent"] = "dev"
# 内测开关:生产默认禁止固定验证码。确实要在内网试用时显式打开,
# 启动会打 WARNING(见 main.py),其余生产校验照旧——绝不能配合公网暴露。
internal_test_login: bool = False
# ── 幂等 ──────────────────────────────────────────────
idempotency_enabled: bool = True
@@ -71,6 +74,27 @@ class Settings(BaseSettings):
# 30 分钟够一次阅读/编辑,过期后刷新页面会重新签发。
attachment_url_ttl_seconds: int = 1800
# ── 知识检索(M3)──────────────────────────────────────
# 切片大小与重叠:中文一段 600 字大约 400~600 token,够表达一个完整片段;
# 重叠 80 字是为了不把跨段的话切断(检索命中时前后文还读得懂)。
chunk_max_chars: int = 600
chunk_overlap_chars: int = 80
# 嵌入向量提供方:
# hash —— 内置的字符 n-gram 哈希向量,离线可用、零依赖、可复现;
# 质量不如真模型,但能把"检索链路"跑通,且换模型时接口不变。
# openai —— 任何 OpenAI 兼容的 /embeddings 端点(BYOK)。
embedding_provider: Literal["hash", "openai"] = "hash"
embedding_base_url: str = "https://api.deepseek.com/v1"
embedding_api_key: str | None = None
embedding_model: str = "text-embedding-3-small"
# 混合检索里向量与词法的权重。纯词法搜不到同义改写,纯向量搜不准专有名词,
# 所以默认各占一半,可用环境变量调。
search_vector_weight: float = 0.6
search_lexical_weight: float = 0.4
# 低于这个分数就不返回:宁可"没找到",也不要塞一条不相干的让人误信。
search_min_score: float = 0.06
# ── 知识库向量库(独立实例,M3 起用)───────────────────
# 刻意与业务库分开放:fnsvr-01 上的 PG 没有 pgvector,也不该为了装扩展去动它;
# 向量检索吃内存,和任务库挤在同一实例会互相拖累。
@@ -79,6 +103,11 @@ class Settings(BaseSettings):
embedding_dim: int = 1024
embedding_model: str = "BAAI/bge-m3"
# ── 前端产物 ──────────────────────────────────────────
# 生产没有 nginx 时由后端直接托管前端构建产物(同源,/api 无跨域)。
# 指向 web/dist 的绝对路径;留空则只提供 API。
web_dist_dir: str | None = None
# ── 构建标记(部署校验用)──────────────────────────────
build_commit: str = "unknown"
build_time: str = "unknown"
@@ -100,8 +129,11 @@ class Settings(BaseSettings):
problems.append("JWT_SECRET 仍是开发默认值或长度不足 32")
if "dadao_dev_pw" in self.database_url:
problems.append("DATABASE_URL 仍在用开发默认口令")
if self.sms_provider == "dev":
problems.append("生产环境不允许 SMS_PROVIDER=dev(任意验证码可登录)")
if self.sms_provider == "dev" and not self.internal_test_login:
problems.append(
"生产环境不允许 SMS_PROVIDER=dev(任意验证码可登录);"
"内网试用确需固定验证码时显式设置 INTERNAL_TEST_LOGIN=true"
)
if problems:
raise RuntimeError("配置校验失败:" + ";".join(problems))
+127
View File
@@ -0,0 +1,127 @@
"""把 Markdown 正文切成适合检索的小片段(M3)。
切片的目的是"喂给检索",不是"给人读",所以三条规则优先于其它考虑:
1. **不切坏结构**:代码块整块保留,表格不拆行,标题跟着它下面的段落走
(单独一句"结论"离开上下文就没意义)。
2. **片内自洽**:每片尽量包含一个完整意思,超长才按句号切。
3. **相邻片有重叠**:跨段的话被切断时,前后片都能读到完整句子,命中后引用才不乱。
不引入分词器:中文另起 `zhparser` 要装扩展,而片段足够短时字符级检索已经够用。
"""
from __future__ import annotations
import re
from dataclasses import dataclass
HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
FENCE_RE = re.compile(r"^\s*```")
SENTENCE_RE = re.compile(r"[^。!?!?;;\n]+[。!?!?;;]?")
@dataclass(frozen=True)
class ChunkDraft:
heading: str | None
content: str
def _split_long(text: str, max_chars: int) -> list[str]:
"""把超长段落按句子切开;单句仍然超长就硬切。"""
pieces: list[str] = []
buffer = ""
for sentence in SENTENCE_RE.findall(text):
if not sentence.strip():
continue
if len(buffer) + len(sentence) <= max_chars:
buffer += sentence
continue
if buffer.strip():
pieces.append(buffer.strip())
if len(sentence) <= max_chars:
buffer = sentence
else:
for start in range(0, len(sentence), max_chars):
pieces.append(sentence[start : start + max_chars].strip())
buffer = ""
if buffer.strip():
pieces.append(buffer.strip())
return [p for p in pieces if p]
def split_markdown(
text: str,
*,
max_chars: int = 600,
overlap: int = 80,
) -> list[ChunkDraft]:
"""返回按顺序排列的切片。
`overlap` 是相邻片之间共享的尾部字数;不想要重叠就传 0。
"""
if not text or not text.strip():
return []
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
blocks: list[tuple[str | None, str]] = []
heading: str | None = None
paragraph: list[str] = []
in_fence = False
def flush() -> None:
if paragraph:
body = "\n".join(paragraph).strip()
if body:
blocks.append((heading, body))
paragraph.clear()
for line in normalized.split("\n"):
if FENCE_RE.match(line):
in_fence = not in_fence
paragraph.append(line)
continue
if not in_fence:
match = HEADING_RE.match(line)
if match:
flush()
heading = match.group(2).strip()
continue
if not line.strip():
flush()
continue
paragraph.append(line)
flush()
chunks: list[ChunkDraft] = []
buffer = ""
buffer_heading: str | None = None
def flush_buffer() -> None:
nonlocal buffer, buffer_heading
if buffer.strip():
chunks.append(ChunkDraft(heading=buffer_heading, content=buffer.strip()))
buffer = ""
for block_heading, body in blocks:
parts = _split_long(body, max_chars) if len(body) > max_chars else [body]
for part in parts:
candidate = f"{buffer}\n\n{part}".strip() if buffer else part
if len(candidate) <= max_chars:
if not buffer:
buffer_heading = block_heading
buffer = candidate
continue
flush_buffer()
# 新片开头带上上一片的尾巴,避免"半句话"被切断后两边都读不通
tail = ""
if overlap > 0 and chunks:
tail = chunks[-1].content[-overlap:]
if "\n" in tail:
tail = tail.split("\n", 1)[-1]
buffer_heading = block_heading
buffer = f"{tail}\n\n{part}".strip() if tail else part
if len(buffer) > max_chars:
# 带尾巴后仍然超长:放弃尾巴,保证不超限
buffer = part
flush_buffer()
return chunks
+6
View File
@@ -56,6 +56,9 @@ class ErrorCode(IntEnum):
KNOWLEDGE_STATUS_INVALID = 7003
KNOWLEDGE_FORBIDDEN = 7004
KNOWLEDGE_SOURCE_REQUIRED = 7005
KNOWLEDGE_CATEGORY_NOT_FOUND = 7006
KNOWLEDGE_CATEGORY_DEPTH = 7007
KNOWLEDGE_CATEGORY_CONFLICT = 7008
# 8xxx 附件
ATTACHMENT_NOT_FOUND = 8001
@@ -101,6 +104,9 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
ErrorCode.KNOWLEDGE_STATUS_INVALID: 409,
ErrorCode.KNOWLEDGE_FORBIDDEN: 403,
ErrorCode.KNOWLEDGE_SOURCE_REQUIRED: 409,
ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND: 404,
ErrorCode.KNOWLEDGE_CATEGORY_DEPTH: 422,
ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT: 409,
ErrorCode.ATTACHMENT_NOT_FOUND: 404,
ErrorCode.ATTACHMENT_TOO_LARGE: 413,
ErrorCode.ATTACHMENT_FORBIDDEN: 403,
+28
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from fastapi import FastAPI, Request
@@ -11,6 +12,9 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy import text
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import Response
from starlette.staticfiles import StaticFiles
from starlette.types import Scope
from .api import attachments, auth, items, knowledge, tickets, workspaces
from .config import get_settings
@@ -27,6 +31,11 @@ logger = get_logger("app")
@asynccontextmanager
async def lifespan(app: FastAPI):
settings.validate_runtime()
if settings.env == "prod" and settings.internal_test_login:
logger.warning(
"内测登录已开启:任意手机号可用固定验证码登录,只能在内网访问,切勿暴露公网",
extra={"extra_fields": {"sms_provider": settings.sms_provider}},
)
logger.info(
"服务启动",
extra={"extra_fields": {"env": settings.env, "commit": settings.build_commit}},
@@ -138,3 +147,22 @@ async def version() -> dict:
"api_prefix": settings.api_prefix,
"db_role": role,
}
# ── 前端产物:单端口部署(没有 nginx 时也让一个端口提供完整站点)────────
# 顺序很重要:必须挂在所有 API 路由之后,否则挂载 "/" 会吞掉 /api、/health、/version。
class _SPAStaticFiles(StaticFiles):
"""静态命中就返回,未命中回退 index.html——前端路由(如 /w/<id>)交给浏览器处理。"""
async def get_response(self, path: str, scope: Scope) -> Response:
try:
return await super().get_response(path, scope)
except StarletteHTTPException as exc:
if exc.status_code == 404:
return await super().get_response("index.html", scope)
raise
_web_dist = Path(settings.web_dist_dir) if settings.web_dist_dir else None
if _web_dist is not None and (_web_dist / "index.html").is_file():
app.mount("/", _SPAStaticFiles(directory=str(_web_dist), html=True), name="web")
+70 -2
View File
@@ -19,15 +19,18 @@ from datetime import datetime
from sqlalchemy import (
CheckConstraint,
DateTime,
Float,
ForeignKey,
Index,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.dialects.postgresql import UUID as PgUUID
from sqlalchemy.orm import Mapped, mapped_column
from .models import Base, Timestamps
from .models import Base, Timestamps, new_uuid
# 知识状态机(与任务/工单共用 item.status 一列,取值集合不同)
KNOWLEDGE_STATUSES: tuple[str, ...] = ("draft", "in_review", "published", "archived")
@@ -36,6 +39,36 @@ DOC_TYPES: tuple[str, ...] = ("note", "howto", "faq", "postmortem", "decision")
SOURCE_KINDS: tuple[str, ...] = ("manual", "task", "ticket")
GENERATED_BY: tuple[str, ...] = ("human", "rule")
# 分类树最多三级。再深就不是"归档"而是"迷宫"了:找东西的人记不住第四层叫什么。
MAX_CATEGORY_DEPTH = 3
class KnowledgeCategory(Base, Timestamps):
"""知识分类:一级大类 → 二级 → 三级,深不过三。
`depth` 是冗余列,但值得:每次筛选、移动子树都要判断层级,存下来就不用
递归数祖先。写入时由 API 计算,读到不一致的行属于数据事故。
"""
__tablename__ = "knowledge_category"
__table_args__ = (
Index("ix_kcat_ws_parent", "workspace_id", "parent_id"),
CheckConstraint("depth BETWEEN 1 AND 3", name="ck_kcat_depth"),
CheckConstraint("length(btrim(name)) > 0", name="ck_kcat_name"),
{"comment": "知识分类树,深不过三"},
)
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
workspace_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
)
parent_id: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("knowledge_category.id", ondelete="CASCADE")
)
name: Mapped[str] = mapped_column(String(40), nullable=False)
depth: Mapped[int] = mapped_column(nullable=False, default=1)
sort_order: Mapped[int] = mapped_column(nullable=False, default=0)
class KnowledgeDoc(Base, Timestamps):
"""知识的附加信息,与 item 一比一。"""
@@ -44,6 +77,7 @@ class KnowledgeDoc(Base, Timestamps):
__table_args__ = (
Index("ix_knowledge_ws_status", "workspace_id", "status_hint"),
Index("ix_knowledge_ws_source", "workspace_id", "source_item_id"),
Index("ix_knowledge_ws_category", "workspace_id", "category_id"),
CheckConstraint(
"source_kind = 'manual' OR source_item_id IS NOT NULL",
name="ck_knowledge_source_required",
@@ -69,6 +103,10 @@ class KnowledgeDoc(Base, Timestamps):
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")
# 分类可以为空("未分类");分类被删时知识不跟着消失,只是回到未分类
category_id: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("knowledge_category.id", ondelete="SET NULL")
)
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))
@@ -76,4 +114,34 @@ class KnowledgeDoc(Base, Timestamps):
chunk_count: Mapped[int] = mapped_column(nullable=False, default=0)
KNOWLEDGE_TENANT_TABLES: tuple[str, ...] = ("knowledge_doc",)
class Chunk(Base):
"""知识正文的切片。
为什么切片与文档同库:检索要按工作区过滤,跨库做权限校验就得把两边数据捞到应用里,
既不安全也不快。向量本身是普通数组,换 pgvector 时只换列类型与索引。
"""
__tablename__ = "chunk"
__table_args__ = (
UniqueConstraint("item_id", "idx", name="uq_chunk_item_idx"),
Index("ix_chunk_ws_item", "workspace_id", "item_id"),
{"comment": "知识正文切片,检索的最小单位"},
)
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
workspace_id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), nullable=False)
item_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), nullable=False
)
# 片内顺序,用于把命中片段按原顺序还原上下文
idx: Mapped[int] = mapped_column(nullable=False)
heading: Mapped[str | None] = mapped_column(String(200))
content: Mapped[str] = mapped_column(Text, nullable=False)
char_count: Mapped[int] = mapped_column(nullable=False, default=0)
# 向量。hash 提供方是 256 维,OpenAI 兼容端点通常是 1024/1536 维,
# 所以不固定维度——换模型时老切片要么重算,要么维度不匹配(检索会拒绝并提示重建)。
embedding: Mapped[list[float] | None] = mapped_column(ARRAY(Float))
embed_model: Mapped[str | None] = mapped_column(String(64))
KNOWLEDGE_TENANT_TABLES: tuple[str, ...] = ("knowledge_category", "knowledge_doc", "chunk")
+28
View File
@@ -0,0 +1,28 @@
"""迁移里用到的数据库角色名。
为什么要有这个模块:RLS 的隔离强度取决于「应用角色不是表 owner、没有 BYPASSRLS」。
开发和生产的库在同一个 PG 实例上(fnsvr-01:8432),如果两边共用 `dadao_app` 角色,
拿到开发口令的人就能直连生产库——所以生产用独立角色,由迁移时的环境变量注入。
默认值就是开发/测试的角色名,所以本地和 CI 行为不变。
"""
from __future__ import annotations
import os
import re
# PostgreSQL 标识符:小写字母/数字/下划线,且不能以数字开头
_ROLE_RE = re.compile(r"^[a-z_][a-z0-9_]{0,62}$")
def _role_from_env(env_name: str, default: str) -> str:
value = os.getenv(env_name, default)
if not _ROLE_RE.match(value):
raise RuntimeError(f"{env_name} 不是合法的 PostgreSQL 角色名:{value!r}")
return value
# 迁移角色(owner,可建表,绕过 RLS)与应用角色(只能读写数据,受 RLS 约束)
OWNER_ROLE = _role_from_env("OWNER_DB_ROLE", "dadao_owner")
APP_ROLE = _role_from_env("APP_DB_ROLE", "dadao_app")
+131
View File
@@ -0,0 +1,131 @@
"""文本向量化(M3)。
为什么要抽象一层:今天用内置的哈希向量,明天换成 OpenAI 兼容的 `/embeddings`,
后天可能换成自建模型。检索代码只依赖 `embed() -> list[float]`,换实现不动业务。
内置 `HashEmbedding` 的诚实说明:它不是"语义模型",是**字符 n-gram 的哈希词袋**。
它能把同词、近义写法("发票"/"票据"这种)之外的东西区分开,抓不到真正的语义相似。
之所以先上它:本机没有可用的向量库与模型服务,检索链路不能一直不落地;
而且它零依赖、可复现(同样的文本永远同样的向量),适合做测试基线。
真上线前应当把 `EMBEDDING_PROVIDER` 换成 `openai`,并把已发布知识整体重建一次。
"""
from __future__ import annotations
import hashlib
import math
import re
from collections.abc import Sequence
from typing import Protocol
import httpx
from ..config import Settings, get_settings
CJK_RE = re.compile(r"[\u4e00-\u9fff]")
WORD_RE = re.compile(r"[a-z0-9]+")
class EmbeddingProvider(Protocol):
"""向量提供方。`name` 会写进切片,换模型后能看出哪些是旧向量。"""
name: str
async def embed(self, texts: Sequence[str]) -> list[list[float]]: ...
def _tokens(text: str) -> list[str]:
"""中文按字二元组、英文按词。
中文不用分词器:单字太碎("发""票"都对不上"发票"),二元组能拿到大多数
有意义的搭配,又不需要维护词典。
"""
lowered = text.lower()
tokens = WORD_RE.findall(lowered)
chars = [c for c in lowered if CJK_RE.match(c)]
tokens.extend(chars[i] + chars[i + 1] for i in range(len(chars) - 1))
tokens.extend(chars)
return tokens
class HashEmbedding:
"""字符 n-gram 的带符号哈希词袋,L2 归一化。"""
def __init__(self, dim: int = 256) -> None:
self.dim = dim
self.name = f"hash-{dim}"
async def embed(self, texts: Sequence[str]) -> list[list[float]]:
return [self._one(text) for text in texts]
def _one(self, text: str) -> list[float]:
vector = [0.0] * self.dim
counts: dict[str, int] = {}
for token in _tokens(text):
counts[token] = counts.get(token, 0) + 1
for token, count in counts.items():
digest = hashlib.blake2b(token.encode("utf-8"), digest_size=8).digest()
raw = int.from_bytes(digest, "big")
index = raw % self.dim
# 用最高位决定正负:无符号哈希会让不相干的词在向量里越加越像
sign = 1.0 if (raw >> 63) & 1 else -1.0
vector[index] += sign * (1.0 + math.log(count))
norm = math.sqrt(sum(v * v for v in vector))
if norm == 0:
return vector
return [v / norm for v in vector]
class OpenAIEmbedding:
"""任何 OpenAI 兼容的 `/embeddings` 端点(BYOK)。"""
def __init__(self, *, base_url: str, api_key: str, model: str) -> None:
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.model = model
self.name = f"openai:{model}"
async def embed(self, texts: Sequence[str]) -> list[list[float]]:
if not texts:
return []
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{self.base_url}/embeddings",
headers={"Authorization": f"Bearer {self.api_key}"},
json={"model": self.model, "input": list(texts)},
)
response.raise_for_status()
payload = response.json()
# 按 index 排序:并发端点的返回顺序没有保证
rows = sorted(payload["data"], key=lambda row: row["index"])
return [row["embedding"] for row in rows]
_provider: EmbeddingProvider | None = None
def get_provider(settings: Settings | None = None) -> EmbeddingProvider:
"""进程内单例:哈希实现无状态,OpenAI 实现也不需要每次新建连接池。"""
global _provider
if _provider is not None:
return _provider
settings = settings or get_settings()
if settings.embedding_provider == "openai":
if not settings.embedding_api_key:
raise RuntimeError(
"EMBEDDING_PROVIDER=openai 但没有配置 EMBEDDING_API_KEY;"
"要么补上密钥,要么改回 hash"
)
_provider = OpenAIEmbedding(
base_url=settings.embedding_base_url,
api_key=settings.embedding_api_key,
model=settings.embedding_model,
)
else:
_provider = HashEmbedding()
return _provider
def reset_provider_for_tests() -> None:
global _provider
_provider = None
+208
View File
@@ -0,0 +1,208 @@
"""知识的切片索引与检索(M3)。
两条硬规则:
1. **只有 `published` 的知识能被检索到**。草稿是"还没被人认可的话",
让它进检索等于把猜测当结论引用出去。过滤写在 SQL 里,不靠调用方自觉。
2. **检索必须带来源**。每个命中都返回 `item_id` 与标题,用户能点回去看原文;
这和 D3(知识必须有来源)是同一条原则的延伸——不给"出处不存在"的答案。
评分是向量与词法的加权和:
- 向量负责"说的是同一件事但换了词";
- 词法(pg_trgm 的 trigram 相似度)负责"专有名词、单号、人名"这类必须原样命中的词,
而哈希向量对这类词并不敏感。
两者都不足时宁可返回空,也不硬凑一条不相关的结果——检索失败比误导便宜。
"""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import delete, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import get_settings
from ..domain.chunking import split_markdown
from ..errors import AppError, ErrorCode
from ..models_knowledge import Chunk, KnowledgeDoc
from ..models_task import Item
from .embedding import get_provider
SEARCH_SQL = text(
"""
SELECT c.item_id,
i.title,
k.doc_type,
c.idx,
c.heading,
c.content,
coalesce(app_cosine(c.embedding, CAST(:qvec AS real[])), 0) AS vec_score,
coalesce(similarity(c.content, :q), 0) AS lex_score
FROM chunk c
JOIN item i ON i.id = c.item_id AND i.workspace_id = c.workspace_id
JOIN knowledge_doc k ON k.item_id = c.item_id AND k.workspace_id = c.workspace_id
WHERE c.workspace_id = :ws
AND k.status_hint = 'published'
ORDER BY (
coalesce(app_cosine(c.embedding, CAST(:qvec AS real[])), 0) * :vw
+ coalesce(similarity(c.content, :q), 0) * :lw
) DESC, c.item_id, c.idx
LIMIT :limit
"""
)
def _vector_param(values: list[float]) -> list[float]:
"""把向量交给驱动去编码。
一开始我拼成 `'{0.1,0.2}'` 字符串再 `CAST(... AS real[])`,asyncpg 会因为 CAST
已经告诉它"这是数组"而拒绝字符串(`a sized iterable container expected`)。
直接传 list 最省事;非有限值替换成 0,免得 NaN 进 SQL 变成错误输入。
"""
return [value if value == value and abs(value) != float("inf") else 0.0 for value in values]
async def reindex_document(
session: AsyncSession,
ws_id: uuid.UUID,
item_id: uuid.UUID,
) -> int:
"""重建一份知识的切片,返回切片数。
全量重建而不是增量:知识正文改动通常牵动上下几段,增量对齐的复杂度
换不来多少收益(一份知识撑死几十片)。
"""
item = (
await session.execute(select(Item).where(Item.workspace_id == ws_id, Item.id == item_id))
).scalar_one_or_none()
if item is None:
raise AppError(ErrorCode.ITEM_NOT_FOUND, "这件事不存在")
doc = (
await session.execute(
select(KnowledgeDoc).where(
KnowledgeDoc.workspace_id == ws_id,
KnowledgeDoc.item_id == item_id,
)
)
).scalar_one_or_none()
if doc is None:
raise AppError(ErrorCode.KNOWLEDGE_NOT_FOUND, "这份知识不存在")
settings = get_settings()
source = "\n\n".join(part for part in (item.title, item.body_md or "") if part)
drafts = split_markdown(
source,
max_chars=settings.chunk_max_chars,
overlap=settings.chunk_overlap_chars,
)
provider = get_provider(settings)
vectors = await provider.embed([draft.content for draft in drafts]) if drafts else []
await session.execute(
delete(Chunk).where(Chunk.workspace_id == ws_id, Chunk.item_id == item_id)
)
for index, (draft, vector) in enumerate(zip(drafts, vectors, strict=True)):
session.add(
Chunk(
workspace_id=ws_id,
item_id=item_id,
idx=index,
heading=draft.heading,
content=draft.content,
char_count=len(draft.content),
embedding=vector,
embed_model=provider.name,
)
)
doc.chunk_count = len(drafts)
await session.flush()
return len(drafts)
async def search_knowledge(
session: AsyncSession,
ws_id: uuid.UUID,
query: str,
*,
limit: int = 10,
mode: str = "hybrid",
) -> list[dict[str, Any]]:
"""检索已发布知识,返回按相关度排序的切片。
`mode`: hybrid(默认)/ vector / keyword —— 前两者主要用于对比效果。
"""
cleaned = (query or "").strip()
if not cleaned:
return []
settings = get_settings()
provider = get_provider(settings)
query_vector = (await provider.embed([cleaned]))[0]
if mode == "vector":
vector_weight, lexical_weight = 1.0, 0.0
elif mode == "keyword":
vector_weight, lexical_weight = 0.0, 1.0
else:
vector_weight, lexical_weight = (
settings.search_vector_weight,
settings.search_lexical_weight,
)
rows = (
await session.execute(
SEARCH_SQL,
{
"qvec": _vector_param(query_vector),
"q": cleaned,
"ws": str(ws_id),
"vw": vector_weight,
"lw": lexical_weight,
"limit": limit,
},
)
).mappings()
hits: list[dict[str, Any]] = []
for row in rows:
vector_score = float(row["vec_score"] or 0.0)
lexical_score = float(row["lex_score"] or 0.0)
hits.append(
{
"item_id": row["item_id"],
"title": row["title"],
"doc_type": row["doc_type"],
"idx": row["idx"],
"heading": row["heading"],
"content": row["content"],
"score": round(vector_score * vector_weight + lexical_score * lexical_weight, 4),
"vector_score": round(vector_score, 4),
"lexical_score": round(lexical_score, 4),
}
)
hits = [hit for hit in hits if hit["score"] >= settings.search_min_score]
return hits
async def reindex_workspace(session: AsyncSession, ws_id: uuid.UUID) -> int:
"""把一个工作区里已发布的知识全部重建,返回处理的知识份数。
换 embedding 模型之后必须整体跑一次,否则新旧向量混在一张表里,
相似度分数没法比较。
"""
item_ids = (
await session.execute(
select(KnowledgeDoc.item_id).where(
KnowledgeDoc.workspace_id == ws_id,
KnowledgeDoc.status_hint == "published",
)
)
).scalars()
count = 0
for item_id in item_ids:
await reindex_document(session, ws_id, item_id)
count += 1
return count
+73
View File
@@ -0,0 +1,73 @@
"""站内通知(M2 起用,M4 会接入外部推送)。
三条原则:
1. **不通知自己**。绝大多数通知都是由自己的操作触发的,
不排除自己会让"未读"迅速变成一个没人看的数字。
2. **通知里写清"谁、做了什么、在哪件事上"**。只写"状态已更新"等于没写,
用户还得点进去猜。
3. **通知只是提示,不是凭证**。点开后仍要按 `item_id` 重新拉数据,
因为通知生成之后事情可能又被改过。
"""
from __future__ import annotations
import uuid
from collections.abc import Iterable
from sqlalchemy.ext.asyncio import AsyncSession
from ..models_ticket import Notification
TICKET_STATUS_CN = {
"open": "待接收",
"accepted": "待办理",
"in_progress": "办理中",
"pending_verify": "待验收",
"done": "已完成",
"canceled": "已取消",
}
TASK_STATUS_CN = {
"inbox": "收件箱",
"todo": "待办",
"doing": "进行中",
"blocked": "被阻塞",
"done": "已完成",
"canceled": "已取消",
}
async def notify(
session: AsyncSession,
*,
ws_id: uuid.UUID,
user_ids: Iterable[uuid.UUID | None],
kind: str,
item_id: uuid.UUID | None,
title: str,
body: str = "",
actor_id: uuid.UUID | None = None,
) -> int:
"""给若干人各写一条通知,返回实际写入条数。
去重与"跳过自己"在这里统一做一次——每个调用点都手写一遍迟早漏一个。
"""
seen: set[uuid.UUID] = set()
count = 0
for user_id in user_ids:
if user_id is None or user_id == actor_id or user_id in seen:
continue
seen.add(user_id)
session.add(
Notification(
workspace_id=ws_id,
user_id=user_id,
kind=kind,
item_id=item_id,
title=title[:120],
body=body[:300],
)
)
count += 1
return count
+3 -1
View File
@@ -37,7 +37,9 @@ def sign(workspace_id: uuid.UUID, attachment_id: uuid.UUID, ttl_seconds: int | N
return f"exp={exp}&sig={_digest(workspace_id, attachment_id, exp)}"
def verify(workspace_id: uuid.UUID, attachment_id: uuid.UUID, exp: int | None, sig: str | None) -> bool:
def verify(
workspace_id: uuid.UUID, attachment_id: uuid.UUID, exp: int | None, sig: str | None
) -> bool:
if not exp or not sig:
return False
if exp < int(time.time()): # 过期就是过期,不给宽限
+2
View File
@@ -1,5 +1,7 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
# 生产进程管理:gunicorn + UvicornWorker(开发用 `uvicorn --reload` 即可)
gunicorn==23.0.0
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
alembic==1.14.0
+3 -4
View File
@@ -8,13 +8,12 @@
from __future__ import annotations
import uuid
from typing import Any
import pytest
from httpx import AsyncClient
import uuid
from app.config import get_settings
from app.services import signing
@@ -178,7 +177,7 @@ async def test_raw_url_uses_short_lived_signature(client: AsyncClient) -> None:
"""图片直链自带短期签名:不需要把登录令牌贴进 URL,也不需要 Authorization 头。"""
user, ws, item_id = await _ws_with_item(client)
body = b"\x89PNG\r\n\x1a\n signed png"
created = unwrap(await _upload(client, user, ws["id"], item_id, body, "签名图.png"))
unwrap(await _upload(client, user, ws["id"], item_id, body, "签名图.png"))
listed = unwrap(
await client.get(
@@ -225,4 +224,4 @@ async def test_raw_url_signature_is_workspace_scoped(client: AsyncClient) -> Non
f"{BASE}/workspaces/{ws_b['id']}/attachments/{created['id']}/raw?{query}"
)
assert crossed.status_code == 403
assert crossed.json()["code"] == 8003
assert crossed.json()["code"] == 8003
+39
View File
@@ -0,0 +1,39 @@
"""生产配置校验:固定验证码必须显式开关,否则启动即失败。
这两条是「部署纪律」的守门员:生产一旦误用 dev 验证码,等于任意手机号可登录。
"""
from __future__ import annotations
import pytest
from app.config import Settings
_STRONG_SECRET = "x" * 48
_PROD_DB = "postgresql+asyncpg://dadao_prod_app:pw@192.168.8.160:8432/dadaotask_prod"
def _prod(**overrides: object) -> Settings:
values: dict[str, object] = {
"env": "prod",
"jwt_secret": _STRONG_SECRET,
"database_url": _PROD_DB,
"sms_provider": "aliyun",
}
values.update(overrides)
return Settings(**values)
def test_prod_rejects_dev_sms_without_switch() -> None:
with pytest.raises(RuntimeError, match="SMS_PROVIDER=dev"):
_prod(sms_provider="dev").validate_runtime()
def test_prod_allows_dev_sms_with_internal_test_login() -> None:
# 内测模式:显式开关后放行,启动时 main.lifespan 会打 WARNING
_prod(sms_provider="dev", internal_test_login=True).validate_runtime()
def test_prod_still_rejects_weak_jwt_secret() -> None:
with pytest.raises(RuntimeError, match="JWT_SECRET"):
_prod(jwt_secret="short").validate_runtime()
+305
View File
@@ -0,0 +1,305 @@
"""知识库的"备忘录"形态:分类树、正文、标签、筛选。
知识最容易做坏的地方,是把它做成"只填标题的任务"——那样的库三个月后没人会打开。
所以这里断言的是:一条知识能装下正文和标签、能被归到三级分类里、能按分类和标签找回来。
三条边界单独盯着:
1. **三级到底**。第四级不是"更细",是"找不到";越界必须当场拒绝(7007)。
2. **移动会带着子树一起动**。把一棵三层树挪到第二层下面,结果会是第五层,
这种操作要么重算层级、要么拒绝——这里选择拒绝,因为静默改层级同样会让人找不到。
3. **删分类不删知识**。有内容时必须显式 `force`,否则容易点一下就把别人的整理工作打散。
"""
from __future__ import annotations
from typing import Any
import pytest
from httpx import AsyncClient
from .conftest import create_workspace, login, unwrap
V1 = "/api/v1"
def _code(resp: Any) -> int:
return int(resp.json().get("code", 0))
async def _ws(client: AsyncClient) -> tuple[dict[str, Any], str]:
user = await login(client)
ws = await create_workspace(client, user, "知识库")
return user, ws["id"]
async def _category(
client: AsyncClient, user: dict[str, Any], ws_id: str, name: str, parent_id: str | None = None
) -> dict[str, Any]:
return unwrap(
await client.post(
f"{V1}/workspaces/{ws_id}/knowledge/categories",
json={"name": name, "parent_id": parent_id},
headers=user["headers"],
)
)
async def _note(
client: AsyncClient,
user: dict[str, Any],
ws_id: str,
title: str,
*,
body: str = "",
tags: list[str] | None = None,
category_id: str | None = None,
) -> dict[str, Any]:
return unwrap(
await client.post(
f"{V1}/workspaces/{ws_id}/knowledge",
json={
"title": title,
"body_md": body,
"doc_type": "note",
"tag_names": tags or [],
"category_id": category_id,
},
headers=user["headers"],
)
)
async def _list(
client: AsyncClient, user: dict[str, Any], ws_id: str, **params: Any
) -> list[dict[str, Any]]:
return unwrap(
await client.get(
f"{V1}/workspaces/{ws_id}/knowledge", params=params, headers=user["headers"]
)
)["items"]
@pytest.mark.asyncio
async def test_note_keeps_body_tags_and_category_path(client: AsyncClient) -> None:
user, ws_id = await _ws(client)
root = await _category(client, user, ws_id, "研发")
child = await _category(client, user, ws_id, "部署", root["id"])
leaf = await _category(client, user, ws_id, "回滚", child["id"])
assert leaf["depth"] == 3
assert leaf["full_path"] == "研发 / 部署 / 回滚"
doc = await _note(
client,
user,
ws_id,
"回滚前先停掉灰度",
body="# 步骤\n\n1. 停灰度\n2. 回滚镜像\n",
tags=["发布", "事故"],
category_id=leaf["id"],
)
assert doc["category"]["id"] == leaf["id"]
assert doc["category"]["full_path"] == "研发 / 部署 / 回滚"
assert {t["name"] for t in doc["tags"]} == {"发布", "事故"}
detail = unwrap(
await client.get(f"{V1}/workspaces/{ws_id}/knowledge/{doc['id']}", headers=user["headers"])
)
assert "停灰度" in detail["body_md"]
assert detail["category"]["full_path"] == "研发 / 部署 / 回滚"
@pytest.mark.asyncio
async def test_category_depth_stops_at_three(client: AsyncClient) -> None:
user, ws_id = await _ws(client)
l1 = await _category(client, user, ws_id, "一级")
l2 = await _category(client, user, ws_id, "二级", l1["id"])
l3 = await _category(client, user, ws_id, "三级", l2["id"])
resp = await client.post(
f"{V1}/workspaces/{ws_id}/knowledge/categories",
json={"name": "四级", "parent_id": l3["id"]},
headers=user["headers"],
)
assert resp.status_code == 422, resp.text
assert _code(resp) == 7007
@pytest.mark.asyncio
async def test_move_cannot_exceed_depth_or_loop(client: AsyncClient) -> None:
user, ws_id = await _ws(client)
a = await _category(client, user, ws_id, "A")
b = await _category(client, user, ws_id, "B", a["id"])
c = await _category(client, user, ws_id, "C", b["id"])
other = await _category(client, user, ws_id, "X")
# 把 A(下面还有 B、C)移到 C 下面:自己是自己的祖先
resp = await client.patch(
f"{V1}/workspaces/{ws_id}/knowledge/categories/{a['id']}",
json={"move": True, "parent_id": c["id"], "version": a["version"]},
headers=user["headers"],
)
assert resp.status_code in (409, 422), resp.text
assert _code(resp) == 7007
# 把 A 移到 X 下面:A 现在是一级,B、C 跟着下沉,C 会到第四级 -> 拒绝
resp = await client.patch(
f"{V1}/workspaces/{ws_id}/knowledge/categories/{a['id']}",
json={"move": True, "parent_id": other["id"], "version": a["version"]},
headers=user["headers"],
)
assert _code(resp) == 7007
del c, b
@pytest.mark.asyncio
async def test_rename_conflict_and_move_to_root(client: AsyncClient) -> None:
user, ws_id = await _ws(client)
a = await _category(client, user, ws_id, "研发")
b = await _category(client, user, ws_id, "市场")
dup = await client.post(
f"{V1}/workspaces/{ws_id}/knowledge/categories",
json={"name": "研发"},
headers=user["headers"],
)
assert dup.status_code == 409, dup.text
assert _code(dup) == 7008
child = await _category(client, user, ws_id, "部署", a["id"])
moved = unwrap(
await client.patch(
f"{V1}/workspaces/{ws_id}/knowledge/categories/{child['id']}",
json={"move": True, "parent_id": None, "version": child["version"]},
headers=user["headers"],
)
)
assert moved["parent_id"] is None
assert moved["depth"] == 1
assert moved["full_path"] == "部署"
del b
@pytest.mark.asyncio
async def test_list_filters_by_category_subtree_tag_and_search(client: AsyncClient) -> None:
user, ws_id = await _ws(client)
root = await _category(client, user, ws_id, "研发")
sub = await _category(client, user, ws_id, "部署", root["id"])
other = await _category(client, user, ws_id, "市场")
deep = await _note(
client,
user,
ws_id,
"灰度发布流程",
body="先停灰度再回滚",
tags=["发布"],
category_id=sub["id"],
)
await _note(
client, user, ws_id, "季度预算", body="市场预算表", tags=["财务"], category_id=other["id"]
)
loose = await _note(client, user, ws_id, "散记", body="还没归类", tags=["草稿"])
by_category = await _list(client, user, ws_id, category_id=root["id"])
assert {d["id"] for d in by_category} == {deep["id"]}
by_none = await _list(client, user, ws_id, uncategorized="true")
assert {d["id"] for d in by_none} == {loose["id"]}
assert {d["id"] for d in await _list(client, user, ws_id, tag="发布")} == {deep["id"]}
assert {d["id"] for d in await _list(client, user, ws_id, q="回滚")} == {deep["id"]}
@pytest.mark.asyncio
async def test_delete_category_needs_force_when_docs_exist(client: AsyncClient) -> None:
user, ws_id = await _ws(client)
cat = await _category(client, user, ws_id, "待清理")
doc = await _note(client, user, ws_id, "有主的东西", category_id=cat["id"])
blocked = await client.delete(
f"{V1}/workspaces/{ws_id}/knowledge/categories/{cat['id']}", headers=user["headers"]
)
assert blocked.status_code == 409, blocked.text
assert _code(blocked) == 7008
forced = unwrap(
await client.delete(
f"{V1}/workspaces/{ws_id}/knowledge/categories/{cat['id']}?force=true",
headers=user["headers"],
)
)
assert forced["uncategorized_docs"] == 1
detail = unwrap(
await client.get(f"{V1}/workspaces/{ws_id}/knowledge/{doc['id']}", headers=user["headers"])
)
assert detail["category"] is None
@pytest.mark.asyncio
async def test_category_is_tenant_scoped(client: AsyncClient) -> None:
owner, ws_id = await _ws(client)
intruder = await login(client)
other_ws = await create_workspace(client, intruder, "别人的工作区")
foreign = await _category(client, intruder, other_ws["id"], "他们的分类")
listed = unwrap(
await client.get(f"{V1}/workspaces/{ws_id}/knowledge/categories", headers=owner["headers"])
)
assert listed["items"] == []
# 用别的工作区的分类 id 建知识:应被当作"不存在"
resp = await client.post(
f"{V1}/workspaces/{ws_id}/knowledge",
json={"title": "越界", "category_id": foreign["id"]},
headers=owner["headers"],
)
assert resp.status_code == 404, resp.text
assert _code(resp) == 7006
@pytest.mark.asyncio
async def test_patch_knowledge_moves_and_clears_category(client: AsyncClient) -> None:
user, ws_id = await _ws(client)
cat = await _category(client, user, ws_id, "研发")
doc = await _note(client, user, ws_id, "一篇知识", category_id=cat["id"])
moved = unwrap(
await client.patch(
f"{V1}/workspaces/{ws_id}/knowledge/{doc['id']}",
json={"category_id": cat["id"], "tag_names": ["运维"], "version": doc["version"]},
headers=user["headers"],
)
)
assert moved["category"]["name"] == "研发"
assert {t["name"] for t in moved["tags"]} == {"运维"}
cleared = unwrap(
await client.patch(
f"{V1}/workspaces/{ws_id}/knowledge/{doc['id']}",
json={"clear_category": True, "version": moved["version"]},
headers=user["headers"],
)
)
assert cleared["category"] is None
@pytest.mark.asyncio
async def test_patch_knowledge_accepts_doc_type_and_bumps_version(client: AsyncClient) -> None:
"""PATCH 只该写 items 自己的列:doc_type/summary 混进去会 CompileError(线上 500)。"""
user, ws_id = await _ws(client)
doc = await _note(client, user, ws_id, "先写结论再写过程", body="原始正文")
patched = unwrap(
await client.patch(
f"{V1}/workspaces/{ws_id}/knowledge/{doc['id']}",
json={
"version": doc["version"],
"title": "先写结论再写过程",
"body_md": "改过的正文",
"doc_type": "howto",
"tag_names": ["写法"],
},
headers=user["headers"],
)
)
assert patched["knowledge"]["doc_type"] == "howto"
assert patched["body_md"] == "改过的正文"
assert patched["version"] == doc["version"] + 1
assert {t["name"] for t in patched["tags"]} == {"写法"}
+271
View File
@@ -0,0 +1,271 @@
"""通知与手工开单。
这两个功能都有一个共同的翻车方式:**看起来有,实际没送到**。
所以要断言的从来不是"接口 200",而是"该收到的人收到了、不该收到的人没收到"。
1. 自己改自己的任务不该给自己发通知(否则未读数两天就没人看了);
2. 通知必须落到具体的人头上——被指派人、创建人,而不是"整个工作区";
3. 手工开的工单要和升级来的工单走同一条路:能接受、能提交、能验收;
4. 指派给工作区外的人必须当场拒绝,否则任务会挂在一个没人看得到的名字上。
"""
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 _inbox(client: AsyncClient, user: dict[str, Any], ws_id: str) -> list[dict]:
page = unwrap(
await client.get(
f"{V1}/workspaces/{ws_id}/notifications",
params={"unread_only": "false"},
headers=user["headers"],
)
)
return page["notifications"]
async def _task_assigned_to(
client: AsyncClient, owner: dict[str, Any], ws_id: str, assignee_id: str, title: str
) -> dict:
return unwrap(
await client.post(
f"{V1}/workspaces/{ws_id}/items",
json={"title": title, "status": "todo", "assignee_id": assignee_id},
headers=owner["headers"],
)
)
@pytest.mark.asyncio
async def test_task_status_change_notifies_the_other_side(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "通知测试")
helper = await _member(client, owner, ws, "member")
item = await _task_assigned_to(client, owner, ws["id"], helper["user_id"], "月度对账")
# owner 指派给 helper 时,helper 应该收到通知
helper_inbox = await _inbox(client, helper, ws["id"])
assert any(n["kind"] == "task_assigned" and n["item_id"] == item["id"] for n in helper_inbox)
# owner 自己指派的,不该收到
assert not [n for n in await _inbox(client, owner, ws["id"]) if n["kind"] == "task_assigned"]
# helper 把状态改成进行中 → owner(创建人)应该收到
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{item['id']}/status",
json={"status": "doing", "version": item["version"]},
headers=helper["headers"],
)
)
owner_inbox = await _inbox(client, owner, ws["id"])
changed = [n for n in owner_inbox if n["kind"] == "task_status_changed"]
assert len(changed) == 1
assert "进行中" in changed[0]["body"]
assert changed[0]["item_title"] == "月度对账"
# helper 是操作者本人,不该收到自己那次改动的通知
assert not [
n for n in await _inbox(client, helper, ws["id"]) if n["kind"] == "task_status_changed"
]
@pytest.mark.asyncio
async def test_assignee_must_be_a_member(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "越界指派")
outsider = await login(client) # 没加入这个工作区
resp = await client.post(
f"{V1}/workspaces/{ws['id']}/items",
json={"title": "指派给外人", "assignee_id": outsider["user_id"]},
headers=owner["headers"],
)
assert resp.status_code == 400, resp.text
assert resp.json()["code"] == 1001
item = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items",
json={"title": "不指派"},
headers=owner["headers"],
)
)
resp = await client.patch(
f"{V1}/workspaces/{ws['id']}/items/{item['id']}",
json={"assignee_id": outsider["user_id"], "version": item["version"]},
headers=owner["headers"],
)
assert resp.status_code == 400, resp.text
@pytest.mark.asyncio
async def test_manual_ticket_runs_the_whole_flow(client: AsyncClient) -> None:
"""手工开的工单不是"半个工单":接受、提交、验收都得能走。"""
owner = await login(client)
ws = await create_workspace(client, owner, "手工开单")
helper = await _member(client, owner, ws, "member")
ticket = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/tickets",
json={
"title": "帮忙核对一遍报价单",
"body_md": "客户催得急,今天下班前要",
"assignee_id": helper["user_id"],
"priority": 2,
},
headers=owner["headers"],
)
)
assert ticket["status"] == "open"
assert ticket["kind"] == "ticket"
assert ticket["ticket"]["requester_id"] == owner["user_id"]
assert ticket["ticket"]["assignee_id"] == helper["user_id"]
assert ticket["ticket"]["parent_item_id"] is None # 没有来源任务
# 出现在被指派人的工单列表里
assigned = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/tickets",
params={"role": "assigned"},
headers=helper["headers"],
)
)
assert [t["id"] for t in assigned["tickets"]] == [ticket["id"]]
# 被指派人收到通知
inbox = await _inbox(client, helper, ws["id"])
assert any(n["kind"] == "ticket_assigned" and n["item_id"] == ticket["id"] for n in inbox)
# 接受 → 开工 → 提交 → 验收通过
for action, body in (
("accept", {}),
("start", {}),
("submit", {"note": "核对完了"}),
):
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{ticket['id']}/ticket/{action}",
json=body,
headers=helper["headers"],
)
)
done = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{ticket['id']}/ticket/verify",
json={"approve": True, "resolution_md": "确认了三处差异"},
headers=owner["headers"],
)
)
assert done["status"] == "done"
# 验收是需求人做的,通知该发给办理人;需求人自己不该收到"你通过了"这种通知
helper_inbox = await _inbox(client, helper, ws["id"])
assert any(n["kind"] == "ticket_approved" for n in helper_inbox)
assert not [n for n in await _inbox(client, owner, ws["id"]) if n["kind"] == "ticket_approved"]
@pytest.mark.asyncio
async def test_manual_ticket_rejects_outsider(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "手工开单越界")
outsider = await login(client)
resp = await client.post(
f"{V1}/workspaces/{ws['id']}/tickets",
json={"title": "给外人开单", "assignee_id": outsider["user_id"]},
headers=owner["headers"],
)
assert resp.status_code == 400, resp.text
assert resp.json()["code"] == 1001
@pytest.mark.asyncio
async def test_comment_notifies_the_other_party(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "评论通知")
helper = await _member(client, owner, ws, "member")
item = await _task_assigned_to(client, owner, ws["id"], helper["user_id"], "整理发票")
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{item['id']}/comments",
json={"body_md": "发票抬头要写公司全称"},
headers=helper["headers"],
)
)
owner_inbox = await _inbox(client, owner, ws["id"])
assert any(n["kind"] == "item_commented" for n in owner_inbox)
# 操作者本人不该收到
assert not [n for n in await _inbox(client, helper, ws["id"]) if n["kind"] == "item_commented"]
@pytest.mark.asyncio
async def test_unread_count_and_read_all(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "未读数")
helper = await _member(client, owner, ws, "member")
await _task_assigned_to(client, owner, ws["id"], helper["user_id"], "第一件")
await _task_assigned_to(client, owner, ws["id"], helper["user_id"], "第二件")
count = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/notifications/unread-count", headers=helper["headers"]
)
)
assert count["unread"] == 2
first = (await _inbox(client, helper, ws["id"]))[0]
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/notifications/{first['id']}/read",
headers=helper["headers"],
)
)
count = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/notifications/unread-count", headers=helper["headers"]
)
)
assert count["unread"] == 1
marked = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/notifications/read-all", headers=helper["headers"]
)
)
assert marked["marked"] == 1
count = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/notifications/unread-count", headers=helper["headers"]
)
)
assert count["unread"] == 0
+200
View File
@@ -0,0 +1,200 @@
"""M3 检索测试:切片、索引、跨租户。
检索最容易做错的地方不是"搜得准不准",而是"什么该被搜到、什么绝不能被搜到":
1. **草稿不能被检索到**。草稿是还没被人认可的话,让它出现等于把猜测当结论;
2. **刚发布就能搜到**。发布后要立刻建索引,否则用户会以为发布失败;
3. **检索结果必须带来源**,能点回原文;
4. **别的租户搜不到我的知识**,即使关键词一模一样。
"""
from __future__ import annotations
from typing import Any
from httpx import AsyncClient
from .conftest import create_workspace, login, unwrap
V1 = "/api/v1"
async def _knowledge(
client: AsyncClient,
user: dict[str, Any],
ws_id: str,
title: str,
body: str,
doc_type: str = "note",
) -> dict[str, Any]:
return unwrap(
await client.post(
f"{V1}/workspaces/{ws_id}/knowledge",
json={"title": title, "body_md": body, "doc_type": doc_type},
headers=user["headers"],
)
)
async def _publish(
client: AsyncClient, user: dict[str, Any], ws_id: str, doc: dict[str, Any]
) -> dict[str, Any]:
return unwrap(
await client.post(
f"{V1}/workspaces/{ws_id}/knowledge/{doc['id']}/status",
json={"action": "publish", "version": doc["version"]},
headers=user["headers"],
)
)
async def _search(
client: AsyncClient,
user: dict[str, Any],
ws_id: str,
query: str,
mode: str = "hybrid",
) -> list[dict[str, Any]]:
return unwrap(
await client.get(
f"{V1}/workspaces/{ws_id}/knowledge/search",
params={"q": query, "mode": mode},
headers=user["headers"],
)
)["items"]
async def test_published_is_searchable_draft_is_not(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "检索测试")
published = await _knowledge(
client, owner, ws["id"], "报销发票怎么开", "电子发票原件必须一起寄,复印件不算。"
)
parked = await _knowledge(
client, owner, ws["id"], "供应商账号变更", "变更收款账号需要法人签字并盖章。"
)
await _publish(client, owner, ws["id"], published)
hits = await _search(client, owner, ws["id"], "发票")
ids = {hit["item_id"] for hit in hits}
assert published["id"] in ids
assert parked["id"] not in ids, "草稿不该出现在检索结果里"
assert hits[0]["score"] > 0
assert hits[0]["title"], "检索结果必须带来源标题"
# 还没验证过的知识,关键词再怎么对也不该冒出来
draft_hits = await _search(client, owner, ws["id"], "法人签字")
assert all(hit["item_id"] != parked["id"] for hit in draft_hits)
async def test_publish_builds_chunks(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "切片测试")
body = "\n\n".join(
[
"## 背景",
"十月的报销单据被财务退回两次,第一次是发票复印件,第二次是缺少行程单。" * 20,
"## 结论",
"报销必须同时提供电子发票原件与行程单,缺一不可。" * 20,
]
)
doc = await _knowledge(client, owner, ws["id"], "十月报销被退单复盘", body, "postmortem")
published = await _publish(client, owner, ws["id"], doc)
assert published["knowledge"]["chunk_count"] >= 2, "长正文应该被切成多片"
detail = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}", headers=owner["headers"]
)
)
assert detail["knowledge"]["chunk_count"] >= 2
async def test_reindex_picks_up_edits(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "重建索引")
doc = await _knowledge(client, owner, ws["id"], "值班安排", "周一由张三负责。")
published = await _publish(client, owner, ws["id"], doc)
assert published["knowledge"]["chunk_count"] == 1
# 已发布的内容不许直接改(7003):产品上的路径是「归档 → 恢复成草稿 → 改」
archived = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
json={"action": "archive", "version": published["version"]},
headers=owner["headers"],
)
)
draft = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
json={"action": "restore", "version": archived["version"]},
headers=owner["headers"],
)
)
patched = unwrap(
await client.patch(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}",
json={
"body_md": "周一由张三负责,周三改由李四负责,节假日另行通知。",
"version": draft["version"],
},
headers=owner["headers"],
)
)
# 改完正文但没重建索引,旧切片还在——这就是 reindex 存在的理由
before = await _search(client, owner, ws["id"], "李四")
assert all("李四" not in hit["content"] for hit in before)
result = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/reindex",
headers=owner["headers"],
)
)
assert result["chunk_count"] >= 1
# 仍是草稿,重建了也不该被检索到
assert result["searchable"] is False
assert await _search(client, owner, ws["id"], "李四") == []
await _publish(client, owner, ws["id"], patched)
after = await _search(client, owner, ws["id"], "李四")
assert any("李四" in hit["content"] for hit in after)
async def test_search_is_tenant_scoped(client: AsyncClient) -> None:
alice = await login(client)
bob = await login(client)
ws_a = await create_workspace(client, alice, "甲队")
ws_b = await create_workspace(client, bob, "乙队")
doc = await _knowledge(client, alice, ws_a["id"], "发票规则", "电子发票原件必须一起寄。")
await _publish(client, alice, ws_a["id"], doc)
assert await _search(client, bob, ws_b["id"], "发票") == []
# 直接拿别人的工作区搜也不行
denied = await client.get(
f"{V1}/workspaces/{ws_a['id']}/knowledge/search",
params={"q": "发票"},
headers=bob["headers"],
)
assert denied.status_code in (403, 404)
async def test_keyword_mode_reports_lexical_score(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "词法检索")
doc = await _knowledge(
client, owner, ws["id"], "顺丰单号核对", "单号 SF1234567890 必须与发货记录一致。"
)
await _publish(client, owner, ws["id"], doc)
hits = await _search(client, owner, ws["id"], "SF1234567890", mode="keyword")
assert hits, "带专有编号的查询在词法模式下应该命中"
assert hits[0]["lexical_score"] > 0
# keyword 模式里向量权重是 0:总分只由词法分决定
assert hits[0]["score"] == hits[0]["lexical_score"]
+33
View File
@@ -0,0 +1,33 @@
# 达道Task 生产服务(用户级 systemd,openclaw-01 上不需要 root)
#
# 安装:
# cp deploy/prod/dadaotask-api.service ~/.config/systemd/user/
# systemctl --user daemon-reload
# systemctl --user enable --now dadaotask-api
#
# 两份 EnvironmentFile:
# env.production —— 真实配置与口令(chmod 600,不进仓库)
# env.build —— 由 deploy.sh 每次部署重写(BUILD_COMMIT / BUILD_TIME),供 /version 校验
[Unit]
Description=DadaoTask API (prod, gunicorn + uvicorn workers)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/home/openclaw/app/dadaotask/backend
EnvironmentFile=/home/openclaw/.config/dadaotask/env.production
EnvironmentFile=/home/openclaw/.config/dadaotask/env.build
Environment=PYTHONUNBUFFERED=1
ExecStart=/home/openclaw/app/dadaotask/.venv/bin/gunicorn app.main:app \
-k uvicorn.workers.UvicornWorker -w 2 \
-b 0.0.0.0:8200 \
--timeout 60 --graceful-timeout 30 --keep-alive 5 \
--access-logfile - --error-logfile -
Restart=always
RestartSec=3
KillSignal=SIGTERM
TimeoutStopSec=40
[Install]
WantedBy=default.target
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# 生产部署脚本 —— openclaw-01(用户级 systemd,全程不需要 root)
#
# 遵守 AGENTS.md 的部署铁律:
# 1. 生产只跑仓库 HEAD(git reset --hard origin/main,禁止服务器手工改)
# 2. 前端产物只在服务器上由 HEAD 构建(npm ci && npm run build)
# 3. 部署后校验 /version 的 commit == git rev-parse --short HEAD
#
# 用法(在 openclaw-01 上,openclaw 身份):
# bash ~/app/dadaotask/deploy/prod/deploy.sh
# 首次部署前准备好 ~/.config/dadaotask/env.production(见 env.production.example)。
set -euo pipefail
APP_DIR=${APP_DIR:-/home/openclaw/app/dadaotask}
CONF_DIR=${CONF_DIR:-$HOME/.config/dadaotask}
ENV_FILE=$CONF_DIR/env.production
SERVICE=${SERVICE:-dadaotask-api}
PORT=${PORT:-8200}
REPO_URL=${REPO_URL:-https://gitea.tatta.cn/Tatta/DadaoTask.git}
# 服务器直连 pypi/npm 官方源很慢(实测 npm 包几百 KB 就能卡住),默认走国内镜像
NPM_REGISTRY=${NPM_REGISTRY:-https://registry.npmmirror.com}
step() { printf '\n== %s ==\n' "$*"; }
die() { printf '!! %s\n' "$*" >&2; exit 1; }
step "0. 前置检查"
test -f "$ENV_FILE" || die "缺少 $ENV_FILE(参考 deploy/prod/env.production.example)"
if ! command -v node >/dev/null; then die "node 未安装(前端必须在服务器上构建)"; fi
node_major=$(node -v | sed 's/^v\([0-9]*\).*/\1/')
[ "$node_major" -ge 20 ] || die "node 版本过低:$(node -v),需要 20+"
step "1. 代码对齐仓库 HEAD"
if [ ! -d "$APP_DIR/.git" ]; then
git clone --branch main --single-branch "$REPO_URL" "$APP_DIR"
fi
cd "$APP_DIR"
git fetch --prune origin
git checkout -q main
git reset --hard -q origin/main # 生产不允许领先/落后仓库,也不允许脏改动
COMMIT=$(git rev-parse HEAD)
SHORT=$(git rev-parse --short HEAD)
echo " HEAD=$COMMIT ($SHORT)"
step "2. Python 依赖"
# openclaw-01 是 Debian/Ubuntu 系但没装 python3-venv(ensurepip 缺失),也没有 sudo;
# 所以先建 --without-pip 的 venv,再用系统 pip 把 pip 装进去。镜像可用 env 覆盖。
PIP_INDEX=${PIP_INDEX:-https://mirrors.aliyun.com/pypi/simple/}
if ! [ -x .venv/bin/python ] || ! .venv/bin/python -m pip --version >/dev/null 2>&1; then
rm -rf .venv
python3 -m venv --without-pip .venv
python3 -m pip --python .venv/bin/python install -q --index-url "$PIP_INDEX" pip setuptools wheel
fi
.venv/bin/python -m pip install -q --index-url "$PIP_INDEX" --upgrade -r backend/requirements.txt
step "3. 数据库迁移(迁移角色,非应用角色)"
set -a; . "$ENV_FILE"; set +a
( cd backend && ../.venv/bin/alembic upgrade head )
step "4. 前端构建(只在服务器上、只用 HEAD 的源码)"
( cd web && npm ci --no-audit --no-fund --registry="$NPM_REGISTRY" && npm run build )
test -f web/dist/index.html || die "前端构建产物缺失:web/dist/index.html"
step "5. 写入构建标记并重启服务"
mkdir -p "$CONF_DIR" backend/var/uploads
chmod 700 "$CONF_DIR"
printf 'BUILD_COMMIT=%s\nBUILD_TIME=%s\n' "$SHORT" "$(date -Iseconds)" >"$CONF_DIR/env.build"
chmod 600 "$CONF_DIR/env.build" "$ENV_FILE"
systemctl --user daemon-reload
systemctl --user enable "$SERVICE" >/dev/null 2>&1 || true
systemctl --user restart "$SERVICE"
step "6. 校验(/health + /version 的 commit)"
for _ in $(seq 1 30); do
curl -fsS "http://127.0.0.1:$PORT/health" >/dev/null 2>&1 && break
sleep 1
done
HEALTH=$(curl -fsS "http://127.0.0.1:$PORT/health") || die "健康检查失败:服务没起来(journalctl --user -u $SERVICE -n 100)"
COMMIT_HTTP=$(curl -fsS "http://127.0.0.1:$PORT/version" | .venv/bin/python -c 'import json,sys; print(json.load(sys.stdin)["commit"])') \
|| die "/version 读取失败"
echo " health=$HEALTH"
echo " version.commit=$COMMIT_HTTP"
[ "$COMMIT_HTTP" = "$SHORT" ] || die "/version 的 commit($COMMIT_HTTP) 与 HEAD($SHORT) 不一致"
printf '\n部署完成:http://%s:%s (commit %s)\n' "$(hostname -I | awk '{print $1}')" "$PORT" "$SHORT"
+37
View File
@@ -0,0 +1,37 @@
# 生产环境变量模板(部署到 openclaw-01)
# 用法:复制到 ~/.config/dadaotask/env.production(chmod 600),把 __CHANGE_ME__ 换成真实口令。
# 真实口令不进仓库;本文件只描述字段。
ENV=prod
APP_NAME=达道Task
# ── 数据库:fnsvr-01 共享 PG 上**独立的库 + 独立的角色** ──────────────
# 生产不复用 dev 的 dadao_app/dadao_owner 角色:同集群同角色 = 拿到开发口令即可连生产。
# 角色名必须在迁移前注入(迁移里的 GRANT 按它生成)。
OWNER_DB_ROLE=dadao_prod_owner
APP_DB_ROLE=dadao_prod_app
DATABASE_URL=postgresql+asyncpg://dadao_prod_app:__APP_PW__@192.168.8.160:8432/dadaotask_prod
MIGRATION_DATABASE_URL=postgresql+asyncpg://dadao_prod_owner:__OWNER_PW__@192.168.8.160:8432/dadaotask_prod
# ── Redis:fnsvr-01 共享实例,独立 db 号隔离(不使用默认 0 号库)──────
REDIS_URL=redis://192.168.8.160:6379/6
# ── 认证 ─────────────────────────────────────────────────────────────
# 生产校验:长度 ≥ 32 且不是开发默认值
JWT_SECRET=__CHANGE_ME_32_CHARS_OR_MORE__
ACCESS_TOKEN_MINUTES=30
REFRESH_TOKEN_DAYS=14
# 内测登录:短信通道还没接(非 dev provider 直接 501),先把固定验证码显式打开。
# 只允许内网访问;短信通道接好后把这两行改成 SMS_PROVIDER=<供应商> 并删掉开关。
INTERNAL_TEST_LOGIN=true
SMS_PROVIDER=dev
SMS_DEV_CODE=123456
SMS_THROTTLE_SECONDS=60
# 同源部署(后端直接托管 web/dist),CORS 只留本机调试用
CORS_ORIGINS=http://127.0.0.1:8200,http://192.168.8.188:8200
# ── 前端产物与上传目录(绝对路径)─────────────────────────────────────
WEB_DIST_DIR=/home/openclaw/app/dadaotask/web/dist
UPLOAD_DIR=/home/openclaw/app/dadaotask/backend/var/uploads
MAX_UPLOAD_MB=20
+28
View File
@@ -0,0 +1,28 @@
# 给 root 用的 nginx 反代(当前没启用:openclaw 账号无 sudo,先跑 8200 单端口)
#
# 装法(root):
# cp deploy/prod/nginx.dadaotask.conf.example /etc/nginx/sites-available/dadaotask
# ln -s /etc/nginx/sites-available/dadaotask /etc/nginx/sites-enabled/dadaotask
# nginx -t && systemctl reload nginx
#
# 说明:后端已直接在 8200 上同时托管 API 和前端产物(web/dist),
# 所以这里只做一层透传,不需要单独的静态站点 root。
server {
listen 80;
listen [::]:80;
server_name _; # 有域名后改成真实域名
client_max_body_size 25m; # 附件上限 20MB,留余量
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
location / {
proxy_pass http://127.0.0.1:8200;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s; # 知识检索/上传偶发慢请求
}
}
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# 在 fnsvr-01 的共享 PostgreSQL 上给「生产」建独立的库与角色。
#
# 为什么独立角色:生产库和开发库在同一个实例(fnsvr-01:8432)。复用 dadao_app
# 等于拿到开发口令就能连生产;所以生产用 dadao_prod_owner / dadao_prod_app,
# 迁移里的 GRANT 与 DEFAULT PRIVILEGES 会按 APP_DB_ROLE / OWNER_DB_ROLE 生成。
#
# 在 fnsvr-01 上以 root 执行(需要 docker 权限):
# OWNER_PW='...' APP_PW='...' bash deploy/prod/provision-db.sh
#
# 幂等:角色或库已存在时跳过创建(不会重置已有口令)。
set -euo pipefail
CONTAINER=${CONTAINER:-1Panel-postgresql-wIKz}
DB=${DB:-dadaotask_prod}
OWNER_ROLE=${OWNER_ROLE:-dadao_prod_owner}
APP_ROLE=${APP_ROLE:-dadao_prod_app}
: "${OWNER_PW:?需要 OWNER_PW 环境变量(迁移角色口令)}"
: "${APP_PW:?需要 APP_PW 环境变量(应用角色口令)}"
echo "== 建角色与库:$DB(owner=$OWNER_ROLE app=$APP_ROLE)=="
docker exec -i "$CONTAINER" psql -U dbadmin -d postgres -v ON_ERROR_STOP=1 -q \
-v owner_role="$OWNER_ROLE" -v owner_pw="$OWNER_PW" \
-v app_role="$APP_ROLE" -v app_pw="$APP_PW" -v db="$DB" <<'SQL'
SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'owner_role', :'owner_pw')
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'owner_role')\gexec
SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'app_role', :'app_pw')
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'app_role')\gexec
-- 应用角色绝不能是 superuser / bypassrls,否则 RLS 直接被跳过
SELECT format('ALTER ROLE %I NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE', :'app_role')\gexec
SELECT format('ALTER ROLE %I NOSUPERUSER NOBYPASSRLS', :'owner_role')\gexec
SELECT format('CREATE DATABASE %I OWNER %I', :'db', :'owner_role')
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = :'db')\gexec
SQL
echo "== 库内权限(schema 归 owner,app 只能读写数据)=="
docker exec -i "$CONTAINER" psql -U dbadmin -d "$DB" -v ON_ERROR_STOP=1 -q \
-v owner_role="$OWNER_ROLE" -v app_role="$APP_ROLE" <<'SQL'
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
SELECT format('ALTER SCHEMA public OWNER TO %I', :'owner_role')\gexec
SELECT format('GRANT CONNECT ON DATABASE %I TO %I', current_database(), :'app_role')\gexec
SELECT format('GRANT USAGE ON SCHEMA public TO %I', :'app_role')\gexec
SQL
echo "== 自检 =="
docker exec -i "$CONTAINER" psql -U dbadmin -d "$DB" -tAc \
"select current_database(), (select rolname from pg_roles where rolname='$APP_ROLE'),
(select rolsuper from pg_roles where rolname='$APP_ROLE'),
(select rolbypassrls from pg_roles where rolname='$APP_ROLE')"
echo "完成。注意:建表与 RLS 由 alembic(deploy.sh 第 3 步)负责。"
+248
View File
@@ -0,0 +1,248 @@
/**
* 知识备忘录形态 e2e:分类树 + 新建时一次填全(标题/类型/分类/标签/正文)+ 按分类筛。
*
* 与 knowledge.mjs 的分工:那个脚本验"沉淀 → 发布"闭环,这个只验"知识是最像备忘录的形态"。
* 前面造数据走接口,界面部分点真实按钮。
*/
import { chromium } from "playwright-core";
import { mkdirSync, writeFileSync } from "node:fs";
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 = `137${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);
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}/knowledge`, { waitUntil: "networkidle" });
await page.getByRole("heading", { name: "知识" }).waitFor({ timeout: 10_000 });
// ① 建两级分类:研发 / 部署
await page.getByRole("button", { name: "+ 新建", exact: true }).click();
await page.getByLabel("新分类名").fill("研发");
await page.getByRole("button", { name: "创建", exact: true }).click();
await page.getByRole("button", { name: /^研发/, exact: false }).first().waitFor();
await page.getByRole("button", { name: "+ 新建", exact: true }).click();
await page.getByLabel("新分类名").fill("部署");
await page.getByLabel("父分类").selectOption({ label: "研发 下" });
await page.getByRole("button", { name: "创建", exact: true }).click();
await page.getByRole("button", { name: /^部署/, exact: false }).first().waitFor();
check("能在侧栏建出「研发 / 部署」两级分类", true);
// ② 记一条:一个面板里填完标题/类型/分类/标签/正文
await page.getByRole("button", { name: "记一条", exact: true }).click();
await page.getByLabel("标题").fill("回滚前先停灰度");
await page.getByLabel("类型").selectOption("howto");
await page.getByLabel("分类", { exact: true }).selectOption({ label: "研发 / 部署" });
await page.getByLabel("标签").fill("发布 事故");
// 正文是富文本编辑器(Vditor),不是 textarea:先等它挂载,再用真实键盘输入
const composerBody = page.locator('.vditor-ir [contenteditable="true"]').last();
await composerBody.waitFor({ timeout: 20_000 });
await composerBody.click();
await page.keyboard.type("## 步骤", { delay: 10 });
await page.keyboard.press("Enter");
await page.keyboard.type("1. 停掉灰度流量", { delay: 10 });
await page.keyboard.press("Enter");
await page.keyboard.type("回滚镜像到上一个 tag", { delay: 10 });
await page.keyboard.press("Enter");
await page.keyboard.type("验证关键接口", { delay: 10 });
check(
"新建面板的正文是富文本编辑器",
(await composerBody.getAttribute("aria-label")) === "正文",
);
await page.screenshot({ path: "e2e/artifacts/knowledge-composer-editor.png" });
// ②½ 附件:新建面板里就能传——点「添加附件」先把这条存成草稿,再上传
mkdirSync("e2e/artifacts", { recursive: true });
const samplePath = "e2e/artifacts/附件样例.txt";
writeFileSync(samplePath, "命令行回滚手册:kubectl rollout undo deployment/api\n");
await page.getByRole("button", { name: "添加附件", exact: true }).click();
// 面板出现后再走真实的「上传」按钮 + filechooser:页面上不止一个 file input(编辑器也有)
await page.getByRole("button", { name: "上传", exact: true }).waitFor({ timeout: 15_000 });
const [chooser] = await Promise.all([
page.waitForEvent("filechooser", { timeout: 15_000 }),
page.getByRole("button", { name: "上传", exact: true }).click(),
]);
const [uploadResp] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes("/attachments") && r.request().method() === "POST",
{ timeout: 20_000 },
),
chooser.setFiles(samplePath),
]);
await page.getByText("附件样例.txt", { exact: false }).first().waitFor({ timeout: 10_000 });
check("新建面板里能上传附件(先懒建草稿)", uploadResp.ok(), `HTTP ${uploadResp.status()}`);
await page.screenshot({ path: "e2e/artifacts/knowledge-composer-attachment.png" });
await page.getByRole("button", { name: "保存并继续写", exact: true }).click();
// 保存后直接打开详情:标题、分类路径、标签都在
await page.getByText("回滚前先停灰度", { exact: false }).first().waitFor({ timeout: 10_000 });
const drawerText = (await page.textContent("body")) ?? "";
check(
"新建面板能把分类与标签一起存下来",
drawerText.includes("研发 / 部署") && drawerText.includes("发布"),
);
check("详情抽屉里能看到刚传的附件", drawerText.includes("附件样例.txt"));
await page.screenshot({ path: "e2e/artifacts/knowledge-composer-saved.png", fullPage: false });
// ③ 列表卡片上能看到分类路径与标签(不用点开)
await page.getByRole("button", { name: "关闭", exact: true }).click();
const card = page.locator("li").filter({ hasText: "回滚前先停灰度" }).first();
await card.waitFor({ timeout: 10_000 });
const cardText = (await card.textContent()) ?? "";
check("列表卡片带分类路径与标签", cardText.includes("研发 / 部署") && cardText.includes("#发布"));
// ④ 侧栏按分类筛:部署里有,未分类里没有
await page.getByRole("button", { name: /^部署/, exact: false }).first().click();
await page.waitForTimeout(400);
check(
"点「部署」能看到归属该分类的知识",
await page.getByText("回滚前先停灰度", { exact: false }).first().isVisible(),
);
await page.getByRole("button", { name: "未分类", exact: true }).click();
await page.waitForTimeout(400);
const uncategorizedHasCard = await page
.getByText("回滚前先停灰度", { exact: false })
.first()
.isVisible()
.catch(() => false);
check("「未分类」里不出现已归类的知识", !uncategorizedHasCard);
await page.screenshot({ path: "e2e/artifacts/knowledge-category-filter.png", fullPage: false });
// ⑤ 抽屉里能改标签与分类,改完接口里就是新的
await page.getByRole("button", { name: "全部知识", exact: true }).click();
// 先等抽屉自己把详情拉回来再填:GET 比手快一步的话,刚填的字会被它覆盖掉
await Promise.all([
page.waitForResponse(
(r) =>
r.url().includes("/knowledge/") &&
r.request().method() === "GET" &&
!r.url().endsWith("/knowledge/categories") &&
r.status() === 200,
),
page.getByRole("button", { name: "打开", exact: true }).first().click(),
]);
const tagInput = page.getByLabel("标签", { exact: true }).last();
await tagInput.waitFor({ timeout: 10_000 });
// 先等抽屉把自己拉回来的旧标签填上——那一刻之后才没有"迟到的响应"来覆盖输入
await page
.waitForFunction(
() => {
const el = document.querySelector('input[aria-label="标签"]');
return !!el && el.value.includes("事故");
},
null,
{ timeout: 10_000 },
)
.catch(() => {});
// 用真实键盘输入而不是 fill:fill 走原生 setter,React 的受控 input 有时收不到这次变更
await tagInput.click();
await tagInput.press("Control+A");
await page.keyboard.type("发布 回滚", { delay: 20 });
check("抽屉里的标签输入框拿到新值", (await tagInput.inputValue()) === "发布 回滚");
const [patchResp] = await Promise.all([
page.waitForResponse(
(r) =>
r.url().includes("/knowledge/") &&
r.request().method() === "PATCH" &&
r.status() === 200,
),
page.getByRole("button", { name: "保存", exact: true }).last().click(),
]);
check("抽屉里保存标签/分类成功", patchResp.ok());
const list = await call(
"GET",
`/workspaces/${ws.id}/knowledge?status=draft&status=in_review`,
undefined,
token,
);
const doc = list.items.find((d) => d.title === "回滚前先停灰度");
check(
"接口里标签已更新、分类是「研发 / 部署」",
String((doc?.tags ?? []).map((t) => t.name).join(" ")).includes("回滚") &&
doc?.category?.full_path === "研发 / 部署",
`tags=${JSON.stringify((doc?.tags ?? []).map((t) => t.name))} category=${doc?.category?.full_path}`,
);
check(
"正文按 Markdown 存下来了",
String(doc?.body_md ?? "").includes("停掉灰度流量"),
);
// 列表只带 attachment_count(省一次 N+1),文件清单要问详情
const detail = await call("GET", `/workspaces/${ws.id}/knowledge/${doc.id}`, undefined, token);
check(
"附件挂在这条知识上(详情回读得到)",
(detail?.attachments ?? []).some((a) => a.filename === "附件样例.txt"),
`attachments=${JSON.stringify((detail?.attachments ?? []).map((a) => a.filename))}`,
);
// ⑥ 分类重名与三级上限:接口层面确认约束还在
const dup = await fetch(
`${API}/workspaces/${ws.id}/knowledge/categories`,
{
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ name: "部署", parent_id: doc.category.parent_id }),
},
).then((r) => r.json());
check("同层重名被拒(7008)", dup.code === 7008, `code=${dup.code}`);
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-notebook-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);
}
+139
View File
@@ -0,0 +1,139 @@
// 通知与手工开单的端到端验收。
// 两个真实用户:A 开单交给 B、B 收到通知并点开;A 改任务状态,B 也应收到提醒。
import { chromium } from "playwright-core";
const BASE = process.env.BASE ?? "http://127.0.0.1:5180";
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
const CHROME = process.env.CHROME ?? "C:/Users/Steven/AppData/Local/ms-playwright/chromium-1223/chrome-win64/chrome.exe";
const stamp = String(Date.now()).slice(-6);
const phoneA = `135${stamp}00`;
const phoneB = `138${String((Number(stamp) + 2222) % 1e8).padStart(8, "0")}`;
const wsName = `通知${stamp}`;
let failed = 0;
const ok = (cond, msg) => {
if (cond) console.log(` PASS ${msg}`);
else {
failed += 1;
console.log(` FAIL ${msg}`);
}
};
async function login(page, phone) {
await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" });
await page.fill("#phone", phone);
await page.getByRole("button", { name: "获取验证码" }).click();
await page.waitForSelector("text=开发环境验证码", { timeout: 15000 });
const code = (await page.locator("text=开发环境验证码").first().innerText()).match(/(\d{6})/)?.[1];
await page.fill("#code", code);
await page.getByRole("main").getByRole("button", { name: "登录", exact: true }).click();
await page.waitForURL((u) => !u.pathname.startsWith("/login"), { timeout: 15000 });
}
const browser = await chromium.launch({
executablePath: CHROME,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const ctxA = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
const pageA = await ctxA.newPage();
const errorsA = [];
pageA.on("console", (m) => m.type() === "error" && errorsA.push(m.text()));
pageA.on("response", (r) => r.status() >= 400 && errorsA.push(`${r.url()} ${r.status()}`));
try {
await login(pageA, phoneA);
console.log("PASS 1. A 登录");
// 建工作区
await pageA.getByPlaceholder("例如:达道科技 / 我的事项").fill(wsName);
await pageA.getByRole("button", { name: "创建" }).click();
await pageA.waitForURL(/\/w\/[0-9a-f-]{36}/, { timeout: 20000 });
const wsId = pageA.url().match(/\/w\/([0-9a-f-]{36})/)[1];
console.log("PASS 2. 建工作区");
// 邀请 B 加入
await pageA.goto(`${BASE}/w/${wsId}/members`, { waitUntil: "domcontentloaded" });
await pageA.getByRole("button", { name: "邀请成员" }).click();
await pageA.waitForSelector("text=/join/", { timeout: 15000 });
const link = await pageA
.locator("text=/join/")
.first()
.innerText()
.then((t) => t.match(/https?:\/\/\S+join\/\S+/)?.[0]);
ok(Boolean(link), "3. 拿到邀请链接");
const ctxB = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
const pageB = await ctxB.newPage();
const errorsB = [];
pageB.on("console", (m) => m.type() === "error" && errorsB.push(m.text()));
pageB.on("response", (r) => r.status() >= 400 && errorsB.push(`${r.url()} ${r.status()}`));
await login(pageB, phoneB);
await pageB.goto(link, { waitUntil: "domcontentloaded" });
await pageB.waitForURL(new RegExp(`/w/${wsId}`), { timeout: 20000 });
console.log("PASS 4. B 从邀请链接登录后自动加入");
// A 手工开一张工单交给 B
await pageA.goto(`${BASE}/w/${wsId}/tickets`, { waitUntil: "domcontentloaded" });
await pageA.getByRole("button", { name: "新建工单" }).click();
await pageA.getByLabel("要办什么").fill(`核对 9 月发票${stamp}`);
await pageA.getByLabel("交给谁").selectOption({ index: 1 });
await pageA.getByRole("button", { name: "开单并通知对方" }).click();
await pageA.waitForSelector(`text=核对 9 月发票${stamp}`, { timeout: 20000 });
ok(true, "5. 手工工单开出(不经过任何任务)");
// 接口确认它确实是 kind=ticket 且没有来源任务
const created = await pageA.evaluate(
async (ws) => {
const t = localStorage.getItem("dadao.access");
const r = await fetch(`/api/v1/workspaces/${ws}/tickets?role=requested`, {
headers: { Authorization: `Bearer ${t}` },
});
return r.json();
},
wsId,
);
const t0 = created.data.tickets[0];
ok(t0?.kind === "ticket" && t0?.ticket?.parent_item_id === null, "6. 手工单没有来源任务");
// B 收到通知
await pageB.goto(`${BASE}/w/${wsId}/notifications`, { waitUntil: "domcontentloaded" });
await pageB.waitForSelector(`text=核对 9 月发票${stamp}`, { timeout: 20000 });
ok(true, "7. B 收到「派给你的工单」通知");
const unreadBefore = await pageB.locator("li.card").count();
ok(unreadBefore >= 1, "8. 未读列表里有条目");
// 点开通知:跳到工单页
await pageB.locator("li.card").first().click();
await pageB.waitForURL(/\/tickets\?open=/, { timeout: 20000 });
await pageB.waitForSelector(`text=核对 9 月发票${stamp}`, { timeout: 20000 });
ok(true, "9. 点通知跳到工单并展开那张单");
// 标记已读后未读数归零
const unread = await pageB.evaluate(async (ws) => {
const t = localStorage.getItem("dadao.access");
const r = await fetch(`/api/v1/workspaces/${ws}/notifications/unread-count`, {
headers: { Authorization: `Bearer ${t}` },
});
return (await r.json()).data.unread;
}, wsId);
ok(unread === 0, "10. 点开后未读数归零");
ok(errorsA.length === 0, `11. A 无报错${errorsA.slice(0, 2).join(" | ")}`);
ok(errorsB.length === 0, `12. B 无报错${errorsB.slice(0, 2).join(" | ")}`);
await pageB.screenshot({ path: "e2e/artifacts/notifications.png" });
await pageA.screenshot({ path: "e2e/artifacts/ticket-new.png" });
} catch (err) {
failed += 1;
console.log("FAIL 异常:", err.message);
await pageA.screenshot({ path: "e2e/artifacts/notifications-fail-a.png" }).catch(() => {});
} finally {
await browser.close();
}
console.log(failed === 0 ? "\nALL PASS" : `\n${failed} 项失败`);
process.exit(failed === 0 ? 0 : 1);
+120
View File
@@ -0,0 +1,120 @@
/**
* 检索 e2e:发布的知识能被搜到,草稿搜不到,结果能点回原文。
*
* 只验证"用户能不能用上",切片与打分逻辑由后端测试覆盖。
*/
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 = `134${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 published = await call(
"POST",
`/workspaces/${ws.id}/knowledge`,
{
title: "快递单号对不上怎么处理",
doc_type: "howto",
body_md:
"## 步骤\n\n先在顺丰后台按手机号反查单号,再和发货记录逐一核对。\n\n如果差超过两天,直接找客服开异常件工单。",
},
token,
);
await call(
"POST",
`/workspaces/${ws.id}/knowledge/${published.id}/status`,
{ action: "publish", version: published.version },
token,
);
const draft = await call(
"POST",
`/workspaces/${ws.id}/knowledge`,
{ title: "还没核对的草稿", doc_type: "note", body_md: "这条写的是异常件的赔付口径,尚未定稿。" },
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}/knowledge`, { waitUntil: "networkidle" });
const box = page.getByPlaceholder(/搜标题或正文/);
await box.fill("异常件单号核对");
await box.press("Enter");
await page.getByText(/命中 \d+ 段/).waitFor({ timeout: 10_000 });
const summary = await page.getByText(/命中 \d+ 段/).first().textContent();
check("检索返回了结果", /命中 [1-9]\d* 段/.test(summary ?? ""), summary ?? "");
check("命中里带标题", await page.getByText("快递单号对不上怎么处理", { exact: false }).first().isVisible());
check("草稿不会出现在检索结果里", !(await page.getByText("还没核对的草稿", { exact: false }).count()));
await page.screenshot({ path: "e2e/artifacts/retrieval-hits.png", fullPage: false });
await page.getByRole("button", { name: "打开原文", exact: true }).first().click();
await page.getByText(/已发布 · 版本 \d+/, { exact: false }).waitFor({ timeout: 10_000 });
check("命中能点回原文", true);
await page.getByRole("button", { name: "关闭", exact: true }).click();
await box.fill("量子纠缠");
await box.press("Enter");
await page.getByText("没有检索到相关内容", { exact: false }).waitFor({ timeout: 10_000 });
check("不相干的关键词宁可返回空", true);
check("浏览器控制台与网络无报错", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | "));
} catch (err) {
failures += 1;
console.log(`FAIL 脚本异常 — ${err.message}`);
await page.screenshot({ path: "e2e/artifacts/retrieval-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);
}
+50
View File
@@ -0,0 +1,50 @@
// 只为截图:登录一个已有通知的账号,截「通知页」与「新建工单」面板。
import { chromium } from "playwright-core";
const BASE = "http://127.0.0.1:5180";
const API = "http://127.0.0.1:8100/api/v1";
const CHROME = "C:/Users/Steven/AppData/Local/ms-playwright/chromium-1223/chrome-win64/chrome.exe";
const login = async (phone) => {
const send = await fetch(`${API}/auth/sms/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone }),
}).then((r) => r.json());
const code = send.data?.dev_code ?? "123456";
const r = await fetch(`${API}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone, code }),
}).then((x) => x.json());
return r.data;
};
const browser = await chromium.launch({ executablePath: CHROME, args: ["--no-sandbox"] });
const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
const auth = await login("13900009002");
const me = await fetch(`${API}/auth/me`, {
headers: { Authorization: `Bearer ${auth.tokens.access_token}` },
}).then((r) => r.json());
const ws = me.data.workspaces[0];
await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" });
await page.evaluate(
([a, r]) => {
localStorage.setItem("dadao.access", a);
localStorage.setItem("dadao.refresh", r);
},
[auth.tokens.access_token, auth.tokens.refresh_token],
);
await page.goto(`${BASE}/w/${ws.id}/notifications`, { waitUntil: "domcontentloaded" });
await page.waitForSelector("ul li", { timeout: 20000 });
await page.screenshot({ path: "e2e/artifacts/ui-notifications.png" });
await page.goto(`${BASE}/w/${ws.id}/tickets`, { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "新建工单" }).click();
await page.waitForSelector("text=交给谁", { timeout: 15000 });
await page.screenshot({ path: "e2e/artifacts/ui-ticket-new.png" });
console.log("done", ws.name);
await browser.close();
+2
View File
@@ -10,6 +10,7 @@ import { TasksPage } from "./pages/Tasks";
import { BoardPage } from "./pages/Board";
import { TicketsPage } from "./pages/Tickets";
import { KnowledgePage } from "./pages/Knowledge";
import { NotificationsPage } from "./pages/Notifications";
import { JoinPage } from "./pages/Join";
interface AuthState {
@@ -90,6 +91,7 @@ export function App() {
<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/notifications" element={<NotificationsPage />} />
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
<Route path="/join/:wsId/:code" element={<JoinPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
+23 -4
View File
@@ -6,6 +6,7 @@ import type { Workspace } from "../lib/types";
import {
Book,
ChevronDown,
Bell,
Columns,
Inbox,
Logo,
@@ -15,13 +16,25 @@ import {
X,
} from "./icons";
export type Section = "tasks" | "board" | "tickets" | "knowledge" | "members";
export type Section =
| "tasks"
| "board"
| "tickets"
| "knowledge"
| "notifications"
| "members";
const NAV: { key: Section; label: string; to: (id: string) => string; icon: ReactNode }[] = [
{ key: "tasks", label: "任务", to: (id) => `/w/${id}`, icon: <Inbox size={16} /> },
{ key: "board", label: "看板", to: (id) => `/w/${id}/board`, icon: <Columns size={16} /> },
{ key: "tickets", label: "工单", to: (id) => `/w/${id}/tickets`, icon: <TicketIcon size={16} /> },
{ key: "knowledge", label: "知识", to: (id) => `/w/${id}/knowledge`, icon: <Book size={16} /> },
{
key: "notifications",
label: "通知",
to: (id) => `/w/${id}/notifications`,
icon: <Bell size={16} />,
},
{ key: "members", label: "成员", to: (id) => `/w/${id}/members`, icon: <Users size={16} /> },
];
@@ -73,16 +86,22 @@ export function AppShell({
let alive = true;
const load = async () => {
try {
const r = await api.get<{ count: number }>(`/workspaces/${wsId}/notifications/unread-count`);
if (alive) setUnread(r.count);
const r = await api.get<{ unread: number }>(
`/workspaces/${wsId}/notifications/unread-count`,
);
if (alive) setUnread(r.unread ?? 0);
} catch {
/* 未读数失败不该影响主流程 */
}
};
// 通知页标了已读之后立刻刷新角标,不必等下一个轮询周期
const onChange = () => void load();
void load();
window.addEventListener("notifications:changed", onChange);
const timer = window.setInterval(load, 30_000);
return () => {
alive = false;
window.removeEventListener("notifications:changed", onChange);
window.clearInterval(timer);
};
}, [wsId]);
@@ -174,7 +193,7 @@ export function AppShell({
<Link key={n.key} to={n.to(ws.id)} className={`nav-item ${isActive ? "nav-item-active" : ""}`}>
<span className={isActive ? "text-primary-200" : "text-ink-400"}>{n.icon}</span>
{n.label}
{n.key === "tickets" && unread > 0 ? (
{n.key === "notifications" && unread > 0 ? (
<span
className={`ml-auto rounded-full px-1.5 py-px text-2xs font-semibold tabular-nums ${
isActive ? "bg-clay-500 text-white" : "bg-clay-500/12 text-clay-600"
+31 -4
View File
@@ -18,7 +18,13 @@ interface Props {
onChange: (next: string) => void;
/** 图片要落到具体条目下,所以这两个是必填 */
wsId: string;
itemId: string;
/** 新建场景还没有条目 id:正文照样能写,只是暂时不能贴图(贴图前必须先存草稿) */
itemId?: string;
/**
* 新建场景的兜底:要贴图时先老老实实把草稿存下来,拿到条目 id 再传。
* 返回 null 表示没存成(由调用方自己提示),编辑器不会继续上传。
*/
ensureItemId?: () => Promise<string | null>;
placeholder?: string;
disabled?: boolean;
/** 编辑器初始高度(px) */
@@ -45,6 +51,7 @@ export function RichTextEditor({
onChange,
wsId,
itemId,
ensureItemId,
placeholder = "写点什么…支持粘贴/拖入图片,也可以用工具栏排版",
disabled = false,
minHeight = 240,
@@ -61,6 +68,7 @@ export function RichTextEditor({
const onErrorRef = useRef(onError);
const resourcesRef = useRef(resources);
const onUploadedRef = useRef(onUploaded);
const ensureItemIdRef = useRef(ensureItemId);
const [ready, setReady] = useState(false);
const [uploading, setUploading] = useState(false);
@@ -69,6 +77,7 @@ export function RichTextEditor({
onErrorRef.current = onError;
resourcesRef.current = resources;
onUploadedRef.current = onUploaded;
ensureItemIdRef.current = ensureItemId;
const disabledRef = useRef(disabled);
disabledRef.current = disabled;
@@ -112,7 +121,8 @@ export function RichTextEditor({
"line",
"code",
"inline-code",
"upload",
// 没有落点就不摆这个按钮,免得点了才报错;新建时靠 ensureItemId 先存草稿
...(itemId || ensureItemId ? ["upload"] : []),
"table",
"|",
"undo",
@@ -129,6 +139,19 @@ export function RichTextEditor({
accept: "image/*",
multiple: true,
handler: async (files: File[]) => {
// 新建时还没有条目 id:先让调用方把草稿存下来,拿到落点再传
let targetId = itemId;
if (!targetId) {
const ensured = await ensureItemIdRef.current?.();
if (!ensured) {
// 存草稿失败的原因由 ensureItemId 自己报,这里不叠一层噪音
if (!ensureItemIdRef.current) {
onErrorRef.current?.("先保存这条内容,再回来贴图或传附件");
}
return null;
}
targetId = ensured;
}
const images = files.filter((file) => file.type.startsWith("image/"));
if (images.length === 0) {
onErrorRef.current?.("只能插入图片");
@@ -139,7 +162,7 @@ export function RichTextEditor({
try {
for (const file of images) {
const created = await uploadFile<Attachment>(
`/workspaces/${wsId}/items/${itemId}/attachments`,
`/workspaces/${wsId}/items/${targetId}/attachments`,
file,
);
// 自己插入,不用 Vditor 代劳:它对 handler 的返回值只会
@@ -211,7 +234,11 @@ export function RichTextEditor({
<p className="mt-1 text-2xs text-ink-400">
{uploading
? "正在上传图片…"
: "支持直接粘贴或拖入图片;内容按 Markdown 保存,模型和导出都读得懂"}
: itemId
? "支持直接粘贴或拖入图片;内容按 Markdown 保存,模型和导出都读得懂"
: ensureItemId
? "支持直接粘贴或拖入图片:会先自动存成草稿,再传上去"
: "内容按 Markdown 保存;存下来之后就能直接贴图、传附件"}
</p>
</div>
);
+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={
+20
View File
@@ -178,6 +178,13 @@ export const Inbox = (p: IconProps) => (
</svg>
);
export const Bell = (p: IconProps) => (
<svg {...base(p)}>
<path d="M6 9a6 6 0 1 1 12 0c0 3.2.8 4.9 1.6 5.9.4.5 0 1.1-.6 1.1H5c-.6 0-1-.6-.6-1.1C5.2 13.9 6 12.2 6 9Z" />
<path d="M10 19a2 2 0 0 0 4 0" />
</svg>
);
export const Sparkle = (p: IconProps) => (
<svg {...base(p)}>
<path d="M12 3.5 13.6 9l5.4 1.6-5.4 1.6L12 17.5l-1.6-5.3L5 10.6 10.4 9Z" />
@@ -227,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 ? (
+42
View File
@@ -150,10 +150,52 @@ export interface KnowledgeSource {
status: string;
}
export interface KnowledgeCategory {
id: string;
parent_id: string | null;
name: string;
depth: number;
sort_order: number;
version: number;
/** 从根到自己的名字链,后端拼好了,前端不用自己爬树 */
path: string[];
full_path: string;
}
export interface KnowledgeCategoryNode extends KnowledgeCategory {
doc_count: number;
}
export interface Knowledge extends Item {
knowledge: KnowledgeInfo;
category: KnowledgeCategory | null;
source: KnowledgeSource | null;
attachments?: Attachment[];
attachment_count?: number;
activities?: Activity[];
}
/** 一条检索命中:切片级结果,带来源,点得回原文。 */
export interface SearchHit {
item_id: string;
title: string;
doc_type: string;
idx: number;
heading: string | null;
content: string;
score: number;
vector_score: number;
lexical_score: number;
}
/** 站内通知:只有"谁做了什么"和"指向哪条事项",详情永远回源取。 */
export interface AppNotification {
id: string;
kind: string;
item_id: string | null;
item_title: string | null;
title: string;
body: string;
read_at: string | null;
created_at: string;
}
+632 -70
View File
@@ -3,7 +3,7 @@ import { useParams } from "react-router-dom";
import { AppShell } from "../components/AppShell";
import { AttachmentPanel } from "../components/AttachmentPanel";
import { RichTextEditor } from "../components/RichTextEditor";
import { Book, Link2, Plus, Search, Sparkle } from "../components/icons";
import { Link2, Paperclip, Plus, Search, Sparkle } from "../components/icons";
import {
Button,
EmptyState,
@@ -14,7 +14,13 @@ import {
StatusPill,
} from "../components/ui";
import { ApiError, api } from "../lib/client";
import type { Attachment, Knowledge, KnowledgeStatus } from "../lib/types";
import type {
Attachment,
Knowledge,
KnowledgeCategoryNode,
KnowledgeStatus,
SearchHit,
} from "../lib/types";
const TABS = [
{ key: "draft", label: "待核对", statuses: ["draft", "in_review"] },
@@ -61,10 +67,48 @@ export function KnowledgePage() {
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 [composing, setComposing] = useState(false);
const [hits, setHits] = useState<SearchHit[] | null>(null);
const [searching, setSearching] = useState(false);
/** null = 不按分类筛(全部);"none" = 未分类;其它是分类 id(含子分类) */
const [catFilter, setCatFilter] = useState<string | null>(null);
const [categories, setCategories] = useState<KnowledgeCategoryNode[]>([]);
const [catBusy, setCatBusy] = useState(false);
const reqId = useRef(0);
const loadCategories = useCallback(async () => {
try {
const page = await api.get<{ items: KnowledgeCategoryNode[] }>(
`/workspaces/${wsId}/knowledge/categories`,
);
setCategories(page.items);
} catch (err) {
setError(err instanceof ApiError ? err.message : "分类加载失败");
}
}, [wsId]);
useEffect(() => {
void loadCategories();
}, [loadCategories]);
async function createCategory(name: string, parentId: string | null) {
setCatBusy(true);
setError(null);
try {
const created = await api.post<KnowledgeCategoryNode>(
`/workspaces/${wsId}/knowledge/categories`,
{ name, parent_id: parentId },
);
await loadCategories();
return created;
} catch (err) {
setError(err instanceof ApiError ? err.message : "分类创建失败");
return null;
} finally {
setCatBusy(false);
}
}
const load = useCallback(
async (nextCursor: string | null) => {
// 切 tab / 改搜索会连发请求,慢的回来会盖掉新结果
@@ -76,6 +120,8 @@ export function KnowledgePage() {
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 (catFilter === "none") qs.set("uncategorized", "true");
else if (catFilter) qs.set("category_id", catFilter);
if (nextCursor) qs.set("cursor", nextCursor);
const page = await api.get<{
items: Knowledge[];
@@ -92,29 +138,46 @@ export function KnowledgePage() {
if (mine === reqId.current) setLoading(false);
}
},
[wsId, tab, q],
[wsId, tab, q, catFilter],
);
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 : "保存失败");
/**
* 语义检索走的是切片接口,和上面的列表筛选不是一回事:
* 列表按标题/正文做子串匹配,检索按向量 + trigram 打分,只覆盖已发布的知识。
* 所以不把 q 直接接到列表上——两套结果混在一起会让人搞不清命中在哪。
*/
async function runSearch() {
const keyword = q.trim();
if (!keyword) {
setHits(null);
return;
}
setSearching(true);
setError(null);
try {
const query = new URLSearchParams({ q: keyword, limit: "12" });
const page = await api.get<{ items: SearchHit[] }>(
`/workspaces/${wsId}/knowledge/search?${query.toString()}`,
);
setHits(page.items);
} catch (err) {
setError(err instanceof ApiError ? err.message : "检索失败");
} finally {
setSearching(false);
}
}
function onCreated(doc: Knowledge) {
setComposing(false);
setTab("draft");
setCatFilter(null);
setOpenId(doc.id);
void load(null);
void loadCategories();
}
const pendingCount = docs.filter((d) => d.knowledge.status_hint !== "published").length;
@@ -133,12 +196,22 @@ export function KnowledgePage() {
</span>
<input
className="input h-9 w-48 pl-8 text-sm"
placeholder="搜标题或正文…"
placeholder="搜标题或正文…(回车检索)"
value={q}
onChange={(e) => setQ(e.target.value)}
onChange={(e) => {
setQ(e.target.value);
if (!e.target.value.trim()) setHits(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") void runSearch();
}}
/>
</div>
<Button variant="primary" onClick={() => setAdding((v) => !v)}>
<Button variant="ghost" disabled={searching || !q.trim()} onClick={() => void runSearch()}>
<Search size={14} />
{searching ? "检索中…" : "检索"}
</Button>
<Button variant="primary" onClick={() => setComposing(true)}>
<Plus size={14} />
记一条
</Button>
@@ -146,6 +219,18 @@ export function KnowledgePage() {
}
/>
<div className="grid gap-4 lg:grid-cols-[220px_minmax(0,1fr)]">
<CategorySidebar
categories={categories}
selected={catFilter}
onSelect={(value) => {
setCatFilter(value);
setHits(null);
}}
busy={catBusy}
onCreate={(name, parentId) => void createCategory(name, parentId)}
/>
<div className="min-w-0">
<div className="mb-4 flex flex-wrap items-center gap-1.5">
{TABS.map((t) => {
const active = tab === t.key;
@@ -170,41 +255,58 @@ export function KnowledgePage() {
) : null}
</div>
{adding && (
<div className="card mb-4 p-4">
<div className="flex items-start gap-2 text-xs text-ink-500">
<span className="mt-0.5 text-clay-500">
<Book size={14} />
</span>
<p className="leading-relaxed">
手记的知识没有来源,它是你自己的笔记;想沉淀某件事,去那件事的详情里点「沉淀成知识」。
</p>
</div>
<div className="mt-3 flex gap-2">
<input
className="input flex-1"
placeholder="一句话标题,比如「报销单要贴发票原件」"
value={draftTitle}
autoFocus
onChange={(e) => setDraftTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void addNote();
}}
/>
<Button variant="primary" disabled={!draftTitle.trim()} onClick={() => void addNote()}>
保存
</Button>
</div>
</div>
)}
{error && (
<div className="mb-4">
<InlineAlert>{error}</InlineAlert>
</div>
)}
{loading && docs.length === 0 ? (
{hits !== null ? (
<section className="space-y-2">
<div className="flex items-baseline justify-between px-1">
<p className="text-xs text-ink-500">
在已发布的知识里检索「{q.trim()}」,命中 {hits.length} 段
</p>
<button className="link text-2xs" onClick={() => setHits(null)}>
退出检索
</button>
</div>
{hits.length === 0 ? (
<div className="card">
<EmptyState
icon={<Search size={18} />}
title="没有检索到相关内容"
hint="只检索已发布的知识。换成更具体的说法,或者先把手头的草稿核对发布。"
/>
</div>
) : (
hits.map((hit) => (
<article key={`${hit.item_id}-${hit.idx}`} className="card p-4">
<div className="flex flex-wrap items-center gap-1.5">
<span className="chip bg-primary-50 text-primary-700">
{DOC_TYPE_LABEL[hit.doc_type] ?? hit.doc_type}
</span>
{hit.heading ? (
<span className="chip bg-paper-200 text-ink-600">{hit.heading}</span>
) : null}
<span className="ml-auto text-2xs text-ink-400">
相关度 {hit.score.toFixed(2)}
</span>
</div>
<p className="mt-2 text-sm font-medium text-ink-900">{hit.title}</p>
<p className="mt-1.5 line-clamp-3 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
{hit.content}
</p>
<div className="mt-2 text-right">
<Button variant="ghost" size="sm" onClick={() => setOpenId(hit.item_id)}>
打开原文
</Button>
</div>
</article>
))
)}
</section>
) : loading && docs.length === 0 ? (
<div className="card overflow-hidden">
<SkeletonRows rows={3} />
</div>
@@ -244,6 +346,11 @@ export function KnowledgePage() {
自动整理
</span>
) : null}
{d.category ? (
<span className="chip max-w-[16rem] truncate bg-paper-200 text-ink-500">
{d.category.full_path}
</span>
) : null}
</div>
<p className="mt-1.5 truncate text-sm font-medium text-ink-900 group-hover:text-primary-700">
{d.title}
@@ -256,6 +363,16 @@ export function KnowledgePage() {
{d.source ? <span className="max-w-[22rem] truncate">「{d.source.title}」</span> : null}
<span>{new Date(d.updated_at).toLocaleString("zh-CN")}</span>
</p>
{/* 标签是备忘录的检索入口,卡片上就露出来,不用点开才知道归在哪 */}
{d.tags.length > 0 ? (
<div className="mt-1.5 flex flex-wrap gap-1">
{d.tags.slice(0, 5).map((t) => (
<span key={t.id} className="chip bg-primary-50 text-primary-700">
#{t.name}
</span>
))}
</div>
) : null}
</button>
<Button variant="ghost" size="sm" className="shrink-0" onClick={() => setOpenId(d.id)}>
打开
@@ -272,28 +389,429 @@ export function KnowledgePage() {
</Button>
</div>
)}
</div>
</div>
{composing && (
<KnowledgeComposer
wsId={wsId}
categories={categories}
catBusy={catBusy}
onCreateCategory={createCategory}
onClose={() => setComposing(false)}
onCreated={onCreated}
/>
)}
{openId && (
<KnowledgeDrawer
wsId={wsId}
itemId={openId}
categories={categories}
onClose={() => setOpenId(null)}
onChanged={() => void load(null)}
onChanged={() => {
void load(null);
void loadCategories();
}}
/>
)}
</AppShell>
);
}
/** 左侧分类树:一层一层缩进,点一下就把列表收窄到该分类(含子分类)。 */
function CategorySidebar({
categories,
selected,
onSelect,
busy,
onCreate,
}: {
categories: KnowledgeCategoryNode[];
selected: string | null;
onSelect: (value: string | null) => void;
busy: boolean;
onCreate: (name: string, parentId: string | null) => void;
}) {
const [adding, setAdding] = useState(false);
const [name, setName] = useState("");
const [parentId, setParentId] = useState("");
return (
<aside className="card h-fit p-3 lg:sticky lg:top-4">
<div className="flex items-center justify-between px-1 py-1">
<span className="text-2xs font-medium uppercase tracking-wide text-ink-400">分类</span>
<button className="link text-2xs" onClick={() => setAdding((v) => !v)}>
{adding ? "取消" : "+ 新建"}
</button>
</div>
{adding && (
<form
className="mt-2 space-y-1.5 rounded-lg border border-line-200 bg-paper-50 p-2"
onSubmit={(e) => {
e.preventDefault();
const value = name.trim();
if (!value) return;
onCreate(value, parentId || null);
setName("");
setAdding(false);
}}
>
<input
className="input h-8 text-sm"
placeholder="分类名,例如「部署」"
value={name}
autoFocus
onChange={(e) => setName(e.target.value)}
aria-label="新分类名"
/>
<select
className="input h-8 text-sm"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
aria-label="父分类"
>
<option value="">放在最外层</option>
{categories
.filter((c) => c.depth < 3)
.map((c) => (
<option key={c.id} value={c.id}>
{c.full_path} 下
</option>
))}
</select>
<Button variant="primary" size="sm" type="submit" disabled={busy || !name.trim()}>
创建
</Button>
</form>
)}
<nav className="mt-2 space-y-0.5">
<CategoryButton active={selected === null} onClick={() => onSelect(null)}>
全部知识
</CategoryButton>
<CategoryButton active={selected === "none"} onClick={() => onSelect("none")}>
未分类
</CategoryButton>
{categories.map((c) => (
<CategoryButton
key={c.id}
active={selected === c.id}
indent={c.depth - 1}
onClick={() => onSelect(c.id)}
>
<span className="truncate">{c.name}</span>
{c.doc_count > 0 ? <span className="text-2xs text-ink-400">{c.doc_count}</span> : null}
</CategoryButton>
))}
</nav>
{categories.length === 0 ? (
<p className="mt-2 px-1 text-2xs leading-relaxed text-ink-400">
还没有分类。先建一层「研发 / 部署」这样的目录,知识才有地方落脚。
</p>
) : null}
</aside>
);
}
function CategoryButton({
active,
indent = 0,
onClick,
children,
}: {
active: boolean;
indent?: number;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
style={{ paddingLeft: `${8 + indent * 14}px` }}
className={`flex w-full items-center justify-between gap-2 rounded-lg py-1.5 pr-2 text-sm transition-colors ${
active
? "bg-white text-ink-900 shadow-sm ring-1 ring-line-200"
: "text-ink-600 hover:bg-white/60 hover:text-ink-800"
}`}
>
{children}
</button>
);
}
/** 新建知识:标题、类型、分类、标签、正文一起填,不再是「输入标题就算完」。 */
function KnowledgeComposer({
wsId,
categories,
catBusy,
onCreateCategory,
onClose,
onCreated,
}: {
wsId: string;
categories: KnowledgeCategoryNode[];
catBusy: boolean;
onCreateCategory: (name: string, parentId: string | null) => Promise<KnowledgeCategoryNode | null>;
onClose: () => void;
onCreated: (doc: Knowledge) => void;
}) {
const [title, setTitle] = useState("");
const [docType, setDocType] = useState("note");
const [body, setBody] = useState("");
const [tags, setTags] = useState("");
const [categoryId, setCategoryId] = useState("");
const [newCat, setNewCat] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
/**
* 附件与图片都要落在具体条目上,可新建时条目还不存在。
* 解法是懒建草稿:第一次传附件/贴图时才 POST 一条草稿,之后一律 PATCH。
* 没传过附件的人,取消后什么都不会留在库里。
*/
const [draftId, setDraftId] = useState<string | null>(null);
const [draftVersion, setDraftVersion] = useState(0);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const draftingRef = useRef<Promise<string | null> | null>(null);
const parentDepth = categories.find((c) => c.id === categoryId)?.depth ?? 0;
const canAddCategory = parentDepth < 3 && Boolean(newCat.trim());
const canSave = Boolean(title.trim()) && !busy;
async function addCategory() {
const name = newCat.trim();
if (!name || !canAddCategory) return;
const created = await onCreateCategory(name, categoryId || null);
if (created) {
setCategoryId(created.id);
setNewCat("");
}
}
/** 拿条目 id:已有就直接返回,没有就现存一条草稿;并发调用共用同一个请求。 */
async function ensureDraft(): Promise<string | null> {
if (draftId) return draftId;
if (draftingRef.current) return draftingRef.current;
const pending = (async () => {
try {
const doc = await api.post<Knowledge>(`/workspaces/${wsId}/knowledge`, {
title: title.trim() || "未命名知识",
body_md: body,
doc_type: docType,
tag_names: tags.split(/[\s,,]+/).filter(Boolean),
category_id: categoryId || null,
});
setDraftId(doc.id);
setDraftVersion(doc.version);
setAttachments(doc.attachments ?? []);
setTitle(doc.title);
setNotice("已存成草稿,现在可以继续写、贴图、传附件");
return doc.id;
} catch (err) {
setError(err instanceof ApiError ? err.message : "保存草稿失败");
return null;
} finally {
draftingRef.current = null;
}
})();
draftingRef.current = pending;
return pending;
}
async function save() {
if (!canSave) return;
const itemId = await ensureDraft();
if (!itemId) return;
setBusy(true);
setError(null);
try {
const doc = await api.patch<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`, {
version: draftVersion,
title: title.trim(),
body_md: body,
doc_type: docType,
tag_names: tags.split(/[\s,,]+/).filter(Boolean),
...(categoryId ? { category_id: categoryId } : { clear_category: true }),
});
onCreated(doc);
} catch (err) {
setError(err instanceof ApiError ? err.message : "保存失败");
} finally {
setBusy(false);
}
}
return (
<Sheet
open
onClose={onClose}
size="xl"
title="记一条知识"
subtitle="标题、正文、标签、分类一次填完;附件和贴图会先把这条存成草稿。"
footer={
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" disabled={!canSave} onClick={() => void save()}>
{busy ? "保存中…" : "保存并继续写"}
</Button>
<Button variant="ghost" disabled={busy} onClick={onClose}>
取消
</Button>
<span className="ml-auto text-2xs text-ink-400">
{draftId
? "已经是草稿了:直接关掉会留在「待核对」里"
: "先存成草稿,核对发布后才进团队检索"}
</span>
</div>
}
>
<div className="space-y-4">
<label className="block">
<span className="label">标题</span>
<input
className="input text-base font-medium"
placeholder="一句话说清这条知识是什么,比如「回滚前先停掉灰度」"
value={title}
autoFocus
onChange={(e) => setTitle(e.target.value)}
aria-label="标题"
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="label">类型</span>
<select
className="input"
value={docType}
onChange={(e) => setDocType(e.target.value)}
aria-label="类型"
>
{Object.entries(DOC_TYPE_LABEL).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</label>
<label className="block">
<span className="label">分类</span>
<select
className="input"
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
aria-label="分类"
>
<option value="">未分类</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.full_path}
</option>
))}
</select>
</label>
</div>
<div className="rounded-xl border border-line-200 bg-paper-50 p-2.5">
<span className="label">在当前分类下新建子分类(最多三级)</span>
<div className="flex gap-2">
<input
className="input h-9 flex-1 text-sm"
placeholder={parentDepth >= 3 ? "已经到第三级了" : "子分类名"}
value={newCat}
disabled={parentDepth >= 3}
onChange={(e) => setNewCat(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void addCategory();
}
}}
aria-label="新建子分类"
/>
<Button
variant="ghost"
disabled={!canAddCategory || catBusy}
onClick={() => void addCategory()}
>
新建
</Button>
</div>
</div>
<label className="block">
<span className="label">标签(空格或逗号分隔)</span>
<input
className="input"
placeholder="例如:发布 事故"
value={tags}
onChange={(e) => setTags(e.target.value)}
aria-label="标签"
/>
</label>
<div>
<span className="label">正文</span>
{/* 和详情抽屉同一个富文本编辑器:新建时就该能排版、写表格,而不是先存一个纯文本框 */}
<div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2">
<RichTextEditor
ariaLabel="正文"
wsId={wsId}
value={body}
onChange={setBody}
minHeight={320}
ensureItemId={ensureDraft}
onError={setError}
placeholder={"写下要点、步骤、结论…\n\n标题、类型、分类、标签都在上面,正文就是这张备忘的主体。"}
/>
</div>
</div>
<div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2">
{draftId ? (
<AttachmentPanel
wsId={wsId}
itemId={draftId}
attachments={attachments}
onChange={setAttachments}
onError={setError}
/>
) : (
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1.5 text-2xs font-medium text-ink-500">
<Paperclip size={13} />
附件
</span>
<Button variant="ghost" disabled={busy} onClick={() => void ensureDraft()}>
添加附件
</Button>
<span className="text-2xs text-ink-400">
附件要挂在条目上:点一下先把这条存成草稿,然后就能上传、下载
</span>
</div>
)}
</div>
{notice && <p className="text-2xs text-primary-700">{notice}</p>}
{error && <InlineAlert>{error}</InlineAlert>}
</div>
</Sheet>
);
}
/** 知识详情抽屉:能改、能提交审议、能发布/归档。 */
function KnowledgeDrawer({
wsId,
itemId,
categories,
onClose,
onChanged,
}: {
wsId: string;
itemId: string;
categories: KnowledgeCategoryNode[];
onClose: () => void;
onChanged: () => void;
}) {
@@ -301,6 +819,8 @@ function KnowledgeDrawer({
const [error, setError] = useState<string | null>(null);
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [tags, setTags] = useState("");
const [categoryId, setCategoryId] = useState("");
const [attachments, setAttachments] = useState<Attachment[]>([]);
/** 附件 id → 带签名的直链(见 lib/attachmentUrls.ts) */
const resources = useMemo(() => {
@@ -311,18 +831,27 @@ function KnowledgeDrawer({
return map;
}, [attachments]);
const [busy, setBusy] = useState(false);
/** 已经载入完成的是哪一条知识。重复的 effect / 慢响应不该再覆盖用户正在敲的内容。 */
const loadedFor = useRef<string | null>(null);
const load = useCallback(async () => {
try {
const data = await api.get<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`);
setDoc(data);
setTitle(data.title);
setBody(data.body_md);
setAttachments(data.attachments ?? []);
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
}
}, [wsId, itemId]);
const load = useCallback(
async (force = false) => {
if (!force && loadedFor.current === itemId) return;
try {
const data = await api.get<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`);
setDoc(data);
setTitle(data.title);
setBody(data.body_md);
setTags(data.tags.map((t) => t.name).join(" "));
setCategoryId(data.category?.id ?? "");
setAttachments(data.attachments ?? []);
loadedFor.current = itemId;
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
}
},
[wsId, itemId],
);
useEffect(() => {
void load();
@@ -336,6 +865,8 @@ function KnowledgeDrawer({
const next = await api.patch<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`, {
title,
body_md: body,
tag_names: tags.split(/[\s,,]+/).filter(Boolean),
...(categoryId ? { category_id: categoryId } : { clear_category: true }),
version: doc.version,
});
setDoc(next);
@@ -343,7 +874,7 @@ function KnowledgeDrawer({
} catch (err) {
if (err instanceof ApiError && err.code === 4009) {
setError("刚被别人改过,已刷新为最新内容");
await load();
await load(true);
} else {
setError(err instanceof ApiError ? err.message : "保存失败");
}
@@ -365,7 +896,7 @@ function KnowledgeDrawer({
onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : "操作失败");
await load();
await load(true);
} finally {
setBusy(false);
}
@@ -389,13 +920,13 @@ function KnowledgeDrawer({
<Sheet
open
onClose={onClose}
width="max-w-2xl"
size="xl"
title={doc ? doc.title : "知识"}
subtitle={
doc
? `${DOC_TYPE_LABEL[doc.knowledge.doc_type] ?? doc.knowledge.doc_type} · ${
STATUS_LABEL[doc.knowledge.status_hint]
} · 版本 ${doc.version}`
? [doc.category?.full_path, DOC_TYPE_LABEL[doc.knowledge.doc_type] ?? doc.knowledge.doc_type]
.filter(Boolean)
.join(" · ")
: "加载中…"
}
footer={
@@ -460,6 +991,37 @@ function KnowledgeDrawer({
aria-label="标题"
/>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="label">分类</span>
<select
className="input"
value={categoryId}
disabled={!canEdit}
onChange={(e) => setCategoryId(e.target.value)}
aria-label="分类"
>
<option value="">未分类</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.full_path}
</option>
))}
</select>
</label>
<label className="block">
<span className="label">标签(空格或逗号分隔)</span>
<input
className="input"
value={tags}
disabled={!canEdit}
onChange={(e) => setTags(e.target.value)}
placeholder="例如:发布 事故"
aria-label="标签"
/>
</label>
</div>
<div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2">
<RichTextEditor
ariaLabel="正文"
+228
View File
@@ -0,0 +1,228 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { AppShell } from "../components/AppShell";
import { Bell, Check, TicketIcon, Inbox, Clock } from "../components/icons";
import { Button, EmptyState, PageHeader, SkeletonRows } from "../components/ui";
import { api, ApiError } from "../lib/client";
import type { AppNotification } from "../lib/types";
const PAGE = 30;
/** 工单类通知点开跳到工单页,其余回任务页——都带 `?open=`,落地即展开那一条。 */
function targetOf(wsId: string, n: AppNotification): string {
if (!n.item_id) return `/w/${wsId}/notifications`;
const isTicket = n.kind.startsWith("ticket_");
return isTicket ? `/w/${wsId}/tickets?open=${n.item_id}` : `/w/${wsId}?open=${n.item_id}`;
}
function iconOf(kind: string) {
if (kind.startsWith("ticket_")) return <TicketIcon size={15} />;
if (kind === "item_commented") return <Inbox size={15} />;
return <Bell size={15} />;
}
function ago(iso: string): string {
const then = new Date(iso).getTime();
const diff = Math.max(0, Date.now() - then);
const min = Math.floor(diff / 60_000);
if (min < 1) return "刚刚";
if (min < 60) return `${min} 分钟前`;
const hour = Math.floor(min / 60);
if (hour < 24) return `${hour} 小时前`;
const day = Math.floor(hour / 24);
if (day < 30) return `${day} 天前`;
return new Date(iso).toLocaleDateString("zh-CN");
}
/** 通知页:默认只看未读——"全部"是查历史用的,不是日常入口。 */
export function NotificationsPage() {
const { wsId = "" } = useParams();
const navigate = useNavigate();
const [tab, setTab] = useState<"unread" | "all">("unread");
const [rows, setRows] = useState<AppNotification[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(
async (reset: boolean) => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
unread_only: tab === "unread" ? "true" : "false",
limit: String(PAGE),
});
if (!reset && cursor) params.set("cursor", cursor);
const page = await api.get<{
notifications: AppNotification[];
next_cursor: string | null;
has_more: boolean;
}>(`/workspaces/${wsId}/notifications?${params.toString()}`);
setRows((prev) => (reset ? page.notifications : [...prev, ...page.notifications]));
setCursor(page.next_cursor);
setHasMore(page.has_more);
} catch (err) {
setError(err instanceof ApiError ? err.message : "通知加载失败");
} finally {
setLoading(false);
}
},
[wsId, tab, cursor],
);
useEffect(() => {
setRows([]);
setCursor(null);
void load(true);
// 只在切 tab / 换工作区时重拉,翻页由按钮触发
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wsId, tab]);
const unreadCount = useMemo(() => rows.filter((r) => !r.read_at).length, [rows]);
async function markRead(n: AppNotification) {
if (!n.read_at) {
try {
await api.post(`/workspaces/${wsId}/notifications/${n.id}/read`);
} catch {
/* 标已读失败不拦着用户点进去看 */
}
setRows((prev) =>
prev.map((r) => (r.id === n.id ? { ...r, read_at: new Date().toISOString() } : r)),
);
window.dispatchEvent(new Event("notifications:changed"));
}
navigate(targetOf(wsId, n));
}
async function markAll() {
try {
await api.post(`/workspaces/${wsId}/notifications/read-all`);
setRows((prev) => prev.map((r) => ({ ...r, read_at: r.read_at ?? new Date().toISOString() })));
window.dispatchEvent(new Event("notifications:changed"));
} catch (err) {
setError(err instanceof ApiError ? err.message : "操作失败");
}
}
return (
<AppShell wsId={wsId} active="notifications">
<PageHeader
eyebrow="事情有动静时,这里会告诉你"
title="通知"
desc="工单被接受、提交、验收,任务被指派、改状态,有人在你关注的事情下说话,都会落在这里。"
actions={
<>
<Button variant="ghost" size="sm" onClick={() => void load(true)} disabled={loading}>
刷新
</Button>
<Button variant="subtle" size="sm" onClick={() => void markAll()} disabled={!unreadCount}>
全部已读
</Button>
</>
}
/>
<div className="mb-3 flex items-center gap-1">
{(
[
["unread", "未读"],
["all", "全部"],
] as const
).map(([key, label]) => (
<button
key={key}
onClick={() => setTab(key)}
className={`rounded-lg px-3 py-1.5 text-xs transition ${
tab === key
? "bg-ink-900 font-medium text-white"
: "text-ink-500 hover:bg-ink-100 hover:text-ink-800"
}`}
>
{label}
</button>
))}
{tab === "unread" && unreadCount > 0 ? (
<span className="ml-auto text-2xs text-ink-400">还有 {unreadCount} 条没看</span>
) : null}
</div>
{error ? (
<div className="mb-3 rounded-xl border border-rust-100 bg-rust-50 px-4 py-3 text-sm text-rust-700">
{error}
</div>
) : null}
{loading && rows.length === 0 ? (
<SkeletonRows rows={4} />
) : rows.length === 0 ? (
<div className="card">
<EmptyState
icon={<Bell size={20} />}
title={tab === "unread" ? "没有未读通知" : "还没有通知"}
hint={
tab === "unread"
? "有人给你派活、或你关注的事情有进展时,会出现在这里。"
: "这里会留下所有历史通知,方便回头找。"
}
/>
</div>
) : (
<ul className="space-y-2">
{rows.map((n) => (
<li key={n.id} className={`card ${n.read_at ? "" : "border-primary-100 bg-primary-50/40"}`}>
<button
onClick={() => void markRead(n)}
className="flex w-full items-start gap-3 px-4 py-3 text-left"
>
<span
className={`mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-full ${
n.read_at ? "bg-ink-100 text-ink-500" : "bg-primary-500 text-white"
}`}
>
{iconOf(n.kind)}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-2">
<span className="truncate text-sm font-medium text-ink-900">{n.title}</span>
{!n.read_at ? (
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-clay-500" />
) : null}
<span className="ml-auto shrink-0 text-2xs text-ink-400">
{ago(n.created_at)}
</span>
</span>
{n.body ? (
<span className="mt-1 block truncate text-xs text-ink-500">{n.body}</span>
) : null}
{n.item_title ? (
<span className="mt-1.5 inline-flex items-center gap-1 text-2xs text-ink-400">
<Clock size={11} />
{n.item_title}
</span>
) : null}
</span>
</button>
</li>
))}
</ul>
)}
{hasMore ? (
<div className="mt-4 flex justify-center">
<Button variant="ghost" size="sm" onClick={() => void load(false)} disabled={loading}>
加载更早
</Button>
</div>
) : rows.length > 0 && !hasMore ? (
<p className="mt-4 text-center text-2xs text-ink-300">
<Check size={11} className="mr-1 inline" />
没有更多了
</p>
) : null}
</AppShell>
);
}
+8 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { useParams, useSearchParams } from "react-router-dom";
import { AppShell } from "../components/AppShell";
import { TaskDetail } from "../components/TaskDetail";
import { Check, Inbox, Plus, Search } from "../components/icons";
@@ -41,7 +41,9 @@ export function TasksPage() {
const [draft, setDraft] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [openId, setOpenId] = useState<string | null>(null);
// 从通知点进来时带 ?open=<id>,落地即展开那一条
const [searchParams, setSearchParams] = useSearchParams();
const [openId, setOpenId] = useState<string | null>(searchParams.get("open"));
const [activeIdx, setActiveIdx] = useState(0);
const reqId = useRef(0);
@@ -333,7 +335,10 @@ export function TasksPage() {
<TaskDetail
wsId={wsId}
itemId={openId}
onClose={() => setOpenId(null)}
onClose={() => {
setOpenId(null);
if (searchParams.get("open")) setSearchParams({}, { replace: true });
}}
onChanged={(updated) =>
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)))
}
+206 -19
View File
@@ -1,12 +1,21 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { useParams, useSearchParams } from "react-router-dom";
import { AppShell } from "../components/AppShell";
import { TaskDetail } from "../components/TaskDetail";
import { ChevronRight, TicketIcon } from "../components/icons";
import { Button, EmptyState, InlineAlert, PageHeader, SkeletonRows, StatusPill } from "../components/ui";
import { ChevronRight, Plus, TicketIcon } from "../components/icons";
import {
Button,
EmptyState,
InlineAlert,
PageHeader,
Sheet,
SkeletonRows,
StatusPill,
useToast,
} from "../components/ui";
import { ApiError, api } from "../lib/client";
import { STATUS_LABEL } from "../lib/parse";
import type { Item, Ticket } from "../lib/types";
import type { Item, Member, Ticket } from "../lib/types";
const ROLES = [
{ key: "assigned", label: "派给我的", desc: "别人等你的" },
@@ -25,14 +34,43 @@ const STATUS_FILTERS = [
export function TicketsPage() {
const { wsId = "" } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const [role, setRole] = useState<(typeof ROLES)[number]["key"]>("assigned");
const [status, setStatus] = useState<string>("");
const [tickets, setTickets] = useState<Ticket[]>([]);
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);
// 从通知点进来时带 ?open=<id>,落地即展开那一条
const [openId, setOpenId] = useState<string | null>(searchParams.get("open"));
const reqId = useRef(0);
const { push: toast } = useToast();
// 手工开单:这条路径不经过任何任务,所以人得自己选
const [members, setMembers] = useState<Member[]>([]);
const [creating, setCreating] = useState(false);
const [formTitle, setFormTitle] = useState("");
const [formBody, setFormBody] = useState("");
const [formAssignee, setFormAssignee] = useState("");
const [formPriority, setFormPriority] = useState(1);
const [formDue, setFormDue] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!wsId) return;
let alive = true;
void api
.get<{ items: Member[] }>(`/workspaces/${wsId}/members?limit=100`)
.then((page) => {
if (alive) setMembers(page.items);
})
.catch(() => {
/* 成员拉不到时,表单里就没人可选,但不该拦住列表 */
});
return () => {
alive = false;
};
}, [wsId]);
const load = useCallback(
async (nextCursor: string | null) => {
@@ -75,7 +113,8 @@ export function TicketsPage() {
void load(null);
} catch (err) {
if (err instanceof ApiError && err.code === 4009) {
setError("这条刚被别人改过,已为你刷新列表"); void load(null);
setError("这条刚被别人改过,已为你刷新列表");
void load(null);
return;
}
setError(err instanceof ApiError ? err.message : "操作失败");
@@ -84,6 +123,41 @@ export function TicketsPage() {
const currentRole = ROLES.find((r) => r.key === role);
async function createTicket() {
if (!formTitle.trim()) {
setError("工单得有个标题,别人才能知道要办什么");
return;
}
if (!formAssignee) {
setError("先选一个人——工单不指派就等于没人接");
return;
}
setSaving(true);
setError(null);
try {
const created = await api.post<Ticket>(`/workspaces/${wsId}/tickets`, {
title: formTitle.trim(),
body_md: formBody.trim() || null,
assignee_id: formAssignee,
priority: formPriority,
due_at: formDue ? new Date(formDue).toISOString() : null,
});
setCreating(false);
setFormTitle("");
setFormBody("");
setFormAssignee("");
setFormDue("");
setFormPriority(1);
setRole("requested");
toast("工单已开出,对方会收到通知", "success");
setOpenId(created.id);
} catch (err) {
setError(err instanceof ApiError ? err.message : "开单失败");
} finally {
setSaving(false);
}
}
return (
<AppShell wsId={wsId} active="tickets">
<PageHeader
@@ -91,18 +165,24 @@ export function TicketsPage() {
desc="待接 → 办理中 → 待验收 → 完成。验收通过后,当初卡住的那条任务会自动回到待办。"
eyebrow={<>需要别人搭把手的事,都在这里</>}
actions={
<select
className="input h-9 w-36 text-sm"
value={status}
onChange={(e) => setStatus(e.target.value)}
aria-label="按状态筛选"
>
{STATUS_FILTERS.map((s) => (
<option key={s.key} value={s.key}>
{s.label}
</option>
))}
</select>
<>
<select
className="input h-9 w-36 text-sm"
value={status}
onChange={(e) => setStatus(e.target.value)}
aria-label="按状态筛选"
>
{STATUS_FILTERS.map((s) => (
<option key={s.key} value={s.key}>
{s.label}
</option>
))}
</select>
<Button variant="primary" size="sm" onClick={() => setCreating(true)}>
<Plus size={14} />
新建工单
</Button>
</>
}
/>
@@ -218,11 +298,118 @@ export function TicketsPage() {
</div>
)}
{/* 手工开单:不经过任务,直接把一件要别人帮忙的事开成工单 */}
<Sheet
open={creating}
onClose={() => setCreating(false)}
size="xl"
title="新建工单"
subtitle="不是从任务升级来的也可以——写清楚要办什么,指定一个人。"
footer={
<>
<Button variant="ghost" onClick={() => setCreating(false)} disabled={saving}>
取消
</Button>
<Button variant="primary" onClick={() => void createTicket()} disabled={saving}>
{saving ? "正在开单…" : "开单并通知对方"}
</Button>
</>
}
>
<div className="space-y-5">
<div>
<label className="label" htmlFor="ticket-title">
要办什么
</label>
<input
id="ticket-title"
className="input"
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
placeholder="一句能直接看懂的话,例如:核对 9 月发票与报销单"
maxLength={200}
autoFocus
/>
</div>
<div>
<label className="label" htmlFor="ticket-body">
详细说明(可选)
</label>
<textarea
id="ticket-body"
className="input min-h-[96px] resize-y text-sm leading-6"
value={formBody}
onChange={(e) => setFormBody(e.target.value)}
placeholder="背景、已有的线索、验收标准…对方看到的就是这些"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="label" htmlFor="ticket-assignee">
交给谁
</label>
<select
id="ticket-assignee"
className="input"
value={formAssignee}
onChange={(e) => setFormAssignee(e.target.value)}
>
<option value="">选择一个人…</option>
{members.map((m) => (
<option key={m.user_id} value={m.user_id}>
{m.nickname || m.user_id.slice(0, 8)}
</option>
))}
</select>
</div>
<div>
<label className="label" htmlFor="ticket-priority">
优先级
</label>
<select
id="ticket-priority"
className="input"
value={formPriority}
onChange={(e) => setFormPriority(Number(e.target.value))}
>
<option value={0}>无</option>
<option value={1}>低</option>
<option value={2}>中</option>
<option value={3}>高</option>
</select>
</div>
</div>
<div>
<label className="label" htmlFor="ticket-due">
希望什么时候办好(可选)
</label>
<input
id="ticket-due"
type="datetime-local"
className="input"
value={formDue}
onChange={(e) => setFormDue(e.target.value)}
/>
</div>
<p className="rounded-xl border border-line-200 bg-paper-200/60 px-3.5 py-2.5 text-2xs leading-5 text-ink-500">
开出后对方会收到通知,并按「待接 → 办理中 → 待验收 → 完成」推进。
手工开的单没有来源任务,验收通过后不会回流任何任务,但同样会沉淀一份知识草稿。
</p>
</div>
</Sheet>
{openId && (
<TaskDetail
wsId={wsId}
itemId={openId}
onClose={() => setOpenId(null)}
onClose={() => {
setOpenId(null);
if (searchParams.get("open")) setSearchParams({}, { replace: true });
}}
onChanged={(updated: Item) =>
setTickets((prev) => prev.map((t) => (t.id === updated.id ? { ...t, ...updated } : t)))
}