Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a118ad407 | ||
|
|
ea5d28304e | ||
|
|
57767c77a1 | ||
|
|
9ac3b46430 | ||
|
|
2dbe4cf675 | ||
|
|
7c84ff3b8d | ||
|
|
ffae7e380f | ||
|
|
9a382e4e8c | ||
|
|
ed31af3f46 | ||
|
|
91b3dde670 |
+81
-10
@@ -227,6 +227,20 @@ canceled→ todo (恢复)
|
|||||||
工单**不是**另一张表里的另一种东西,它是一条 `kind='ticket'` 的 item 加一行 `ticket_ext`。
|
工单**不是**另一张表里的另一种东西,它是一条 `kind='ticket'` 的 item 加一行 `ticket_ext`。
|
||||||
所以详情仍然走 `GET /items/{item_id}`,响应里多一个 `ticket` 字段。
|
所以详情仍然走 `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`(任务升级成工单)
|
### 4.1 `POST /items/{item_id}/promote`(任务升级成工单)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -286,8 +300,26 @@ canceled→ todo (恢复)
|
|||||||
| POST | `/notifications/{id}/read` | 标一条 |
|
| POST | `/notifications/{id}/read` | 标一条 |
|
||||||
| POST | `/notifications/read-all` | 全标已读 |
|
| 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 工单的错误码
|
### 4.6 工单的错误码
|
||||||
|
|
||||||
@@ -310,22 +342,30 @@ canceled→ todo (恢复)
|
|||||||
| `status` | 可重复;`draft`/`in_review`/`published`/`archived`,不传为全部 |
|
| `status` | 可重复;`draft`/`in_review`/`published`/`archived`,不传为全部 |
|
||||||
| `doc_type` | `note`/`howto`/`faq`/`postmortem`/`decision` |
|
| `doc_type` | `note`/`howto`/`faq`/`postmortem`/`decision` |
|
||||||
| `source_item_id` | 按来源事项筛 |
|
| `source_item_id` | 按来源事项筛 |
|
||||||
|
| `category_id` | 按分类筛,**包含子分类** |
|
||||||
|
| `uncategorized` | `true` 只看未分类 |
|
||||||
|
| `tag` | 按标签名筛(不区分大小写) |
|
||||||
| `q` | 标题 / 正文 / 摘要子串 |
|
| `q` | 标题 / 正文 / 摘要子串 |
|
||||||
| `limit` | 1–100,默认 30 |
|
| `limit` | 1–100,默认 30 |
|
||||||
| `cursor` | 上一页的 `next_cursor` |
|
| `cursor` | 上一页的 `next_cursor` |
|
||||||
|
|
||||||
返回 `{items, next_cursor, has_more}`。每条是 `item` 字段 + `knowledge` + `source`:
|
返回 `{items, next_cursor, has_more}`。每条是 `item` 字段 + `knowledge` + `category` + `source`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "…", "kind": "knowledge", "title": "发票原件必须一起寄",
|
"id": "…", "kind": "knowledge", "title": "发票原件必须一起寄",
|
||||||
"body_md": "…", "status": "published", "tags": [],
|
"body_md": "…", "status": "published", "tags": [{"id": "…", "name": "财务"}],
|
||||||
"knowledge": {
|
"knowledge": {
|
||||||
"doc_type": "howto", "summary": "…", "status_hint": "published",
|
"doc_type": "howto", "summary": "…", "status_hint": "published",
|
||||||
"source_kind": "task", "source_item_id": "…", "generated_by": "human",
|
"source_kind": "task", "source_item_id": "…", "generated_by": "human",
|
||||||
"published_at": "2026-10-28T05:54:44Z", "archived_at": null,
|
"published_at": "2026-10-28T05:54:44Z", "archived_at": null,
|
||||||
"chunk_count": 0, "author_id": "…"
|
"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"}
|
"source": {"id": "…", "kind": "task", "title": "发票原件必须一起寄", "status": "done"}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -342,19 +382,43 @@ canceled→ todo (恢复)
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{"title": "报销单要贴发票原件", "body_md": "…", "doc_type": "note",
|
{"title": "报销单要贴发票原件", "body_md": "…", "doc_type": "note",
|
||||||
|
"tag_names": ["财务", "报销"], "category_id": "可选的分类 id",
|
||||||
"source_item_id": "可选的来源事项 id"}
|
"source_item_id": "可选的来源事项 id"}
|
||||||
```
|
```
|
||||||
|
|
||||||
- 不带 `source_item_id` → `source_kind=manual`(手记),允许没有来源。
|
- 不带 `source_item_id` → `source_kind=manual`(手记),允许没有来源。
|
||||||
- 带 → 来源必须是同工作区的 `task`/`ticket`,否则 `7005`。
|
- 带 → 来源必须是同工作区的 `task`/`ticket`,否则 `7005`。
|
||||||
|
- `category_id` 可省(未分类);跨工作区的分类一律按不存在处理(`7006`)。
|
||||||
|
- `tag_names` 按名字去重创建,空格/逗号分隔由前端拆好再传。
|
||||||
- 新知识一律 `draft`,不会直接对外可见。
|
- 新知识一律 `draft`,不会直接对外可见。
|
||||||
|
|
||||||
### 5.4 `PATCH /knowledge/{item_id}` — 修改内容
|
### 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`,先撤回审议或归档),避免"发布后被悄悄改写"。
|
**已发布的不许直接改**(`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 | 允许的前置状态 | 结果 | 谁能做 |
|
| action | 允许的前置状态 | 结果 | 谁能做 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -369,7 +433,7 @@ canceled→ todo (恢复)
|
|||||||
|
|
||||||
**发布要 owner/admin 的理由**:发布等于团队为这条结论背书,不能谁都能推。
|
**发布要 owner/admin 的理由**:发布等于团队为这条结论背书,不能谁都能推。
|
||||||
|
|
||||||
### 5.6 `POST /items/{item_id}/knowledge` — 把一件事沉淀成知识
|
### 5.7 `POST /items/{item_id}/knowledge` — 把一件事沉淀成知识
|
||||||
|
|
||||||
Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;工单会带出验收结论。
|
Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;工单会带出验收结论。
|
||||||
返回与 5.2 相同的知识对象;同一来源重复沉淀 → `7002`。
|
返回与 5.2 相同的知识对象;同一来源重复沉淀 → `7002`。
|
||||||
@@ -377,7 +441,7 @@ Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;
|
|||||||
**工单验收通过时,后端会自动调用这条逻辑**(`generated_by=rule`,`doc_type=postmortem`,
|
**工单验收通过时,后端会自动调用这条逻辑**(`generated_by=rule`,`doc_type=postmortem`,
|
||||||
正文开头固定一句"这份草稿由系统根据来源自动整理,请先核对再发布"),所以用户不需要记得点。
|
正文开头固定一句"这份草稿由系统根据来源自动整理,请先核对再发布"),所以用户不需要记得点。
|
||||||
|
|
||||||
### 5.7 检索 `GET /knowledge/search`
|
### 5.8 检索 `GET /knowledge/search`
|
||||||
|
|
||||||
Query:
|
Query:
|
||||||
| 参数 | 说明 |
|
| 参数 | 说明 |
|
||||||
@@ -402,7 +466,7 @@ Query:
|
|||||||
2. **结果必须带来源**,能点回原文。这是 D3(知识必须有来源)的延伸:不给"出处不存在"的答案。
|
2. **结果必须带来源**,能点回原文。这是 D3(知识必须有来源)的延伸:不给"出处不存在"的答案。
|
||||||
3. **宁可返回空,也不凑数**。检索失败比误导便宜。
|
3. **宁可返回空,也不凑数**。检索失败比误导便宜。
|
||||||
|
|
||||||
发布(`5.5` 的 `publish`)时会自动重建索引,用户发布完立刻能搜到;
|
发布(`5.6` 的 `publish`)时会自动重建索引,用户发布完立刻能搜到;
|
||||||
但改完正文不会自动重建,需要调用下面这条。
|
但改完正文不会自动重建,需要调用下面这条。
|
||||||
|
|
||||||
### `POST /knowledge/{item_id}/reindex` — 重建切片
|
### `POST /knowledge/{item_id}/reindex` — 重建切片
|
||||||
@@ -415,7 +479,7 @@ Query:
|
|||||||
将来换 pgvector 只改列类型与索引,接口不变。换 embedding 模型(`EMBEDDING_PROVIDER`)后
|
将来换 pgvector 只改列类型与索引,接口不变。换 embedding 模型(`EMBEDDING_PROVIDER`)后
|
||||||
**必须整体重建**,否则新旧向量混在一张表里,分数没法比较。
|
**必须整体重建**,否则新旧向量混在一张表里,分数没法比较。
|
||||||
|
|
||||||
### 5.8 知识的错误码
|
### 5.9 知识的错误码
|
||||||
|
|
||||||
| 码 | HTTP | 含义 |
|
| 码 | HTTP | 含义 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -424,6 +488,9 @@ Query:
|
|||||||
| 7003 | 409 | 当前状态不允许这个动作 |
|
| 7003 | 409 | 当前状态不允许这个动作 |
|
||||||
| 7004 | 403 | 权限不足(如非管理员发布) |
|
| 7004 | 403 | 权限不足(如非管理员发布) |
|
||||||
| 7005 | 409 | 来源事项无效或缺失 |
|
| 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`。
|
附件对象的字段:`id / item_id / filename / content_type / size / sha256 / uploader_id / created_at / raw_url / can_delete`。
|
||||||
`raw_url` 是**后端签发的短期直链**(默认 30 分钟),客户端直接用它渲染,不需要自己拼令牌。
|
`raw_url` 是**后端签发的短期直链**(默认 30 分钟),客户端直接用它渲染,不需要自己拼令牌。
|
||||||
|
附件可挂在 `kind` 为 `task / ticket / knowledge` 的事项上(`ALLOWED_KINDS`),知识与任务、工单共用同一套接口;
|
||||||
事项与知识的列表接口会附带 `attachment_count`,详情接口附带 `attachments` 全量。
|
事项与知识的列表接口会附带 `attachment_count`,详情接口附带 `attachments` 全量。
|
||||||
|
|
||||||
|
新建知识时条目还不存在,前端用「懒建草稿」:点上传(或往正文里贴图)时先 `POST /knowledge` 存一条草稿拿到 `item_id`,
|
||||||
|
再上传到 `/items/{item_id}/attachments`,之后的保存一律走 `PATCH /knowledge/{item_id}`。没传过附件就取消的人,库里不会留下空条目。
|
||||||
|
|
||||||
### 6.2 图片直链:短期签名,而不是把令牌写进正文
|
### 6.2 图片直链:短期签名,而不是把令牌写进正文
|
||||||
|
|
||||||
`<img>` 发不出 `Authorization` 头。两种解法:把图片读成 base64 内联(正文膨胀几十倍,破坏检索),
|
`<img>` 发不出 `Authorization` 头。两种解法:把图片读成 base64 内联(正文膨胀几十倍,破坏检索),
|
||||||
|
|||||||
@@ -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 | 别人占满连接或内存会波及本站 | 独立实例 |
|
||||||
@@ -189,8 +189,8 @@
|
|||||||
- [ ] 下拉刷新、只读缓存(Room)未做
|
- [ ] 下拉刷新、只读缓存(Room)未做
|
||||||
|
|
||||||
### 1.6 通知与提醒(0.8d)
|
### 1.6 通知与提醒(0.8d)
|
||||||
- [ ] 站内通知表与未读角标 —— **主动推迟到 M2**:通知的绝大多数触发源是工单(被指派、被催、验收),
|
- [x] 站内通知表与未读角标 —— 随 M2 一起落地,七类触发(指派、改状态、评论、工单全流程);
|
||||||
M1 只有自己的任务,做出来也没人收到东西。表随 M2 的工单一起建,避免建两次。
|
未读角标 30 秒轮询,标已读立刻刷新。原计划"推迟到 M2",M2 已兑现。
|
||||||
- [ ] 每日摘要邮件(同上推迟)
|
- [ ] 每日摘要邮件(同上推迟)
|
||||||
|
|
||||||
### 1.7 测试与验收(0.7d)
|
### 1.7 测试与验收(0.7d)
|
||||||
@@ -230,6 +230,8 @@
|
|||||||
- [x] `POST /items/{id}/promote`、`/ticket/{accept,start,submit,verify,reassign,cancel}`
|
- [x] `POST /items/{id}/promote`、`/ticket/{accept,start,submit,verify,reassign,cancel}`
|
||||||
- [x] `GET /tickets?role=assigned|requested|all&status=`
|
- [x] `GET /tickets?role=assigned|requested|all&status=`
|
||||||
- [x] `GET|POST /items/{id}/links`、`DELETE /items/{id}/links/{link_id}`
|
- [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] `GET /notifications`、`/unread-count`、`/read`、`/read-all`
|
||||||
- [x] 抽出 `app/serializers.py`:任务与工单共用一份序列化,**根除"工单响应缺 tags 导致前端白屏"**
|
- [x] 抽出 `app/serializers.py`:任务与工单共用一份序列化,**根除"工单响应缺 tags 导致前端白屏"**
|
||||||
|
|
||||||
@@ -238,17 +240,27 @@
|
|||||||
- [x] 详情抽屉:按角色显示受理、开工、提交、验收、驳回、改派、取消;工单态顶部显示需求人/办理人/退回次数
|
- [x] 详情抽屉:按角色显示受理、开工、提交、验收、驳回、改派、取消;工单态顶部显示需求人/办理人/退回次数
|
||||||
- [x] 任务详情底部"升级成工单"面板(从成员里选人,成功后提示"这条任务现在处于被阻塞")
|
- [x] 任务详情底部"升级成工单"面板(从成员里选人,成功后提示"这条任务现在处于被阻塞")
|
||||||
- [x] 导航加未读角标(30 秒对齐一次,操作后立刻刷新)
|
- [x] 导航加未读角标(30 秒对齐一次,操作后立刻刷新)
|
||||||
|
- [x] 通知页 `/w/:wsId/notifications`:未读 / 全部两档,点开即标已读并跳到目标;深链 `?open=` 落地即展开那一条
|
||||||
|
- [x] 工单页"新建"面板:手工开单(选人、优先级、说明),成功切到"我发起的"并展开
|
||||||
- [x] 登录支持 `?next=` 回跳,邀请链接不再"登录完掉回首页"
|
- [x] 登录支持 `?next=` 回跳,邀请链接不再"登录完掉回首页"
|
||||||
|
|
||||||
### 2.5 安卓(1.5d)
|
### 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)
|
### 2.6 测试与验收(0.7d)
|
||||||
- [x] `backend/tests/test_tickets.py` 11 个用例:升级建单+原任务阻塞+通知、全流程验收回流、驳回、
|
- [x] `backend/tests/test_tickets.py` 11 个用例:升级建单+原任务阻塞+通知、全流程验收回流、驳回、
|
||||||
自验 403、非法流转、重复升级、非成员指派、角色作用域、跨租户、关系挂载、通知已读、**响应必带 tags**
|
自验 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 项断言全绿:
|
- [x] `web/e2e/tickets.mjs` 双浏览器上下文双用户 e2e,8 项断言全绿:
|
||||||
升级 → B 受理/开工/提交 → A 验收通过 → 原任务回流"待办" → 两端控制台零报错
|
升级 → B 受理/开工/提交 → A 验收通过 → 原任务回流"待办" → 两端控制台零报错
|
||||||
|
- [x] `web/e2e/notifications.mjs` 12 项断言全绿:A 建工作区邀 B → B 自动加入 → A 手工开单 →
|
||||||
|
这张单没有来源任务 → B 收到"派给你的工单" → 通知列表有条目 → 点通知跳到工单并展开 →
|
||||||
|
未读数归零 → 两端控制台零报错
|
||||||
- [x] `web/e2e/smoke.mjs` 回归复跑(抽屉多了"选人"下拉后修正选择器)7 项全绿
|
- [x] `web/e2e/smoke.mjs` 回归复跑(抽屉多了"选人"下拉后修正选择器)7 项全绿
|
||||||
|
|
||||||
### 2.7 通知真实投递
|
### 2.7 通知真实投递
|
||||||
@@ -279,11 +291,15 @@
|
|||||||
| 3.8.4 | 索引生命周期:发布自动建、改正文需 `reindex`、换模型整体重建 | 0.5 | [x] `POST /knowledge/{id}/reindex` |
|
| 3.8.4 | 索引生命周期:发布自动建、改正文需 `reindex`、换模型整体重建 | 0.5 | [x] `POST /knowledge/{id}/reindex` |
|
||||||
| 3.9 | 安卓知识列表 | 1.0 | [x] 只读列表 + 详情动作(编辑正文走安卓编辑器) |
|
| 3.9 | 安卓知识列表 | 1.0 | [x] 只读列表 + 详情动作(编辑正文走安卓编辑器) |
|
||||||
| 3.10 | 文档与 e2e | 1.0 | [x] `knowledge.mjs` 7 项 + `retrieval.mjs` 6 项 |
|
| 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)
|
### M3 完成定义(DOD)
|
||||||
- [x] 一件事办完 → 自动出现一份草稿 → 核对 → 发布,全程不用手动复制粘贴
|
- [x] 一件事办完 → 自动出现一份草稿 → 核对 → 发布,全程不用手动复制粘贴
|
||||||
- [x] 每条知识都能追到来源(除手记),点开能看到来源事项
|
- [x] 每条知识都能追到来源(除手记),点开能看到来源事项
|
||||||
- [x] 非管理员发布被拒;跨租户看不到别人的知识
|
- [x] 非管理员发布被拒;跨租户看不到别人的知识
|
||||||
|
- [x] 知识是备忘录:标题、正文、标签、三级分类、搜索;新建时就能把这些一次填完(Web 与安卓一致)
|
||||||
- [x] 切片与语义检索跑通(`real[]` + SQL 余弦;pgvector 仍待独立实例)
|
- [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 |
@@ -6,6 +6,7 @@
|
|||||||
- M0–M1 实施清单(含真实进度):[`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md)
|
- M0–M1 实施清单(含真实进度):[`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md)
|
||||||
- 接口约定(已实现部分):[`2.Design/API.md`](2.Design/API.md)
|
- 接口约定(已实现部分):[`2.Design/API.md`](2.Design/API.md)
|
||||||
- 本地开发:[`2.Design/DEVELOPMENT.md`](2.Design/DEVELOPMENT.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)
|
- 中文命名方案:[`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.KnowledgeScreen
|
||||||
import com.dadao.task.ui.LoginScreen
|
import com.dadao.task.ui.LoginScreen
|
||||||
import com.dadao.task.ui.MembersScreen
|
import com.dadao.task.ui.MembersScreen
|
||||||
|
import com.dadao.task.ui.NotificationsScreen
|
||||||
import com.dadao.task.ui.RichTextEditorScreen
|
import com.dadao.task.ui.RichTextEditorScreen
|
||||||
import com.dadao.task.ui.TaskListScreen
|
import com.dadao.task.ui.TaskListScreen
|
||||||
import com.dadao.task.ui.TicketsScreen
|
import com.dadao.task.ui.TicketsScreen
|
||||||
@@ -71,6 +72,8 @@ private fun AppRoot(joinUri: String?) {
|
|||||||
var current by remember { mutableStateOf<Workspace?>(null) }
|
var current by remember { mutableStateOf<Workspace?>(null) }
|
||||||
var tab by remember { mutableStateOf(WorkspaceTab.Tasks) }
|
var tab by remember { mutableStateOf(WorkspaceTab.Tasks) }
|
||||||
var unread by remember { mutableStateOf(0) }
|
var unread by remember { mutableStateOf(0) }
|
||||||
|
// 通知页标了已读之后要立刻刷新角标:改这个数会触发下面的 LaunchedEffect
|
||||||
|
var unreadRefresh by remember { mutableStateOf(0) }
|
||||||
// 打开富文本编辑器时非空;关掉就恢复原来的界面
|
// 打开富文本编辑器时非空;关掉就恢复原来的界面
|
||||||
var editing by remember { mutableStateOf<EditRequest?>(null) }
|
var editing by remember { mutableStateOf<EditRequest?>(null) }
|
||||||
// 编辑器保存成功的那条:回到工作区后重新打开它的详情,然后清掉
|
// 编辑器保存成功的那条:回到工作区后重新打开它的详情,然后清掉
|
||||||
@@ -105,8 +108,8 @@ private fun AppRoot(joinUri: String?) {
|
|||||||
.onFailure { pendingJoin = null }
|
.onFailure { pendingJoin = null }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 工单未读数:进工作区就拉一次,切页面时再刷。
|
// 未读数:进工作区、切页面、或通知页标了已读(unreadRefresh)时刷新
|
||||||
LaunchedEffect(current?.id, tab) {
|
LaunchedEffect(current?.id, tab, unreadRefresh) {
|
||||||
val ws = current ?: return@LaunchedEffect
|
val ws = current ?: return@LaunchedEffect
|
||||||
unread = api.unreadCount(ws.id)
|
unread = api.unreadCount(ws.id)
|
||||||
}
|
}
|
||||||
@@ -184,7 +187,12 @@ private fun AppRoot(joinUri: String?) {
|
|||||||
onReopened = { reopenItem = null },
|
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(
|
WorkspaceTab.Knowledge -> KnowledgeScreen(
|
||||||
api = api,
|
api = api,
|
||||||
workspace = ws,
|
workspace = ws,
|
||||||
@@ -194,6 +202,18 @@ private fun AppRoot(joinUri: String?) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
WorkspaceTab.Members -> MembersScreen(api = api, workspace = ws)
|
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 = "",
|
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
|
@Serializable
|
||||||
data class KnowledgeDoc(
|
data class KnowledgeDoc(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -245,6 +265,7 @@ data class KnowledgeDoc(
|
|||||||
val version: Int = 1,
|
val version: Int = 1,
|
||||||
val tags: List<Tag> = emptyList(),
|
val tags: List<Tag> = emptyList(),
|
||||||
val knowledge: KnowledgeInfo = KnowledgeInfo(),
|
val knowledge: KnowledgeInfo = KnowledgeInfo(),
|
||||||
|
val category: KnowledgeCategory? = null,
|
||||||
val source: SourceRef? = null,
|
val source: SourceRef? = null,
|
||||||
val attachments: List<Attachment> = emptyList(),
|
val attachments: List<Attachment> = emptyList(),
|
||||||
)
|
)
|
||||||
@@ -258,6 +279,25 @@ data class KnowledgePage(
|
|||||||
|
|
||||||
// ---- 请求体。每个都要 @Serializable,否则 encodeToString 找不到序列化器 ----
|
// ---- 请求体。每个都要 @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
|
@Serializable
|
||||||
private data class UnreadCount(@SerialName("unread") val unread: Int = 0)
|
private data class UnreadCount(@SerialName("unread") val unread: Int = 0)
|
||||||
|
|
||||||
@@ -306,11 +346,22 @@ private data class KnowledgeCreateRequest(
|
|||||||
val title: String,
|
val title: String,
|
||||||
@SerialName("body_md") val bodyMd: String,
|
@SerialName("body_md") val bodyMd: String,
|
||||||
@SerialName("doc_type") val docType: String,
|
@SerialName("doc_type") val docType: String,
|
||||||
|
@SerialName("tag_names") val tagNames: List<String> = emptyList(),
|
||||||
|
@SerialName("category_id") val categoryId: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
private data class KnowledgeActionRequest(val action: String)
|
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
|
@Serializable
|
||||||
private data class ItemUpdateRequest(
|
private data class ItemUpdateRequest(
|
||||||
val version: Int,
|
val version: Int,
|
||||||
@@ -323,6 +374,10 @@ private data class KnowledgeUpdateRequest(
|
|||||||
val version: Int,
|
val version: Int,
|
||||||
val title: String? = null,
|
val title: String? = null,
|
||||||
@SerialName("body_md") val bodyMd: 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,
|
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,不要因为角标让整页报错。 */
|
/** 未读工单通知数,导航角标用。失败就当 0,不要因为角标让整页报错。 */
|
||||||
suspend fun unreadCount(workspaceId: String): Int = runCatching {
|
suspend fun unreadCount(workspaceId: String): Int = runCatching {
|
||||||
call<UnreadCount>(path = "/workspaces/$workspaceId/notifications/unread-count", auth = true).unread
|
call<UnreadCount>(path = "/workspaces/$workspaceId/notifications/unread-count", auth = true).unread
|
||||||
@@ -596,20 +693,39 @@ class Api(private val tokens: TokenStore) {
|
|||||||
workspaceId: String,
|
workspaceId: String,
|
||||||
statuses: List<String> = listOf("draft", "in_review"),
|
statuses: List<String> = listOf("draft", "in_review"),
|
||||||
query: String? = null,
|
query: String? = null,
|
||||||
|
categoryId: String? = null,
|
||||||
|
uncategorized: Boolean = false,
|
||||||
): KnowledgePage {
|
): KnowledgePage {
|
||||||
val params = buildString {
|
val params = buildString {
|
||||||
append("?limit=30")
|
append("?limit=30")
|
||||||
statuses.forEach { append("&status=").append(it) }
|
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)
|
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(
|
call(
|
||||||
path = "/workspaces/$workspaceId/knowledge",
|
path = "/workspaces/$workspaceId/knowledge",
|
||||||
body = json.encodeToString(KnowledgeCreateRequest(title, body, "note")),
|
body = json.encodeToString(
|
||||||
|
KnowledgeCreateRequest(title, body, docType, tags, categoryId),
|
||||||
|
),
|
||||||
auth = true,
|
auth = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -639,9 +755,14 @@ class Api(private val tokens: TokenStore) {
|
|||||||
version: Int,
|
version: Int,
|
||||||
title: String? = null,
|
title: String? = null,
|
||||||
body: String? = null,
|
body: String? = null,
|
||||||
|
tags: List<String>? = null,
|
||||||
|
categoryId: String? = null,
|
||||||
|
clearCategory: Boolean = false,
|
||||||
): KnowledgeDoc = call(
|
): KnowledgeDoc = call(
|
||||||
path = "/workspaces/$workspaceId/knowledge/$itemId",
|
path = "/workspaces/$workspaceId/knowledge/$itemId",
|
||||||
body = json.encodeToString(KnowledgeUpdateRequest(version, title, body)),
|
body = json.encodeToString(
|
||||||
|
KnowledgeUpdateRequest(version, title, body, tags, categoryId, clearCategory),
|
||||||
|
),
|
||||||
auth = true,
|
auth = true,
|
||||||
method = "PATCH",
|
method = "PATCH",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.dadao.task.data.Workspace
|
import com.dadao.task.data.Workspace
|
||||||
|
|
||||||
/** 手机端的五个去处。看板不是独立入口,而是任务页顶部的一个切换(屏幕太窄,塞不下六个 tab)。 */
|
/** 手机端的去处。看板不是独立入口,而是任务页顶部的一个切换(屏幕太窄,塞不下更多 tab)。 */
|
||||||
enum class WorkspaceTab(val label: String) {
|
enum class WorkspaceTab(val label: String) {
|
||||||
Tasks("任务"),
|
Tasks("任务"),
|
||||||
Tickets("工单"),
|
Tickets("工单"),
|
||||||
Knowledge("知识"),
|
Knowledge("知识"),
|
||||||
|
Notifications("通知"),
|
||||||
Members("成员"),
|
Members("成员"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
package com.dadao.task.ui
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -31,13 +40,20 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
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.Api
|
||||||
import com.dadao.task.data.ApiException
|
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.KnowledgeDoc
|
||||||
import com.dadao.task.data.Workspace
|
import com.dadao.task.data.Workspace
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
private val knowledgeTabs = listOf(
|
private val knowledgeTabs = listOf(
|
||||||
"draft" to "待核对",
|
"draft" to "待核对",
|
||||||
@@ -46,13 +62,26 @@ private val knowledgeTabs = listOf(
|
|||||||
"all" to "全部",
|
"all" to "全部",
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun docTypeLabel(type: String): String = when (type) {
|
private val docTypes = listOf(
|
||||||
"howto" -> "怎么做"
|
"note" to "笔记",
|
||||||
"faq" -> "问答"
|
"howto" to "怎么做",
|
||||||
"postmortem" -> "复盘"
|
"faq" to "问答",
|
||||||
"decision" -> "决策"
|
"postmortem" to "复盘",
|
||||||
else -> "笔记"
|
"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
|
@Composable
|
||||||
fun KnowledgeScreen(
|
fun KnowledgeScreen(
|
||||||
@@ -66,12 +95,18 @@ fun KnowledgeScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var tab by remember { mutableStateOf("draft") }
|
var tab by remember { mutableStateOf("draft") }
|
||||||
var docs by remember { mutableStateOf<List<KnowledgeDoc>>(emptyList()) }
|
var docs by remember { mutableStateOf<List<KnowledgeDoc>>(emptyList()) }
|
||||||
|
var categories by remember { mutableStateOf<List<KnowledgeCategory>>(emptyList()) }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
var message by remember { mutableStateOf<String?>(null) }
|
var message by remember { mutableStateOf<String?>(null) }
|
||||||
var open by remember { mutableStateOf<KnowledgeDoc?>(null) }
|
var open by remember { mutableStateOf<KnowledgeDoc?>(null) }
|
||||||
var adding by remember { mutableStateOf(false) }
|
var adding by remember { mutableStateOf(false) }
|
||||||
var newTitle by remember { mutableStateOf("") }
|
// null = 全部;"none" = 未分类;其它是分类 id(服务端含子分类)
|
||||||
var newBody by remember { mutableStateOf("") }
|
var catFilter by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
suspend fun loadCategories() {
|
||||||
|
runCatching { api.knowledgeCategories(workspace.id) }
|
||||||
|
.onSuccess { categories = it.items }
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun reload() {
|
suspend fun reload() {
|
||||||
loading = true
|
loading = true
|
||||||
@@ -81,7 +116,15 @@ fun KnowledgeScreen(
|
|||||||
"all" -> listOf("draft", "in_review", "published", "archived")
|
"all" -> listOf("draft", "in_review", "published", "archived")
|
||||||
else -> listOf("draft", "in_review")
|
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 {
|
.onSuccess {
|
||||||
docs = it.items
|
docs = it.items
|
||||||
message = null
|
message = null
|
||||||
@@ -90,7 +133,8 @@ fun KnowledgeScreen(
|
|||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(tab) { reload() }
|
LaunchedEffect(tab, catFilter) { reload() }
|
||||||
|
LaunchedEffect(Unit) { loadCategories() }
|
||||||
|
|
||||||
// 从编辑器保存回来:本页是新建出来的,刷完列表再把那条详情重新打开。
|
// 从编辑器保存回来:本页是新建出来的,刷完列表再把那条详情重新打开。
|
||||||
LaunchedEffect(reopenItem) {
|
LaunchedEffect(reopenItem) {
|
||||||
@@ -125,11 +169,30 @@ fun KnowledgeScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
GhostButton("记一条", onClick = {
|
Row(
|
||||||
adding = true
|
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||||
newTitle = ""
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
newBody = ""
|
) {
|
||||||
})
|
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()) {
|
if (!loading && docs.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
"还没有待核对的知识",
|
if (catFilter == null) "还没有待核对的知识" else "这个分类下还没有知识",
|
||||||
"把工单验收通过,或在任务详情里点「沉淀成知识」",
|
"把工单验收通过,或在任务详情里点「沉淀成知识」",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -165,6 +228,18 @@ fun KnowledgeScreen(
|
|||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(6.dp))
|
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(
|
Text(
|
||||||
sourceLine(doc),
|
sourceLine(doc),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
@@ -182,68 +257,44 @@ fun KnowledgeScreen(
|
|||||||
api = api,
|
api = api,
|
||||||
workspace = workspace,
|
workspace = workspace,
|
||||||
doc = target,
|
doc = target,
|
||||||
|
categories = categories,
|
||||||
onClose = { open = null },
|
onClose = { open = null },
|
||||||
onEdit = onEdit,
|
onEdit = onEdit,
|
||||||
onChanged = {
|
onChanged = {
|
||||||
scope.launch { reload() }
|
scope.launch {
|
||||||
|
reload()
|
||||||
|
loadCategories()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
onCategoriesChanged = { scope.launch { loadCategories() } },
|
||||||
onFail = { fail(it) },
|
onFail = { fail(it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (adding) {
|
if (adding) {
|
||||||
AlertDialog(
|
KnowledgeComposerDialog(
|
||||||
onDismissRequest = { adding = false },
|
api = api,
|
||||||
title = { Text("记一条", style = MaterialTheme.typography.titleMedium) },
|
workspace = workspace,
|
||||||
text = {
|
categories = categories,
|
||||||
Column {
|
onDismiss = { adding = false },
|
||||||
Text(
|
onCreated = { created ->
|
||||||
"手记没有来源,想沉淀某件具体的事,去那条任务的详情里点「沉淀成知识」。",
|
adding = false
|
||||||
style = MaterialTheme.typography.bodySmall,
|
scope.launch {
|
||||||
color = InkMuted,
|
loadCategories()
|
||||||
)
|
reload()
|
||||||
Spacer(Modifier.height(10.dp))
|
// 正文交给富文本编辑器写:先落一条草稿,再直接进去写,不让用户对着纯文本框凑排版
|
||||||
OutlinedTextField(
|
onEdit(
|
||||||
value = newTitle,
|
EditRequest(
|
||||||
onValueChange = { newTitle = it },
|
itemId = created.id,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
title = created.title,
|
||||||
placeholder = { Text("一句话说清这条知识是什么", color = InkMuted) },
|
body = created.bodyMd,
|
||||||
shape = RoundedCornerShape(10.dp),
|
version = created.version,
|
||||||
colors = TextFieldDefaults.colors(
|
knowledge = true,
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
onFail = { fail(it) },
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
@Composable
|
||||||
private fun KnowledgeDetailDialog(
|
private fun KnowledgeDetailDialog(
|
||||||
api: Api,
|
api: Api,
|
||||||
workspace: Workspace,
|
workspace: Workspace,
|
||||||
doc: KnowledgeDoc,
|
doc: KnowledgeDoc,
|
||||||
|
categories: List<KnowledgeCategory>,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onChanged: () -> Unit,
|
onChanged: () -> Unit,
|
||||||
|
onCategoriesChanged: () -> Unit,
|
||||||
onFail: (Throwable) -> Unit,
|
onFail: (Throwable) -> Unit,
|
||||||
onEdit: (EditRequest) -> Unit,
|
onEdit: (EditRequest) -> Unit,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
var action by remember { mutableStateOf(doc.status) }
|
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) {
|
val actions = when (action) {
|
||||||
"draft" -> listOf("submit_review" to "提交审议", "publish" to "发布", "archive" to "归档")
|
"draft" -> listOf("submit_review" to "提交审议", "publish" to "发布", "archive" to "归档")
|
||||||
"in_review" -> listOf("withdraw" to "撤回审议", "publish" to "发布", "archive" to "归档")
|
"in_review" -> listOf("withdraw" to "撤回审议", "publish" to "发布", "archive" to "归档")
|
||||||
@@ -292,15 +533,105 @@ private fun KnowledgeDetailDialog(
|
|||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
Column(
|
Column(
|
||||||
Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()),
|
Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
|
||||||
) {
|
) {
|
||||||
Text(sourceLine(doc), style = MaterialTheme.typography.labelSmall, color = InkMuted)
|
Text(sourceLine(doc), style = MaterialTheme.typography.labelSmall, color = InkMuted)
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Text(
|
Text(
|
||||||
doc.bodyMd.ifBlank { "(还没有正文,点「补一句」在 Web 端补齐更快)" },
|
doc.bodyMd.ifBlank { "(还没有正文,点「编辑正文」补齐)" },
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = InkSoft,
|
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 = {
|
confirmButton = {
|
||||||
@@ -312,7 +643,7 @@ private fun KnowledgeDetailDialog(
|
|||||||
itemId = doc.id,
|
itemId = doc.id,
|
||||||
title = doc.title,
|
title = doc.title,
|
||||||
body = doc.bodyMd,
|
body = doc.bodyMd,
|
||||||
version = doc.version,
|
version = version,
|
||||||
titleEnabled = action == "draft",
|
titleEnabled = action == "draft",
|
||||||
knowledge = true,
|
knowledge = true,
|
||||||
),
|
),
|
||||||
@@ -320,6 +651,34 @@ private fun KnowledgeDetailDialog(
|
|||||||
onClose()
|
onClose()
|
||||||
},
|
},
|
||||||
) { Text("编辑正文") }
|
) { 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) ->
|
actions.forEach { (key, label) ->
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -343,3 +702,20 @@ private fun KnowledgeDetailDialog(
|
|||||||
containerColor = Paper,
|
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
|
package com.dadao.task.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
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.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -31,6 +33,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.dadao.task.data.Api
|
import com.dadao.task.data.Api
|
||||||
import com.dadao.task.data.ApiException
|
import com.dadao.task.data.ApiException
|
||||||
|
import com.dadao.task.data.Member
|
||||||
import com.dadao.task.data.Ticket
|
import com.dadao.task.data.Ticket
|
||||||
import com.dadao.task.data.Workspace
|
import com.dadao.task.data.Workspace
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -42,7 +45,12 @@ private val ticketRoles = listOf(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TicketsScreen(api: Api, workspace: Workspace) {
|
fun TicketsScreen(
|
||||||
|
api: Api,
|
||||||
|
workspace: Workspace,
|
||||||
|
focusId: String? = null,
|
||||||
|
onFocusHandled: () -> Unit = {},
|
||||||
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var role by remember { mutableStateOf("assigned") }
|
var role by remember { mutableStateOf("assigned") }
|
||||||
var tickets by remember { mutableStateOf<List<Ticket>>(emptyList()) }
|
var tickets by remember { mutableStateOf<List<Ticket>>(emptyList()) }
|
||||||
@@ -52,6 +60,16 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
|
|||||||
var note by remember { mutableStateOf("") }
|
var note by remember { mutableStateOf("") }
|
||||||
var resolution 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() {
|
suspend fun reload() {
|
||||||
loading = true
|
loading = true
|
||||||
runCatching { api.tickets(workspace.id, role) }
|
runCatching { api.tickets(workspace.id, role) }
|
||||||
@@ -65,6 +83,18 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
|
|||||||
|
|
||||||
LaunchedEffect(role) { reload() }
|
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) {
|
fun fail(e: Throwable) {
|
||||||
val code = (e as? ApiException)?.code ?: 0
|
val code = (e as? ApiException)?.code ?: 0
|
||||||
message = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" }
|
message = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" }
|
||||||
@@ -95,10 +125,20 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
|
|||||||
modifier = Modifier.padding(top = 4.dp),
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(10.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) ->
|
ticketRoles.forEach { (key, label) ->
|
||||||
SurfaceChip(text = label, selected = role == key, onClick = { role = key })
|
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))
|
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)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
when (ticket.status) {
|
when (ticket.status) {
|
||||||
"open" -> PrimaryButton("接收", onClick = { act(ticket, "accept") })
|
"open" -> if (canWork) {
|
||||||
"accepted" -> PrimaryButton("开始办理", onClick = { act(ticket, "start") })
|
PrimaryButton("接收", onClick = { act(ticket, "accept") })
|
||||||
"in_progress" -> PrimaryButton("提交验收", onClick = {
|
}
|
||||||
scope.launch {
|
"accepted" -> if (canWork) {
|
||||||
runCatching { api.ticketAction(workspace.id, ticket.id, "submit") }
|
PrimaryButton("开始办理", onClick = { act(ticket, "start") })
|
||||||
.onSuccess {
|
}
|
||||||
message = null
|
"in_progress" -> if (canWork) {
|
||||||
reload()
|
PrimaryButton("提交验收", onClick = { act(ticket, "submit") })
|
||||||
}
|
}
|
||||||
.onFailure { fail(it) }
|
"pending_verify" -> if (canVerify) {
|
||||||
}
|
|
||||||
})
|
|
||||||
"pending_verify" -> {
|
|
||||||
PrimaryButton("通过验收", onClick = {
|
PrimaryButton("通过验收", onClick = {
|
||||||
verifyTarget = ticket
|
verifyTarget = ticket
|
||||||
note = ""
|
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)) }
|
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
|
val target = verifyTarget
|
||||||
if (target != null) {
|
if (target != null) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
|
|||||||
@@ -24,14 +24,20 @@ JWT_SECRET=dev-only-change-me
|
|||||||
ACCESS_TOKEN_MINUTES=30
|
ACCESS_TOKEN_MINUTES=30
|
||||||
REFRESH_TOKEN_DAYS=14
|
REFRESH_TOKEN_DAYS=14
|
||||||
|
|
||||||
# 生产环境禁止 SMS_PROVIDER=dev(否则任意验证码可登录),启动即失败
|
# 生产环境禁止 SMS_PROVIDER=dev(否则任意验证码可登录),启动即失败;
|
||||||
|
# 内网试用确需固定验证码时显式设置 INTERNAL_TEST_LOGIN=true(启动会打 WARNING)
|
||||||
SMS_PROVIDER=dev
|
SMS_PROVIDER=dev
|
||||||
SMS_DEV_CODE=123456
|
SMS_DEV_CODE=123456
|
||||||
|
INTERNAL_TEST_LOGIN=false
|
||||||
# 同一手机号两次发码的最小间隔(秒),测试里置 0 可关闭
|
# 同一手机号两次发码的最小间隔(秒),测试里置 0 可关闭
|
||||||
SMS_THROTTLE_SECONDS=60
|
SMS_THROTTLE_SECONDS=60
|
||||||
|
|
||||||
CORS_ORIGINS=http://localhost:5180,http://127.0.0.1:5180
|
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 起用)──────────────────────
|
# ── 知识库向量库(独立的 PG + pgvector,M3 起用)──────────────────────
|
||||||
# 单独实例,不动 fnsvr-01:那台没有 pgvector,且向量检索吃内存,不该和业务库挤。
|
# 单独实例,不动 fnsvr-01:那台没有 pgvector,且向量检索吃内存,不该和业务库挤。
|
||||||
# 起法见 deploy/docker-compose.vector.yml。不配则 M3 的语义检索能力直接不可用。
|
# 起法见 deploy/docker-compose.vector.yml。不配则 M3 的语义检索能力直接不可用。
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ import sqlalchemy as sa
|
|||||||
from alembic import op
|
from alembic import op
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from app.roles import APP_ROLE, OWNER_ROLE
|
||||||
|
|
||||||
revision: str = "0001"
|
revision: str = "0001"
|
||||||
down_revision: str | None = None
|
down_revision: str | None = None
|
||||||
branch_labels: str | Sequence[str] | None = None
|
branch_labels: str | Sequence[str] | None = None
|
||||||
depends_on: str | Sequence[str] | None = None
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
APP_ROLE = "dadao_app"
|
|
||||||
|
|
||||||
# 说明:asyncpg 走"预备语句"执行,**一条 execute 里只能有一条 SQL**,
|
# 说明:asyncpg 走"预备语句"执行,**一条 execute 里只能有一条 SQL**,
|
||||||
# 多条语句会报 `cannot insert multiple commands into a prepared statement`。
|
# 多条语句会报 `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};"
|
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};"
|
f"GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO {APP_ROLE};"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ import sqlalchemy as sa
|
|||||||
from alembic import op
|
from alembic import op
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from app.roles import APP_ROLE
|
||||||
|
|
||||||
revision: str = "0002"
|
revision: str = "0002"
|
||||||
down_revision: str | None = "0001"
|
down_revision: str | None = "0001"
|
||||||
branch_labels: str | Sequence[str] | None = None
|
branch_labels: str | Sequence[str] | None = None
|
||||||
depends_on: 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")
|
TENANT_TABLES_0002: tuple[str, ...] = ("item", "tag", "item_tag", "comment", "activity")
|
||||||
|
|
||||||
_TIMESTAMP_COLUMNS = (
|
_TIMESTAMP_COLUMNS = (
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ import sqlalchemy as sa
|
|||||||
from alembic import op
|
from alembic import op
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from app.roles import APP_ROLE
|
||||||
|
|
||||||
revision: str = "0003"
|
revision: str = "0003"
|
||||||
down_revision: str | None = "0002"
|
down_revision: str | None = "0002"
|
||||||
branch_labels: str | Sequence[str] | None = None
|
branch_labels: str | Sequence[str] | None = None
|
||||||
depends_on: 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")
|
TENANT_TABLES_0003: tuple[str, ...] = ("ticket_ext", "item_link", "notification")
|
||||||
|
|
||||||
TASK_STATUSES = ("inbox", "todo", "doing", "done", "blocked", "canceled")
|
TASK_STATUSES = ("inbox", "todo", "doing", "done", "blocked", "canceled")
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ import sqlalchemy as sa
|
|||||||
from alembic import op
|
from alembic import op
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from app.roles import APP_ROLE
|
||||||
|
|
||||||
revision: str = "0004"
|
revision: str = "0004"
|
||||||
down_revision: str | None = "0003"
|
down_revision: str | None = "0003"
|
||||||
branch_labels: str | Sequence[str] | None = None
|
branch_labels: str | Sequence[str] | None = None
|
||||||
depends_on: str | Sequence[str] | None = None
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
APP_ROLE = "dadao_app"
|
|
||||||
|
|
||||||
TENANT_TABLES_0004: tuple[str, ...] = ("knowledge_doc",)
|
TENANT_TABLES_0004: tuple[str, ...] = ("knowledge_doc",)
|
||||||
|
|
||||||
TASK_STATUSES = ("inbox", "todo", "doing", "done", "blocked", "canceled")
|
TASK_STATUSES = ("inbox", "todo", "doing", "done", "blocked", "canceled")
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ from sqlalchemy.dialects.postgresql import UUID as PgUUID
|
|||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
|
from app.roles import APP_ROLE
|
||||||
|
|
||||||
revision = "0005"
|
revision = "0005"
|
||||||
down_revision = "0004"
|
down_revision = "0004"
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
@@ -55,7 +57,7 @@ def upgrade() -> None:
|
|||||||
"ALTER TABLE attachment ENABLE ROW LEVEL SECURITY",
|
"ALTER TABLE attachment ENABLE ROW LEVEL SECURITY",
|
||||||
"CREATE POLICY attachment_isolation ON attachment "
|
"CREATE POLICY attachment_isolation ON attachment "
|
||||||
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
|
"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)
|
op.execute(statement)
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ from collections.abc import Sequence
|
|||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
|
from app.roles import APP_ROLE
|
||||||
|
|
||||||
revision: str = "0006"
|
revision: str = "0006"
|
||||||
down_revision: str = "0005"
|
down_revision: str = "0005"
|
||||||
branch_labels: Sequence[str] | None = None
|
branch_labels: Sequence[str] | None = None
|
||||||
@@ -82,7 +84,7 @@ POLICY_SQL = (
|
|||||||
"ALTER TABLE chunk ENABLE ROW LEVEL SECURITY",
|
"ALTER TABLE chunk ENABLE ROW LEVEL SECURITY",
|
||||||
"CREATE POLICY chunk_isolation ON chunk "
|
"CREATE POLICY chunk_isolation ON chunk "
|
||||||
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
|
"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")
|
||||||
@@ -27,10 +27,12 @@ from ..domain.task import (
|
|||||||
view_statuses,
|
view_statuses,
|
||||||
)
|
)
|
||||||
from ..errors import AppError, ErrorCode
|
from ..errors import AppError, ErrorCode
|
||||||
|
from ..models import Member
|
||||||
from ..models_task import Activity, Comment, Item, ItemTag, Tag
|
from ..models_task import Activity, Comment, Item, ItemTag, Tag
|
||||||
from ..models_ticket import TicketExt
|
from ..models_ticket import TicketExt
|
||||||
from ..schemas import Pagination, encode_cursor, ok
|
from ..schemas import Pagination, encode_cursor, ok
|
||||||
from ..serializers import item_out, tag_out, tags_of
|
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
|
from .attachments import count_for_items, list_for_item
|
||||||
|
|
||||||
router = APIRouter(prefix="/workspaces/{ws_id}/items", tags=["任务"])
|
router = APIRouter(prefix="/workspaces/{ws_id}/items", tags=["任务"])
|
||||||
@@ -98,6 +100,25 @@ async def _load_item(
|
|||||||
return 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(
|
async def _tags_of(
|
||||||
session: AsyncSession, ws_id: uuid.UUID, item_ids: list[uuid.UUID]
|
session: AsyncSession, ws_id: uuid.UUID, item_ids: list[uuid.UUID]
|
||||||
) -> dict[uuid.UUID, list[Tag]]:
|
) -> 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}
|
fields: dict[str, Any] = {"status": body.status, "completed_at": None}
|
||||||
apply_status(fields, body.status, now=datetime.now(timezone.utc))
|
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(
|
item = Item(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
workspace_id=ctx.ws_id,
|
workspace_id=ctx.ws_id,
|
||||||
@@ -470,6 +494,17 @@ async def create_item(body: CreateItemIn, ctx: MemberDep, session: SessionDep) -
|
|||||||
if body.tag_names:
|
if body.tag_names:
|
||||||
await _set_tags(session, ctx.ws_id, item, 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})
|
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])
|
tags = await _tags_of(session, ctx.ws_id, [item.id])
|
||||||
return ok(_item_out(item, tags.get(item.id)), message="已创建")
|
return ok(_item_out(item, tags.get(item.id)), message="已创建")
|
||||||
@@ -576,6 +611,7 @@ async def update_item(
|
|||||||
values["assignee_id"] = None
|
values["assignee_id"] = None
|
||||||
changes["assignee_id"] = None
|
changes["assignee_id"] = None
|
||||||
elif body.assignee_id is not None and body.assignee_id != item.assignee_id:
|
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
|
values["assignee_id"] = body.assignee_id
|
||||||
changes["assignee_id"] = str(body.assignee_id)
|
changes["assignee_id"] = str(body.assignee_id)
|
||||||
if body.clear_due_at:
|
if body.clear_due_at:
|
||||||
@@ -605,6 +641,18 @@ async def update_item(
|
|||||||
)
|
)
|
||||||
item.version += 1
|
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])
|
tags = await _tags_of(session, ctx.ws_id, [item.id])
|
||||||
return ok(_item_out(item, tags.get(item.id)), message="已保存")
|
return ok(_item_out(item, tags.get(item.id)), message="已保存")
|
||||||
|
|
||||||
@@ -634,6 +682,20 @@ async def change_status(
|
|||||||
payload={"from": previous, "to": body.status},
|
payload={"from": previous, "to": body.status},
|
||||||
)
|
)
|
||||||
tags = await _tags_of(session, ctx.ws_id, [item.id])
|
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="状态已更新")
|
return ok(_item_out(item, tags.get(item.id)), message="状态已更新")
|
||||||
|
|
||||||
|
|
||||||
@@ -671,6 +733,16 @@ async def add_comment(
|
|||||||
session.add(comment)
|
session.add(comment)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
await _log(session, item, ctx.user.id, "commented", {"comment_id": str(comment.id)})
|
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(
|
return ok(
|
||||||
{
|
{
|
||||||
"id": str(comment.id),
|
"id": str(comment.id),
|
||||||
|
|||||||
@@ -21,15 +21,21 @@ from typing import Annotated, Any
|
|||||||
|
|
||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, Query
|
||||||
from pydantic import BaseModel, Field
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..audit import record as audit_record
|
from ..audit import record as audit_record
|
||||||
from ..deps import MemberDep, SessionDep
|
from ..deps import MemberDep, SessionDep
|
||||||
from ..domain.knowledge import can_publish_role, sidebar_fields, target_status
|
from ..domain.knowledge import can_publish_role, sidebar_fields, target_status
|
||||||
from ..errors import AppError, ErrorCode
|
from ..errors import AppError, ErrorCode
|
||||||
from ..models_knowledge import DOC_TYPES, KNOWLEDGE_STATUSES, KnowledgeDoc
|
from ..models_knowledge import (
|
||||||
from ..models_task import Activity, Item
|
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 ..schemas import clamp_limit, decode_cursor, encode_cursor, ok
|
||||||
from ..serializers import item_out, tags_of
|
from ..serializers import item_out, tags_of
|
||||||
from ..services.knowledge import create_draft_from_source, find_doc_by_source
|
from ..services.knowledge import create_draft_from_source, find_doc_by_source
|
||||||
@@ -47,6 +53,7 @@ class KnowledgeIn(BaseModel):
|
|||||||
doc_type: str = "note"
|
doc_type: str = "note"
|
||||||
source_item_id: uuid.UUID | None = None
|
source_item_id: uuid.UUID | None = None
|
||||||
tag_names: list[str] = Field(default_factory=list)
|
tag_names: list[str] = Field(default_factory=list)
|
||||||
|
category_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
class KnowledgePatch(BaseModel):
|
class KnowledgePatch(BaseModel):
|
||||||
@@ -55,6 +62,24 @@ class KnowledgePatch(BaseModel):
|
|||||||
summary: str | None = None
|
summary: str | None = None
|
||||||
doc_type: str | None = None
|
doc_type: str | None = None
|
||||||
tag_names: list[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
|
version: int
|
||||||
|
|
||||||
|
|
||||||
@@ -112,6 +137,7 @@ def _doc_out(doc: KnowledgeDoc) -> dict[str, Any]:
|
|||||||
"status_hint": doc.status_hint,
|
"status_hint": doc.status_hint,
|
||||||
"source_kind": doc.source_kind,
|
"source_kind": doc.source_kind,
|
||||||
"source_item_id": str(doc.source_item_id) if doc.source_item_id else None,
|
"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,
|
"generated_by": doc.generated_by,
|
||||||
"published_at": doc.published_at,
|
"published_at": doc.published_at,
|
||||||
"archived_at": doc.archived_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(
|
async def _knowledge_out(
|
||||||
session: AsyncSession, ws_id: uuid.UUID, item: Item, doc: KnowledgeDoc
|
session: AsyncSession, ws_id: uuid.UUID, item: Item, doc: KnowledgeDoc
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
tags = (await tags_of(session, ws_id, [item.id])).get(item.id, [])
|
tags = (await tags_of(session, ws_id, [item.id])).get(item.id, [])
|
||||||
data = item_out(item, tags)
|
data = item_out(item, tags)
|
||||||
data["knowledge"] = _doc_out(doc)
|
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["source"] = await _source_out(session, ws_id, doc.source_item_id)
|
||||||
data["attachments"] = await list_for_item(session, ws_id, item.id)
|
data["attachments"] = await list_for_item(session, ws_id, item.id)
|
||||||
data["attachment_count"] = len(data["attachments"])
|
data["attachment_count"] = len(data["attachments"])
|
||||||
@@ -148,6 +258,9 @@ async def list_knowledge(
|
|||||||
status: Annotated[list[str] | None, Query()] = None,
|
status: Annotated[list[str] | None, Query()] = None,
|
||||||
doc_type: Annotated[str | None, Query()] = None,
|
doc_type: Annotated[str | None, Query()] = None,
|
||||||
source_item_id: Annotated[uuid.UUID | 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,
|
q: Annotated[str | None, Query(max_length=100)] = None,
|
||||||
limit: Annotated[int, Query(ge=1, le=100, description="每页条数")] = 30,
|
limit: Annotated[int, Query(ge=1, le=100, description="每页条数")] = 30,
|
||||||
cursor: Annotated[str | None, Query(description="上一页返回的 next_cursor")] = None,
|
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)
|
stmt = stmt.where(KnowledgeDoc.doc_type == doc_type)
|
||||||
if source_item_id:
|
if source_item_id:
|
||||||
stmt = stmt.where(KnowledgeDoc.source_item_id == 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:
|
if q:
|
||||||
like = f"%{q.strip()}%"
|
like = f"%{q.strip()}%"
|
||||||
stmt = stmt.where(
|
stmt = stmt.where(
|
||||||
@@ -188,12 +316,16 @@ async def list_knowledge(
|
|||||||
has_more = len(rows) > limit
|
has_more = len(rows) > limit
|
||||||
rows = rows[:limit]
|
rows = rows[:limit]
|
||||||
tag_map = await tags_of(session, ws_id, [it.id for it, _ in rows])
|
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 = []
|
items = []
|
||||||
counts = await count_for_items(session, ws_id, [it.id for it, _ in rows])
|
counts = await count_for_items(session, ws_id, [it.id for it, _ in rows])
|
||||||
for item, doc in rows:
|
for item, doc in rows:
|
||||||
data = item_out(item, tag_map.get(item.id, []))
|
data = item_out(item, tag_map.get(item.id, []))
|
||||||
data["knowledge"] = _doc_out(doc)
|
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["source"] = await _source_out(session, ws_id, doc.source_item_id)
|
||||||
data["attachment_count"] = counts.get(str(item.id), 0)
|
data["attachment_count"] = counts.get(str(item.id), 0)
|
||||||
items.append(data)
|
items.append(data)
|
||||||
@@ -204,6 +336,207 @@ async def list_knowledge(
|
|||||||
return ok({"items": items, "next_cursor": next_cursor, "has_more": has_more})
|
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}` 之前,
|
# 注意路由顺序:`/knowledge/search` 必须排在 `/knowledge/{item_id}` 之前,
|
||||||
# 否则 "search" 会被当成 uuid 解析,返回 422。
|
# 否则 "search" 会被当成 uuid 解析,返回 422。
|
||||||
@router.get("/knowledge/search", summary="检索已发布知识")
|
@router.get("/knowledge/search", summary="检索已发布知识")
|
||||||
@@ -257,6 +590,7 @@ async def create_knowledge(body: KnowledgeIn, ctx: MemberDep, session: SessionDe
|
|||||||
ErrorCode.KNOWLEDGE_SOURCE_REQUIRED,
|
ErrorCode.KNOWLEDGE_SOURCE_REQUIRED,
|
||||||
"来源必须是与本知识同一工作区的任务或工单",
|
"来源必须是与本知识同一工作区的任务或工单",
|
||||||
)
|
)
|
||||||
|
category = await _resolve_category(session, ws_id, body.category_id)
|
||||||
|
|
||||||
item = Item(
|
item = Item(
|
||||||
workspace_id=ws_id,
|
workspace_id=ws_id,
|
||||||
@@ -278,6 +612,7 @@ async def create_knowledge(body: KnowledgeIn, ctx: MemberDep, session: SessionDe
|
|||||||
doc_type=body.doc_type,
|
doc_type=body.doc_type,
|
||||||
status_hint="draft",
|
status_hint="draft",
|
||||||
summary=body.summary,
|
summary=body.summary,
|
||||||
|
category_id=category.id if category else None,
|
||||||
generated_by="human",
|
generated_by="human",
|
||||||
)
|
)
|
||||||
session.add(doc)
|
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:
|
if body.doc_type is not None and body.doc_type not in DOC_TYPES:
|
||||||
raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{body.doc_type}")
|
raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{body.doc_type}")
|
||||||
|
|
||||||
|
# 这里只放 Item 表自己的列:doc_type / summary 属于 knowledge_doc,
|
||||||
|
# 混进来会直接 CompileError: Unconsumed column names
|
||||||
values: dict[str, Any] = {}
|
values: dict[str, Any] = {}
|
||||||
if body.title is not None:
|
if body.title is not None:
|
||||||
values["title"] = body.title.strip()
|
values["title"] = body.title.strip()
|
||||||
if body.body_md is not None:
|
if body.body_md is not None:
|
||||||
values["body_md"] = body.body_md
|
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)
|
values["updated_at"] = datetime.now(timezone.utc)
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
update(Item)
|
update(Item)
|
||||||
@@ -344,6 +681,11 @@ async def patch_knowledge(
|
|||||||
doc.doc_type = body.doc_type
|
doc.doc_type = body.doc_type
|
||||||
if body.summary is not None:
|
if body.summary is not None:
|
||||||
doc.summary = body.summary
|
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:
|
if body.tag_names is not None:
|
||||||
await _set_tags_for(session, ws_id, item, body.tag_names)
|
await _set_tags_for(session, ws_id, item, body.tag_names)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from ..models_task import Activity, Item
|
|||||||
from ..models_ticket import ItemLink, Notification, TicketExt
|
from ..models_ticket import ItemLink, Notification, TicketExt
|
||||||
from ..schemas import Pagination, encode_cursor, ok
|
from ..schemas import Pagination, encode_cursor, ok
|
||||||
from ..serializers import item_out, tags_of
|
from ..serializers import item_out, tags_of
|
||||||
|
from ..services.notify import notify
|
||||||
|
|
||||||
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["工单"])
|
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["工单"])
|
||||||
notify_router = APIRouter(prefix="/workspaces/{ws_id}/notifications", tags=["通知"])
|
notify_router = APIRouter(prefix="/workspaces/{ws_id}/notifications", tags=["通知"])
|
||||||
@@ -60,6 +61,16 @@ class ReassignIn(BaseModel):
|
|||||||
assignee_id: uuid.UUID
|
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):
|
class VerifyIn(BaseModel):
|
||||||
approve: bool
|
approve: bool
|
||||||
note: str = Field(default="", max_length=MAX_REJECT_NOTE)
|
note: str = Field(default="", max_length=MAX_REJECT_NOTE)
|
||||||
@@ -139,16 +150,18 @@ async def _notify(
|
|||||||
item_id: uuid.UUID | None,
|
item_id: uuid.UUID | None,
|
||||||
title: str,
|
title: str,
|
||||||
body: str = "",
|
body: str = "",
|
||||||
|
actor_id: uuid.UUID | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
session.add(
|
"""单人便捷入口,实际写入交给 services.notify(去重与跳过自己在那里做)。"""
|
||||||
Notification(
|
await notify(
|
||||||
workspace_id=ws_id,
|
session,
|
||||||
user_id=user_id,
|
ws_id=ws_id,
|
||||||
kind=kind,
|
user_ids=(user_id,),
|
||||||
item_id=item_id,
|
kind=kind,
|
||||||
title=title[:120],
|
item_id=item_id,
|
||||||
body=body[:300],
|
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)
|
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="工单列表:我指派的 / 我发起的")
|
@router.get("/tickets", summary="工单列表:我指派的 / 我发起的")
|
||||||
async def list_tickets(
|
async def list_tickets(
|
||||||
|
|||||||
+13
-2
@@ -57,6 +57,9 @@ class Settings(BaseSettings):
|
|||||||
sms_throttle_seconds: int = 60
|
sms_throttle_seconds: int = 60
|
||||||
sms_dev_code: str = "123456"
|
sms_dev_code: str = "123456"
|
||||||
sms_provider: Literal["dev", "aliyun", "tencent"] = "dev"
|
sms_provider: Literal["dev", "aliyun", "tencent"] = "dev"
|
||||||
|
# 内测开关:生产默认禁止固定验证码。确实要在内网试用时显式打开,
|
||||||
|
# 启动会打 WARNING(见 main.py),其余生产校验照旧——绝不能配合公网暴露。
|
||||||
|
internal_test_login: bool = False
|
||||||
|
|
||||||
# ── 幂等 ──────────────────────────────────────────────
|
# ── 幂等 ──────────────────────────────────────────────
|
||||||
idempotency_enabled: bool = True
|
idempotency_enabled: bool = True
|
||||||
@@ -100,6 +103,11 @@ class Settings(BaseSettings):
|
|||||||
embedding_dim: int = 1024
|
embedding_dim: int = 1024
|
||||||
embedding_model: str = "BAAI/bge-m3"
|
embedding_model: str = "BAAI/bge-m3"
|
||||||
|
|
||||||
|
# ── 前端产物 ──────────────────────────────────────────
|
||||||
|
# 生产没有 nginx 时由后端直接托管前端构建产物(同源,/api 无跨域)。
|
||||||
|
# 指向 web/dist 的绝对路径;留空则只提供 API。
|
||||||
|
web_dist_dir: str | None = None
|
||||||
|
|
||||||
# ── 构建标记(部署校验用)──────────────────────────────
|
# ── 构建标记(部署校验用)──────────────────────────────
|
||||||
build_commit: str = "unknown"
|
build_commit: str = "unknown"
|
||||||
build_time: str = "unknown"
|
build_time: str = "unknown"
|
||||||
@@ -121,8 +129,11 @@ class Settings(BaseSettings):
|
|||||||
problems.append("JWT_SECRET 仍是开发默认值或长度不足 32")
|
problems.append("JWT_SECRET 仍是开发默认值或长度不足 32")
|
||||||
if "dadao_dev_pw" in self.database_url:
|
if "dadao_dev_pw" in self.database_url:
|
||||||
problems.append("DATABASE_URL 仍在用开发默认口令")
|
problems.append("DATABASE_URL 仍在用开发默认口令")
|
||||||
if self.sms_provider == "dev":
|
if self.sms_provider == "dev" and not self.internal_test_login:
|
||||||
problems.append("生产环境不允许 SMS_PROVIDER=dev(任意验证码可登录)")
|
problems.append(
|
||||||
|
"生产环境不允许 SMS_PROVIDER=dev(任意验证码可登录);"
|
||||||
|
"内网试用确需固定验证码时显式设置 INTERNAL_TEST_LOGIN=true"
|
||||||
|
)
|
||||||
if problems:
|
if problems:
|
||||||
raise RuntimeError("配置校验失败:" + ";".join(problems))
|
raise RuntimeError("配置校验失败:" + ";".join(problems))
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ class ErrorCode(IntEnum):
|
|||||||
KNOWLEDGE_STATUS_INVALID = 7003
|
KNOWLEDGE_STATUS_INVALID = 7003
|
||||||
KNOWLEDGE_FORBIDDEN = 7004
|
KNOWLEDGE_FORBIDDEN = 7004
|
||||||
KNOWLEDGE_SOURCE_REQUIRED = 7005
|
KNOWLEDGE_SOURCE_REQUIRED = 7005
|
||||||
|
KNOWLEDGE_CATEGORY_NOT_FOUND = 7006
|
||||||
|
KNOWLEDGE_CATEGORY_DEPTH = 7007
|
||||||
|
KNOWLEDGE_CATEGORY_CONFLICT = 7008
|
||||||
|
|
||||||
# 8xxx 附件
|
# 8xxx 附件
|
||||||
ATTACHMENT_NOT_FOUND = 8001
|
ATTACHMENT_NOT_FOUND = 8001
|
||||||
@@ -101,6 +104,9 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
|
|||||||
ErrorCode.KNOWLEDGE_STATUS_INVALID: 409,
|
ErrorCode.KNOWLEDGE_STATUS_INVALID: 409,
|
||||||
ErrorCode.KNOWLEDGE_FORBIDDEN: 403,
|
ErrorCode.KNOWLEDGE_FORBIDDEN: 403,
|
||||||
ErrorCode.KNOWLEDGE_SOURCE_REQUIRED: 409,
|
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_NOT_FOUND: 404,
|
||||||
ErrorCode.ATTACHMENT_TOO_LARGE: 413,
|
ErrorCode.ATTACHMENT_TOO_LARGE: 413,
|
||||||
ErrorCode.ATTACHMENT_FORBIDDEN: 403,
|
ErrorCode.ATTACHMENT_FORBIDDEN: 403,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
@@ -11,6 +12,9 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
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 .api import attachments, auth, items, knowledge, tickets, workspaces
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
@@ -27,6 +31,11 @@ logger = get_logger("app")
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
settings.validate_runtime()
|
settings.validate_runtime()
|
||||||
|
if settings.env == "prod" and settings.internal_test_login:
|
||||||
|
logger.warning(
|
||||||
|
"内测登录已开启:任意手机号可用固定验证码登录,只能在内网访问,切勿暴露公网",
|
||||||
|
extra={"extra_fields": {"sms_provider": settings.sms_provider}},
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"服务启动",
|
"服务启动",
|
||||||
extra={"extra_fields": {"env": settings.env, "commit": settings.build_commit}},
|
extra={"extra_fields": {"env": settings.env, "commit": settings.build_commit}},
|
||||||
@@ -138,3 +147,22 @@ async def version() -> dict:
|
|||||||
"api_prefix": settings.api_prefix,
|
"api_prefix": settings.api_prefix,
|
||||||
"db_role": role,
|
"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")
|
||||||
|
|||||||
@@ -39,6 +39,36 @@ DOC_TYPES: tuple[str, ...] = ("note", "howto", "faq", "postmortem", "decision")
|
|||||||
SOURCE_KINDS: tuple[str, ...] = ("manual", "task", "ticket")
|
SOURCE_KINDS: tuple[str, ...] = ("manual", "task", "ticket")
|
||||||
GENERATED_BY: tuple[str, ...] = ("human", "rule")
|
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):
|
class KnowledgeDoc(Base, Timestamps):
|
||||||
"""知识的附加信息,与 item 一比一。"""
|
"""知识的附加信息,与 item 一比一。"""
|
||||||
@@ -47,6 +77,7 @@ class KnowledgeDoc(Base, Timestamps):
|
|||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("ix_knowledge_ws_status", "workspace_id", "status_hint"),
|
Index("ix_knowledge_ws_status", "workspace_id", "status_hint"),
|
||||||
Index("ix_knowledge_ws_source", "workspace_id", "source_item_id"),
|
Index("ix_knowledge_ws_source", "workspace_id", "source_item_id"),
|
||||||
|
Index("ix_knowledge_ws_category", "workspace_id", "category_id"),
|
||||||
CheckConstraint(
|
CheckConstraint(
|
||||||
"source_kind = 'manual' OR source_item_id IS NOT NULL",
|
"source_kind = 'manual' OR source_item_id IS NOT NULL",
|
||||||
name="ck_knowledge_source_required",
|
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")
|
doc_type: Mapped[str] = mapped_column(String(16), nullable=False, default="note")
|
||||||
# 冗余一份状态:列表页要按知识自己的口径筛,不想每次都回 item 表
|
# 冗余一份状态:列表页要按知识自己的口径筛,不想每次都回 item 表
|
||||||
status_hint: Mapped[str] = mapped_column(String(16), nullable=False, default="draft")
|
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="")
|
summary: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
generated_by: Mapped[str] = mapped_column(String(16), nullable=False, default="human")
|
generated_by: Mapped[str] = mapped_column(String(16), nullable=False, default="human")
|
||||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
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))
|
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")
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
fastapi==0.115.6
|
fastapi==0.115.6
|
||||||
uvicorn[standard]==0.34.0
|
uvicorn[standard]==0.34.0
|
||||||
|
# 生产进程管理:gunicorn + UvicornWorker(开发用 `uvicorn --reload` 即可)
|
||||||
|
gunicorn==23.0.0
|
||||||
sqlalchemy[asyncio]==2.0.36
|
sqlalchemy[asyncio]==2.0.36
|
||||||
asyncpg==0.30.0
|
asyncpg==0.30.0
|
||||||
alembic==1.14.0
|
alembic==1.14.0
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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"]} == {"写法"}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Executable
+85
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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; # 知识检索/上传偶发慢请求
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+50
@@ -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 步)负责。"
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
@@ -10,6 +10,7 @@ import { TasksPage } from "./pages/Tasks";
|
|||||||
import { BoardPage } from "./pages/Board";
|
import { BoardPage } from "./pages/Board";
|
||||||
import { TicketsPage } from "./pages/Tickets";
|
import { TicketsPage } from "./pages/Tickets";
|
||||||
import { KnowledgePage } from "./pages/Knowledge";
|
import { KnowledgePage } from "./pages/Knowledge";
|
||||||
|
import { NotificationsPage } from "./pages/Notifications";
|
||||||
import { JoinPage } from "./pages/Join";
|
import { JoinPage } from "./pages/Join";
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
@@ -90,6 +91,7 @@ export function App() {
|
|||||||
<Route path="/w/:wsId/board" element={<BoardPage />} />
|
<Route path="/w/:wsId/board" element={<BoardPage />} />
|
||||||
<Route path="/w/:wsId/tickets" element={<TicketsPage />} />
|
<Route path="/w/:wsId/tickets" element={<TicketsPage />} />
|
||||||
<Route path="/w/:wsId/knowledge" element={<KnowledgePage />} />
|
<Route path="/w/:wsId/knowledge" element={<KnowledgePage />} />
|
||||||
|
<Route path="/w/:wsId/notifications" element={<NotificationsPage />} />
|
||||||
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
|
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
|
||||||
<Route path="/join/:wsId/:code" element={<JoinPage />} />
|
<Route path="/join/:wsId/:code" element={<JoinPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { Workspace } from "../lib/types";
|
|||||||
import {
|
import {
|
||||||
Book,
|
Book,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Bell,
|
||||||
Columns,
|
Columns,
|
||||||
Inbox,
|
Inbox,
|
||||||
Logo,
|
Logo,
|
||||||
@@ -15,13 +16,25 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "./icons";
|
} 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 }[] = [
|
const NAV: { key: Section; label: string; to: (id: string) => string; icon: ReactNode }[] = [
|
||||||
{ key: "tasks", label: "任务", to: (id) => `/w/${id}`, icon: <Inbox size={16} /> },
|
{ key: "tasks", label: "任务", to: (id) => `/w/${id}`, icon: <Inbox size={16} /> },
|
||||||
{ key: "board", label: "看板", to: (id) => `/w/${id}/board`, icon: <Columns 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: "tickets", label: "工单", to: (id) => `/w/${id}/tickets`, icon: <TicketIcon size={16} /> },
|
||||||
{ key: "knowledge", label: "知识", to: (id) => `/w/${id}/knowledge`, icon: <Book 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} /> },
|
{ key: "members", label: "成员", to: (id) => `/w/${id}/members`, icon: <Users size={16} /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -73,16 +86,22 @@ export function AppShell({
|
|||||||
let alive = true;
|
let alive = true;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const r = await api.get<{ count: number }>(`/workspaces/${wsId}/notifications/unread-count`);
|
const r = await api.get<{ unread: number }>(
|
||||||
if (alive) setUnread(r.count);
|
`/workspaces/${wsId}/notifications/unread-count`,
|
||||||
|
);
|
||||||
|
if (alive) setUnread(r.unread ?? 0);
|
||||||
} catch {
|
} catch {
|
||||||
/* 未读数失败不该影响主流程 */
|
/* 未读数失败不该影响主流程 */
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// 通知页标了已读之后立刻刷新角标,不必等下一个轮询周期
|
||||||
|
const onChange = () => void load();
|
||||||
void load();
|
void load();
|
||||||
|
window.addEventListener("notifications:changed", onChange);
|
||||||
const timer = window.setInterval(load, 30_000);
|
const timer = window.setInterval(load, 30_000);
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
|
window.removeEventListener("notifications:changed", onChange);
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
};
|
};
|
||||||
}, [wsId]);
|
}, [wsId]);
|
||||||
@@ -174,7 +193,7 @@ export function AppShell({
|
|||||||
<Link key={n.key} to={n.to(ws.id)} className={`nav-item ${isActive ? "nav-item-active" : ""}`}>
|
<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>
|
<span className={isActive ? "text-primary-200" : "text-ink-400"}>{n.icon}</span>
|
||||||
{n.label}
|
{n.label}
|
||||||
{n.key === "tickets" && unread > 0 ? (
|
{n.key === "notifications" && unread > 0 ? (
|
||||||
<span
|
<span
|
||||||
className={`ml-auto rounded-full px-1.5 py-px text-2xs font-semibold tabular-nums ${
|
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"
|
isActive ? "bg-clay-500 text-white" : "bg-clay-500/12 text-clay-600"
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ interface Props {
|
|||||||
onChange: (next: string) => void;
|
onChange: (next: string) => void;
|
||||||
/** 图片要落到具体条目下,所以这两个是必填 */
|
/** 图片要落到具体条目下,所以这两个是必填 */
|
||||||
wsId: string;
|
wsId: string;
|
||||||
itemId: string;
|
/** 新建场景还没有条目 id:正文照样能写,只是暂时不能贴图(贴图前必须先存草稿) */
|
||||||
|
itemId?: string;
|
||||||
|
/**
|
||||||
|
* 新建场景的兜底:要贴图时先老老实实把草稿存下来,拿到条目 id 再传。
|
||||||
|
* 返回 null 表示没存成(由调用方自己提示),编辑器不会继续上传。
|
||||||
|
*/
|
||||||
|
ensureItemId?: () => Promise<string | null>;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** 编辑器初始高度(px) */
|
/** 编辑器初始高度(px) */
|
||||||
@@ -45,6 +51,7 @@ export function RichTextEditor({
|
|||||||
onChange,
|
onChange,
|
||||||
wsId,
|
wsId,
|
||||||
itemId,
|
itemId,
|
||||||
|
ensureItemId,
|
||||||
placeholder = "写点什么…支持粘贴/拖入图片,也可以用工具栏排版",
|
placeholder = "写点什么…支持粘贴/拖入图片,也可以用工具栏排版",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
minHeight = 240,
|
minHeight = 240,
|
||||||
@@ -61,6 +68,7 @@ export function RichTextEditor({
|
|||||||
const onErrorRef = useRef(onError);
|
const onErrorRef = useRef(onError);
|
||||||
const resourcesRef = useRef(resources);
|
const resourcesRef = useRef(resources);
|
||||||
const onUploadedRef = useRef(onUploaded);
|
const onUploadedRef = useRef(onUploaded);
|
||||||
|
const ensureItemIdRef = useRef(ensureItemId);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
@@ -69,6 +77,7 @@ export function RichTextEditor({
|
|||||||
onErrorRef.current = onError;
|
onErrorRef.current = onError;
|
||||||
resourcesRef.current = resources;
|
resourcesRef.current = resources;
|
||||||
onUploadedRef.current = onUploaded;
|
onUploadedRef.current = onUploaded;
|
||||||
|
ensureItemIdRef.current = ensureItemId;
|
||||||
const disabledRef = useRef(disabled);
|
const disabledRef = useRef(disabled);
|
||||||
disabledRef.current = disabled;
|
disabledRef.current = disabled;
|
||||||
|
|
||||||
@@ -112,7 +121,8 @@ export function RichTextEditor({
|
|||||||
"line",
|
"line",
|
||||||
"code",
|
"code",
|
||||||
"inline-code",
|
"inline-code",
|
||||||
"upload",
|
// 没有落点就不摆这个按钮,免得点了才报错;新建时靠 ensureItemId 先存草稿
|
||||||
|
...(itemId || ensureItemId ? ["upload"] : []),
|
||||||
"table",
|
"table",
|
||||||
"|",
|
"|",
|
||||||
"undo",
|
"undo",
|
||||||
@@ -129,6 +139,19 @@ export function RichTextEditor({
|
|||||||
accept: "image/*",
|
accept: "image/*",
|
||||||
multiple: true,
|
multiple: true,
|
||||||
handler: async (files: File[]) => {
|
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/"));
|
const images = files.filter((file) => file.type.startsWith("image/"));
|
||||||
if (images.length === 0) {
|
if (images.length === 0) {
|
||||||
onErrorRef.current?.("只能插入图片");
|
onErrorRef.current?.("只能插入图片");
|
||||||
@@ -139,7 +162,7 @@ export function RichTextEditor({
|
|||||||
try {
|
try {
|
||||||
for (const file of images) {
|
for (const file of images) {
|
||||||
const created = await uploadFile<Attachment>(
|
const created = await uploadFile<Attachment>(
|
||||||
`/workspaces/${wsId}/items/${itemId}/attachments`,
|
`/workspaces/${wsId}/items/${targetId}/attachments`,
|
||||||
file,
|
file,
|
||||||
);
|
);
|
||||||
// 自己插入,不用 Vditor 代劳:它对 handler 的返回值只会
|
// 自己插入,不用 Vditor 代劳:它对 handler 的返回值只会
|
||||||
@@ -211,7 +234,11 @@ export function RichTextEditor({
|
|||||||
<p className="mt-1 text-2xs text-ink-400">
|
<p className="mt-1 text-2xs text-ink-400">
|
||||||
{uploading
|
{uploading
|
||||||
? "正在上传图片…"
|
? "正在上传图片…"
|
||||||
: "支持直接粘贴或拖入图片;内容按 Markdown 保存,模型和导出都读得懂"}
|
: itemId
|
||||||
|
? "支持直接粘贴或拖入图片;内容按 Markdown 保存,模型和导出都读得懂"
|
||||||
|
: ensureItemId
|
||||||
|
? "支持直接粘贴或拖入图片:会先自动存成草稿,再传上去"
|
||||||
|
: "内容按 Markdown 保存;存下来之后就能直接贴图、传附件"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ export function TaskDetail({
|
|||||||
<Sheet
|
<Sheet
|
||||||
open
|
open
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
width="max-w-2xl"
|
size="xl"
|
||||||
title={item ? item.title : "打开中…"}
|
title={item ? item.title : "打开中…"}
|
||||||
subtitle={meta}
|
subtitle={meta}
|
||||||
footer={
|
footer={
|
||||||
|
|||||||
@@ -178,6 +178,13 @@ export const Inbox = (p: IconProps) => (
|
|||||||
</svg>
|
</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) => (
|
export const Sparkle = (p: IconProps) => (
|
||||||
<svg {...base(p)}>
|
<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" />
|
<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>
|
</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) => (
|
export const More = (p: IconProps) => (
|
||||||
<svg {...base(p)}>
|
<svg {...base(p)}>
|
||||||
<circle cx="6" cy="12" r="1.2" fill="currentColor" stroke="none" />
|
<circle cx="6" cy="12" r="1.2" fill="currentColor" stroke="none" />
|
||||||
|
|||||||
@@ -8,7 +8,17 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} 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,
|
subtitle,
|
||||||
children,
|
children,
|
||||||
footer,
|
footer,
|
||||||
width = "max-w-xl",
|
size = "lg",
|
||||||
|
width,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -275,8 +286,14 @@ export function Sheet({
|
|||||||
subtitle?: ReactNode;
|
subtitle?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
|
/** 宽度档位;长文/多图用 xl,右上方还能一键放满整屏 */
|
||||||
|
size?: "md" | "lg" | "xl";
|
||||||
|
/** 兜底:直接给 Tailwind 宽度类 */
|
||||||
width?: string;
|
width?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const sizeClass = width ?? SHEET_SIZE[size];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -298,17 +315,31 @@ export function Sheet({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={typeof title === "string" ? title : "详情"}
|
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">
|
<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="min-w-0">
|
||||||
<div className="text-base font-semibold text-ink-900">{title}</div>
|
<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}
|
{subtitle ? <div className="mt-0.5 text-xs text-ink-400">{subtitle}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="subtle" size="sm" onClick={onClose} aria-label="关闭">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
<X size={15} />
|
<Button
|
||||||
关闭
|
variant="subtle"
|
||||||
</Button>
|
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>
|
</header>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">{children}</div>
|
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">{children}</div>
|
||||||
{footer ? (
|
{footer ? (
|
||||||
|
|||||||
@@ -150,8 +150,25 @@ export interface KnowledgeSource {
|
|||||||
status: string;
|
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 {
|
export interface Knowledge extends Item {
|
||||||
knowledge: KnowledgeInfo;
|
knowledge: KnowledgeInfo;
|
||||||
|
category: KnowledgeCategory | null;
|
||||||
source: KnowledgeSource | null;
|
source: KnowledgeSource | null;
|
||||||
attachments?: Attachment[];
|
attachments?: Attachment[];
|
||||||
attachment_count?: number;
|
attachment_count?: number;
|
||||||
@@ -170,3 +187,15 @@ export interface SearchHit {
|
|||||||
vector_score: number;
|
vector_score: number;
|
||||||
lexical_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
@@ -3,7 +3,7 @@ import { useParams } from "react-router-dom";
|
|||||||
import { AppShell } from "../components/AppShell";
|
import { AppShell } from "../components/AppShell";
|
||||||
import { AttachmentPanel } from "../components/AttachmentPanel";
|
import { AttachmentPanel } from "../components/AttachmentPanel";
|
||||||
import { RichTextEditor } from "../components/RichTextEditor";
|
import { RichTextEditor } from "../components/RichTextEditor";
|
||||||
import { Book, Link2, Plus, Search, Sparkle } from "../components/icons";
|
import { Link2, Paperclip, Plus, Search, Sparkle } from "../components/icons";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
@@ -14,7 +14,13 @@ import {
|
|||||||
StatusPill,
|
StatusPill,
|
||||||
} from "../components/ui";
|
} from "../components/ui";
|
||||||
import { ApiError, api } from "../lib/client";
|
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 = [
|
const TABS = [
|
||||||
{ key: "draft", label: "待核对", statuses: ["draft", "in_review"] },
|
{ key: "draft", label: "待核对", statuses: ["draft", "in_review"] },
|
||||||
@@ -61,12 +67,48 @@ export function KnowledgePage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [openId, setOpenId] = useState<string | null>(null);
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
const [adding, setAdding] = useState(false);
|
const [composing, setComposing] = useState(false);
|
||||||
const [draftTitle, setDraftTitle] = useState("");
|
|
||||||
const [hits, setHits] = useState<SearchHit[] | null>(null);
|
const [hits, setHits] = useState<SearchHit[] | null>(null);
|
||||||
const [searching, setSearching] = useState(false);
|
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 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(
|
const load = useCallback(
|
||||||
async (nextCursor: string | null) => {
|
async (nextCursor: string | null) => {
|
||||||
// 切 tab / 改搜索会连发请求,慢的回来会盖掉新结果
|
// 切 tab / 改搜索会连发请求,慢的回来会盖掉新结果
|
||||||
@@ -78,6 +120,8 @@ export function KnowledgePage() {
|
|||||||
const statuses = TABS.find((t) => t.key === tab)!.statuses;
|
const statuses = TABS.find((t) => t.key === tab)!.statuses;
|
||||||
for (const s of statuses) qs.append("status", s);
|
for (const s of statuses) qs.append("status", s);
|
||||||
if (q.trim()) qs.set("q", q.trim());
|
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);
|
if (nextCursor) qs.set("cursor", nextCursor);
|
||||||
const page = await api.get<{
|
const page = await api.get<{
|
||||||
items: Knowledge[];
|
items: Knowledge[];
|
||||||
@@ -94,7 +138,7 @@ export function KnowledgePage() {
|
|||||||
if (mine === reqId.current) setLoading(false);
|
if (mine === reqId.current) setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[wsId, tab, q],
|
[wsId, tab, q, catFilter],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -127,22 +171,13 @@ export function KnowledgePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addNote() {
|
function onCreated(doc: Knowledge) {
|
||||||
const title = draftTitle.trim();
|
setComposing(false);
|
||||||
if (!title) return;
|
setTab("draft");
|
||||||
try {
|
setCatFilter(null);
|
||||||
const doc = await api.post<Knowledge>(`/workspaces/${wsId}/knowledge`, {
|
setOpenId(doc.id);
|
||||||
title,
|
void load(null);
|
||||||
doc_type: "note",
|
void loadCategories();
|
||||||
});
|
|
||||||
setDraftTitle("");
|
|
||||||
setAdding(false);
|
|
||||||
setTab("draft");
|
|
||||||
setOpenId(doc.id);
|
|
||||||
void load(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof ApiError ? err.message : "保存失败");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingCount = docs.filter((d) => d.knowledge.status_hint !== "published").length;
|
const pendingCount = docs.filter((d) => d.knowledge.status_hint !== "published").length;
|
||||||
@@ -176,7 +211,7 @@ export function KnowledgePage() {
|
|||||||
<Search size={14} />
|
<Search size={14} />
|
||||||
{searching ? "检索中…" : "检索"}
|
{searching ? "检索中…" : "检索"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" onClick={() => setAdding((v) => !v)}>
|
<Button variant="primary" onClick={() => setComposing(true)}>
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
记一条
|
记一条
|
||||||
</Button>
|
</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">
|
<div className="mb-4 flex flex-wrap items-center gap-1.5">
|
||||||
{TABS.map((t) => {
|
{TABS.map((t) => {
|
||||||
const active = tab === t.key;
|
const active = tab === t.key;
|
||||||
@@ -208,34 +255,6 @@ export function KnowledgePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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 && (
|
{error && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<InlineAlert>{error}</InlineAlert>
|
<InlineAlert>{error}</InlineAlert>
|
||||||
@@ -327,6 +346,11 @@ export function KnowledgePage() {
|
|||||||
自动整理
|
自动整理
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{d.category ? (
|
||||||
|
<span className="chip max-w-[16rem] truncate bg-paper-200 text-ink-500">
|
||||||
|
{d.category.full_path}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1.5 truncate text-sm font-medium text-ink-900 group-hover:text-primary-700">
|
<p className="mt-1.5 truncate text-sm font-medium text-ink-900 group-hover:text-primary-700">
|
||||||
{d.title}
|
{d.title}
|
||||||
@@ -339,6 +363,16 @@ export function KnowledgePage() {
|
|||||||
{d.source ? <span className="max-w-[22rem] truncate">「{d.source.title}」</span> : null}
|
{d.source ? <span className="max-w-[22rem] truncate">「{d.source.title}」</span> : null}
|
||||||
<span>{new Date(d.updated_at).toLocaleString("zh-CN")}</span>
|
<span>{new Date(d.updated_at).toLocaleString("zh-CN")}</span>
|
||||||
</p>
|
</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>
|
||||||
<Button variant="ghost" size="sm" className="shrink-0" onClick={() => setOpenId(d.id)}>
|
<Button variant="ghost" size="sm" className="shrink-0" onClick={() => setOpenId(d.id)}>
|
||||||
打开
|
打开
|
||||||
@@ -355,28 +389,429 @@ export function KnowledgePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{composing && (
|
||||||
|
<KnowledgeComposer
|
||||||
|
wsId={wsId}
|
||||||
|
categories={categories}
|
||||||
|
catBusy={catBusy}
|
||||||
|
onCreateCategory={createCategory}
|
||||||
|
onClose={() => setComposing(false)}
|
||||||
|
onCreated={onCreated}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{openId && (
|
{openId && (
|
||||||
<KnowledgeDrawer
|
<KnowledgeDrawer
|
||||||
wsId={wsId}
|
wsId={wsId}
|
||||||
itemId={openId}
|
itemId={openId}
|
||||||
|
categories={categories}
|
||||||
onClose={() => setOpenId(null)}
|
onClose={() => setOpenId(null)}
|
||||||
onChanged={() => void load(null)}
|
onChanged={() => {
|
||||||
|
void load(null);
|
||||||
|
void loadCategories();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AppShell>
|
</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({
|
function KnowledgeDrawer({
|
||||||
wsId,
|
wsId,
|
||||||
itemId,
|
itemId,
|
||||||
|
categories,
|
||||||
onClose,
|
onClose,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
wsId: string;
|
wsId: string;
|
||||||
itemId: string;
|
itemId: string;
|
||||||
|
categories: KnowledgeCategoryNode[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -384,6 +819,8 @@ function KnowledgeDrawer({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [body, setBody] = useState("");
|
const [body, setBody] = useState("");
|
||||||
|
const [tags, setTags] = useState("");
|
||||||
|
const [categoryId, setCategoryId] = useState("");
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
/** 附件 id → 带签名的直链(见 lib/attachmentUrls.ts) */
|
/** 附件 id → 带签名的直链(见 lib/attachmentUrls.ts) */
|
||||||
const resources = useMemo(() => {
|
const resources = useMemo(() => {
|
||||||
@@ -394,18 +831,27 @@ function KnowledgeDrawer({
|
|||||||
return map;
|
return map;
|
||||||
}, [attachments]);
|
}, [attachments]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
/** 已经载入完成的是哪一条知识。重复的 effect / 慢响应不该再覆盖用户正在敲的内容。 */
|
||||||
|
const loadedFor = useRef<string | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(
|
||||||
try {
|
async (force = false) => {
|
||||||
const data = await api.get<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`);
|
if (!force && loadedFor.current === itemId) return;
|
||||||
setDoc(data);
|
try {
|
||||||
setTitle(data.title);
|
const data = await api.get<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`);
|
||||||
setBody(data.body_md);
|
setDoc(data);
|
||||||
setAttachments(data.attachments ?? []);
|
setTitle(data.title);
|
||||||
} catch (err) {
|
setBody(data.body_md);
|
||||||
setError(err instanceof ApiError ? err.message : "加载失败");
|
setTags(data.tags.map((t) => t.name).join(" "));
|
||||||
}
|
setCategoryId(data.category?.id ?? "");
|
||||||
}, [wsId, itemId]);
|
setAttachments(data.attachments ?? []);
|
||||||
|
loadedFor.current = itemId;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "加载失败");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[wsId, itemId],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
@@ -419,6 +865,8 @@ function KnowledgeDrawer({
|
|||||||
const next = await api.patch<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`, {
|
const next = await api.patch<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`, {
|
||||||
title,
|
title,
|
||||||
body_md: body,
|
body_md: body,
|
||||||
|
tag_names: tags.split(/[\s,,]+/).filter(Boolean),
|
||||||
|
...(categoryId ? { category_id: categoryId } : { clear_category: true }),
|
||||||
version: doc.version,
|
version: doc.version,
|
||||||
});
|
});
|
||||||
setDoc(next);
|
setDoc(next);
|
||||||
@@ -426,7 +874,7 @@ function KnowledgeDrawer({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError && err.code === 4009) {
|
if (err instanceof ApiError && err.code === 4009) {
|
||||||
setError("刚被别人改过,已刷新为最新内容");
|
setError("刚被别人改过,已刷新为最新内容");
|
||||||
await load();
|
await load(true);
|
||||||
} else {
|
} else {
|
||||||
setError(err instanceof ApiError ? err.message : "保存失败");
|
setError(err instanceof ApiError ? err.message : "保存失败");
|
||||||
}
|
}
|
||||||
@@ -448,7 +896,7 @@ function KnowledgeDrawer({
|
|||||||
onChanged();
|
onChanged();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : "操作失败");
|
setError(err instanceof ApiError ? err.message : "操作失败");
|
||||||
await load();
|
await load(true);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -472,13 +920,13 @@ function KnowledgeDrawer({
|
|||||||
<Sheet
|
<Sheet
|
||||||
open
|
open
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
width="max-w-2xl"
|
size="xl"
|
||||||
title={doc ? doc.title : "知识"}
|
title={doc ? doc.title : "知识"}
|
||||||
subtitle={
|
subtitle={
|
||||||
doc
|
doc
|
||||||
? `${DOC_TYPE_LABEL[doc.knowledge.doc_type] ?? doc.knowledge.doc_type} · ${
|
? [doc.category?.full_path, DOC_TYPE_LABEL[doc.knowledge.doc_type] ?? doc.knowledge.doc_type]
|
||||||
STATUS_LABEL[doc.knowledge.status_hint]
|
.filter(Boolean)
|
||||||
} · 版本 ${doc.version}`
|
.join(" · ")
|
||||||
: "加载中…"
|
: "加载中…"
|
||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
@@ -543,6 +991,37 @@ function KnowledgeDrawer({
|
|||||||
aria-label="标题"
|
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">
|
<div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2">
|
||||||
<RichTextEditor
|
<RichTextEditor
|
||||||
ariaLabel="正文"
|
ariaLabel="正文"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
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 { AppShell } from "../components/AppShell";
|
||||||
import { TaskDetail } from "../components/TaskDetail";
|
import { TaskDetail } from "../components/TaskDetail";
|
||||||
import { Check, Inbox, Plus, Search } from "../components/icons";
|
import { Check, Inbox, Plus, Search } from "../components/icons";
|
||||||
@@ -41,7 +41,9 @@ export function TasksPage() {
|
|||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 [activeIdx, setActiveIdx] = useState(0);
|
||||||
const reqId = useRef(0);
|
const reqId = useRef(0);
|
||||||
|
|
||||||
@@ -333,7 +335,10 @@ export function TasksPage() {
|
|||||||
<TaskDetail
|
<TaskDetail
|
||||||
wsId={wsId}
|
wsId={wsId}
|
||||||
itemId={openId}
|
itemId={openId}
|
||||||
onClose={() => setOpenId(null)}
|
onClose={() => {
|
||||||
|
setOpenId(null);
|
||||||
|
if (searchParams.get("open")) setSearchParams({}, { replace: true });
|
||||||
|
}}
|
||||||
onChanged={(updated) =>
|
onChanged={(updated) =>
|
||||||
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)))
|
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)))
|
||||||
}
|
}
|
||||||
|
|||||||
+206
-19
@@ -1,12 +1,21 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
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 { AppShell } from "../components/AppShell";
|
||||||
import { TaskDetail } from "../components/TaskDetail";
|
import { TaskDetail } from "../components/TaskDetail";
|
||||||
import { ChevronRight, TicketIcon } from "../components/icons";
|
import { ChevronRight, Plus, TicketIcon } from "../components/icons";
|
||||||
import { Button, EmptyState, InlineAlert, PageHeader, SkeletonRows, StatusPill } from "../components/ui";
|
import {
|
||||||
|
Button,
|
||||||
|
EmptyState,
|
||||||
|
InlineAlert,
|
||||||
|
PageHeader,
|
||||||
|
Sheet,
|
||||||
|
SkeletonRows,
|
||||||
|
StatusPill,
|
||||||
|
useToast,
|
||||||
|
} from "../components/ui";
|
||||||
import { ApiError, api } from "../lib/client";
|
import { ApiError, api } from "../lib/client";
|
||||||
import { STATUS_LABEL } from "../lib/parse";
|
import { STATUS_LABEL } from "../lib/parse";
|
||||||
import type { Item, Ticket } from "../lib/types";
|
import type { Item, Member, Ticket } from "../lib/types";
|
||||||
|
|
||||||
const ROLES = [
|
const ROLES = [
|
||||||
{ key: "assigned", label: "派给我的", desc: "别人等你的" },
|
{ key: "assigned", label: "派给我的", desc: "别人等你的" },
|
||||||
@@ -25,14 +34,43 @@ const STATUS_FILTERS = [
|
|||||||
|
|
||||||
export function TicketsPage() {
|
export function TicketsPage() {
|
||||||
const { wsId = "" } = useParams();
|
const { wsId = "" } = useParams();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [role, setRole] = useState<(typeof ROLES)[number]["key"]>("assigned");
|
const [role, setRole] = useState<(typeof ROLES)[number]["key"]>("assigned");
|
||||||
const [status, setStatus] = useState<string>("");
|
const [status, setStatus] = useState<string>("");
|
||||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||||
const [cursor, setCursor] = useState<string | null>(null);
|
const [cursor, setCursor] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 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(
|
const load = useCallback(
|
||||||
async (nextCursor: string | null) => {
|
async (nextCursor: string | null) => {
|
||||||
@@ -75,7 +113,8 @@ export function TicketsPage() {
|
|||||||
void load(null);
|
void load(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError && err.code === 4009) {
|
if (err instanceof ApiError && err.code === 4009) {
|
||||||
setError("这条刚被别人改过,已为你刷新列表"); void load(null);
|
setError("这条刚被别人改过,已为你刷新列表");
|
||||||
|
void load(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(err instanceof ApiError ? err.message : "操作失败");
|
setError(err instanceof ApiError ? err.message : "操作失败");
|
||||||
@@ -84,6 +123,41 @@ export function TicketsPage() {
|
|||||||
|
|
||||||
const currentRole = ROLES.find((r) => r.key === role);
|
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 (
|
return (
|
||||||
<AppShell wsId={wsId} active="tickets">
|
<AppShell wsId={wsId} active="tickets">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -91,18 +165,24 @@ export function TicketsPage() {
|
|||||||
desc="待接 → 办理中 → 待验收 → 完成。验收通过后,当初卡住的那条任务会自动回到待办。"
|
desc="待接 → 办理中 → 待验收 → 完成。验收通过后,当初卡住的那条任务会自动回到待办。"
|
||||||
eyebrow={<>需要别人搭把手的事,都在这里</>}
|
eyebrow={<>需要别人搭把手的事,都在这里</>}
|
||||||
actions={
|
actions={
|
||||||
<select
|
<>
|
||||||
className="input h-9 w-36 text-sm"
|
<select
|
||||||
value={status}
|
className="input h-9 w-36 text-sm"
|
||||||
onChange={(e) => setStatus(e.target.value)}
|
value={status}
|
||||||
aria-label="按状态筛选"
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
>
|
aria-label="按状态筛选"
|
||||||
{STATUS_FILTERS.map((s) => (
|
>
|
||||||
<option key={s.key} value={s.key}>
|
{STATUS_FILTERS.map((s) => (
|
||||||
{s.label}
|
<option key={s.key} value={s.key}>
|
||||||
</option>
|
{s.label}
|
||||||
))}
|
</option>
|
||||||
</select>
|
))}
|
||||||
|
</select>
|
||||||
|
<Button variant="primary" size="sm" onClick={() => setCreating(true)}>
|
||||||
|
<Plus size={14} />
|
||||||
|
新建工单
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -218,11 +298,118 @@ export function TicketsPage() {
|
|||||||
</div>
|
</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 && (
|
{openId && (
|
||||||
<TaskDetail
|
<TaskDetail
|
||||||
wsId={wsId}
|
wsId={wsId}
|
||||||
itemId={openId}
|
itemId={openId}
|
||||||
onClose={() => setOpenId(null)}
|
onClose={() => {
|
||||||
|
setOpenId(null);
|
||||||
|
if (searchParams.get("open")) setSearchParams({}, { replace: true });
|
||||||
|
}}
|
||||||
onChanged={(updated: Item) =>
|
onChanged={(updated: Item) =>
|
||||||
setTickets((prev) => prev.map((t) => (t.id === updated.id ? { ...t, ...updated } : t)))
|
setTickets((prev) => prev.map((t) => (t.id === updated.id ? { ...t, ...updated } : t)))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user