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
56 changed files with 4383 additions and 244 deletions
+81 -10
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,7 @@ Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;
**工单验收通过时,后端会自动调用这条逻辑**(`generated_by=rule`,`doc_type=postmortem`,
正文开头固定一句"这份草稿由系统根据来源自动整理,请先核对再发布"),所以用户不需要记得点。
### 5.7 检索 `GET /knowledge/search`
### 5.8 检索 `GET /knowledge/search`
Query:
| 参数 | 说明 |
@@ -402,7 +466,7 @@ Query:
2. **结果必须带来源**,能点回原文。这是 D3(知识必须有来源)的延伸:不给"出处不存在"的答案。
3. **宁可返回空,也不凑数**。检索失败比误导便宜。
发布(`5.5` 的 `publish`)时会自动重建索引,用户发布完立刻能搜到;
发布(`5.6` 的 `publish`)时会自动重建索引,用户发布完立刻能搜到;
但改完正文不会自动重建,需要调用下面这条。
### `POST /knowledge/{item_id}/reindex` — 重建切片
@@ -415,7 +479,7 @@ Query:
将来换 pgvector 只改列类型与索引,接口不变。换 embedding 模型(`EMBEDDING_PROVIDER`)后
**必须整体重建**,否则新旧向量混在一张表里,分数没法比较。
### 5.8 知识的错误码
### 5.9 知识的错误码
| 码 | HTTP | 含义 |
|---|---|---|
@@ -424,6 +488,9 @@ Query:
| 7003 | 409 | 当前状态不允许这个动作 |
| 7004 | 403 | 权限不足(如非管理员发布) |
| 7005 | 409 | 来源事项无效或缺失 |
| 7006 | 404 | 分类不存在,或不是本工作区的 |
| 7007 | 422 | 分类层级超过三级,或移动后会让子树超过三级 |
| 7008 | 409 | 分类重名 / 还有子分类 / 分类下还有知识(需 `force`) |
---
@@ -450,8 +517,12 @@ Query:
附件对象的字段:`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 | 别人占满连接或内存会波及本站 | 独立实例 |
+20 -4
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 通知真实投递
@@ -279,11 +291,15 @@
| 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] 非管理员发布被拒;跨租户看不到别人的知识
- [x] 知识是备忘录:标题、正文、标签、三级分类、搜索;新建时就能把这些一次填完(Web 与安卓一致)
- [x] 切片与语义检索跑通(`real[]` + SQL 余弦;pgvector 仍待独立实例)
- [ ] 真实用两周:新同事靠知识库自己解决过一次问题(需要真实团队)
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

+1
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)
## 技术栈
@@ -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,6 +72,8 @@ 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) }
// 编辑器保存成功的那条:回到工作区后重新打开它的详情,然后清掉
@@ -105,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)
}
@@ -184,7 +187,12 @@ private fun AppRoot(joinUri: String?) {
onReopened = { reopenItem = null },
)
WorkspaceTab.Tickets -> TicketsScreen(api = api, workspace = ws)
WorkspaceTab.Tickets -> TicketsScreen(
api = api,
workspace = ws,
focusId = reopenItem,
onFocusHandled = { reopenItem = null },
)
WorkspaceTab.Knowledge -> KnowledgeScreen(
api = api,
workspace = ws,
@@ -194,6 +202,18 @@ private fun AppRoot(joinUri: String?) {
)
WorkspaceTab.Members -> MembersScreen(api = api, workspace = ws)
WorkspaceTab.Notifications -> NotificationsScreen(
api = api,
workspaceId = ws.id,
onOpenItem = { itemId, isTicket ->
// 通知点开后跳到对应列表并展开那一条;通知本身不带数据,页面自己回源
reopenItem = itemId
reopenKnowledge = false
tab = if (isTicket) WorkspaceTab.Tickets else WorkspaceTab.Tasks
},
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,13 +62,26 @@ 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(
@@ -66,12 +95,18 @@ fun KnowledgeScreen(
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
@@ -81,7 +116,15 @@ fun KnowledgeScreen(
"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
@@ -90,7 +133,8 @@ fun KnowledgeScreen(
loading = false
}
LaunchedEffect(tab) { reload() }
LaunchedEffect(tab, catFilter) { reload() }
LaunchedEffect(Unit) { loadCategories() }
// 从编辑器保存回来:本页是新建出来的,刷完列表再把那条详情重新打开。
LaunchedEffect(reopenItem) {
@@ -125,11 +169,30 @@ fun KnowledgeScreen(
}
}
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 })
}
}
@@ -141,7 +204,7 @@ fun KnowledgeScreen(
if (!loading && docs.isEmpty()) {
item {
EmptyState(
"还没有待核对的知识",
if (catFilter == null) "还没有待核对的知识" else "这个分类下还没有知识",
"把工单验收通过,或在任务详情里点「沉淀成知识」",
)
}
@@ -165,6 +228,18 @@ fun KnowledgeScreen(
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,
@@ -182,68 +257,44 @@ fun KnowledgeScreen(
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) },
)
}
}
@@ -257,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 "归档")
@@ -292,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 = {
@@ -312,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,
),
@@ -320,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 {
@@ -343,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)
}
}
}
}
}
}
}
}
@@ -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)
+3 -1
View File
@@ -21,6 +21,8 @@ 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
@@ -82,7 +84,7 @@ 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())",
"GRANT SELECT, INSERT, UPDATE, DELETE ON chunk TO dadao_app",
f"GRANT SELECT, INSERT, UPDATE, DELETE ON chunk TO {APP_ROLE}",
)
@@ -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),
+350 -8
View File
@@ -21,15 +21,21 @@ 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
@@ -47,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):
@@ -55,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
@@ -112,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,
@@ -120,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"])
@@ -148,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,
@@ -165,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(
@@ -188,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)
@@ -204,6 +336,207 @@ 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="检索已发布知识")
@@ -257,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,
@@ -278,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)
@@ -310,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)
@@ -344,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()
+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(
+13 -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
@@ -100,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"
@@ -121,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))
+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")
+36 -1
View File
@@ -39,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 一比一。"""
@@ -47,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",
@@ -72,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))
@@ -109,4 +144,4 @@ class Chunk(Base):
embed_model: Mapped[str | None] = mapped_column(String(64))
KNOWLEDGE_TENANT_TABLES: tuple[str, ...] = ("knowledge_doc", "chunk")
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")
+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
+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
+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
+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);
+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 ? (
+29
View File
@@ -150,8 +150,25 @@ 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;
@@ -170,3 +187,15 @@ export interface SearchHit {
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;
}
+547 -68
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, SearchHit } from "../lib/types";
import type {
Attachment,
Knowledge,
KnowledgeCategoryNode,
KnowledgeStatus,
SearchHit,
} from "../lib/types";
const TABS = [
{ key: "draft", label: "待核对", statuses: ["draft", "in_review"] },
@@ -61,12 +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 / 改搜索会连发请求,慢的回来会盖掉新结果
@@ -78,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[];
@@ -94,7 +138,7 @@ export function KnowledgePage() {
if (mine === reqId.current) setLoading(false);
}
},
[wsId, tab, q],
[wsId, tab, q, catFilter],
);
useEffect(() => {
@@ -127,22 +171,13 @@ export function KnowledgePage() {
}
}
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 : "保存失败");
}
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;
@@ -176,7 +211,7 @@ export function KnowledgePage() {
<Search size={14} />
{searching ? "检索中…" : "检索"}
</Button>
<Button variant="primary" onClick={() => setAdding((v) => !v)}>
<Button variant="primary" onClick={() => setComposing(true)}>
<Plus size={14} />
记一条
</Button>
@@ -184,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;
@@ -208,34 +255,6 @@ 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>
@@ -327,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}
@@ -339,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)}>
打开
@@ -355,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;
}) {
@@ -384,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(() => {
@@ -394,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();
@@ -419,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);
@@ -426,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 : "保存失败");
}
@@ -448,7 +896,7 @@ function KnowledgeDrawer({
onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : "操作失败");
await load();
await load(true);
} finally {
setBusy(false);
}
@@ -472,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={
@@ -543,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)))
}