Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b7627d70e | ||
|
|
28eab4ae5f | ||
|
|
dcd77c6b35 | ||
|
|
4e3f56af79 | ||
|
|
377dabcc9e | ||
|
|
3e29ef5ee2 | ||
|
|
690779c5fb | ||
|
|
626f83fb9c | ||
|
|
f6e7616756 | ||
|
|
2170a0664d | ||
|
|
07b01db7cc | ||
|
|
1e8cd01b3d | ||
|
|
8e3dc65cc6 | ||
|
|
8436c0edbf | ||
|
|
d21394a5ac | ||
|
|
c194609193 | ||
|
|
90b99f8253 | ||
|
|
33a3fc03e0 | ||
|
|
2a9d72a974 | ||
|
|
29805bd61e | ||
|
|
2c338490e5 | ||
|
|
02ef47e753 | ||
|
|
da31083df2 | ||
|
|
35fdea7260 | ||
|
|
d34a6ac69e | ||
|
|
127303b7aa | ||
|
|
b5170ad1ce | ||
|
|
c0c5230fd1 | ||
|
|
742af4dd02 | ||
|
|
7558637567 | ||
|
|
23687d6e37 | ||
|
|
0660e5cbda | ||
|
|
2032e9d981 | ||
|
|
0b4e829d8b | ||
|
|
71982f9fa4 | ||
|
|
d7c9670880 | ||
|
|
288e27e465 | ||
|
|
684b9b2604 | ||
|
|
4796b695a3 | ||
|
|
b3cd115bd9 | ||
|
|
694b643683 | ||
|
|
b662429842 | ||
|
|
d24be46d8c | ||
|
|
483b554c3c | ||
|
|
370d8923c8 | ||
|
|
429905892c | ||
|
|
e3f1310cff | ||
|
|
096acb00e7 | ||
|
|
a5510cdb79 | ||
|
|
29aae12224 | ||
|
|
7668799040 | ||
|
|
35fe4d92cb | ||
|
|
50227c2114 | ||
|
|
b29286778f | ||
|
|
441b1b93e1 | ||
|
|
bbd30ec85e | ||
|
|
039e5b903c | ||
|
|
8b6594bf10 | ||
|
|
2ff1f6e9cd | ||
|
|
2a118ad407 | ||
|
|
ea5d28304e | ||
|
|
57767c77a1 | ||
|
|
9ac3b46430 | ||
|
|
2dbe4cf675 | ||
|
|
7c84ff3b8d | ||
|
|
ffae7e380f | ||
|
|
9a382e4e8c | ||
|
|
ed31af3f46 | ||
|
|
91b3dde670 | ||
|
|
d079564d6c | ||
|
|
d509f0c335 | ||
|
|
17be9d1d42 | ||
|
|
fbdf54cb4c | ||
|
|
7a7ea932a4 | ||
|
|
800c2726e2 | ||
|
|
a24a493403 |
@@ -27,6 +27,9 @@ android/.gradle/
|
||||
android/build/
|
||||
android/app/build/
|
||||
android/local.properties
|
||||
# 发布签名证书与口令(只在发布者本机;仓库里没有它就用调试签名)
|
||||
android/keystore.properties
|
||||
*.jks
|
||||
|
||||
# 编辑器 / 系统
|
||||
.DS_Store
|
||||
@@ -43,3 +46,19 @@ tmprfx*
|
||||
|
||||
# Kotlin 编译器诊断日志(构建产物)
|
||||
android/.kotlin/
|
||||
|
||||
# 本地上传文件(附件落盘目录,绝不入库)
|
||||
backend/var/
|
||||
|
||||
# 发布的安卓安装包由 publish_apk.py 放在这里,APK 不进版本库
|
||||
backend/static/apk/
|
||||
|
||||
# pytest 的临时目录(在 backend/ 下跑测试时生成)
|
||||
backend/pytest-of-*/
|
||||
|
||||
# Vditor 运行时资源(lute/i18n/icons,约 3.8 MB)
|
||||
# 由 web/scripts/vendor-vditor.mjs 从 node_modules 生成,不进版本库
|
||||
web/public/vditor/
|
||||
|
||||
# 本机部署/调试的临时脚本与缓存
|
||||
.tmp/
|
||||
|
||||
@@ -29,6 +29,16 @@
|
||||
| 2010 | 429 | 验证码请求太频繁 | 倒计时 60 秒 |
|
||||
| 2011 | 400 | 验证码错误/已过期 | 重发 |
|
||||
| 2012 | 403 | 账号被禁用 | 联系管理员 |
|
||||
| 2013 | 400 | 密码错误,或这个号还没设过密码 | 提示核对账号与密码 |
|
||||
| 2014 | 400 | 该账号没有密码(保留码) | 先设密码 |
|
||||
| 2015 | 422 | 密码太弱(少于 8 位 / 缺字母或数字 / 与旧密码相同) | 就地提示规则 |
|
||||
| 2016 | 429 | 密码连错太多次(15 分钟内 10 次) | 提示稍后再试 |
|
||||
| 2017 | 403 | 这条登录方式没开(默认只开密码) | 换密码登录 |
|
||||
| 2018 | 422 | 邮箱格式不对 | 就地提示 |
|
||||
| 2019 | 409 | 邮箱已被别的账号占用 | 换一个 |
|
||||
| 2020 | 502 | 发信失败(SMTP 认证/收件人/网络) | 去「系统设置」核对授权码 |
|
||||
| 2021 | 400 | 邮箱验证码不对 / 已过期 / 试错次数用尽 | 重新获取验证码 |
|
||||
| 2022 | 429 | 验证码发送过于频繁 | 等 60 秒再发 |
|
||||
| 3001 | 404 | 工作区不存在 | — |
|
||||
| 3003 | 403 | 不是该工作区成员 | **不要**提示"工作区不存在" |
|
||||
| 3004 | 403 | 角色权限不足 | 隐藏对应按钮 |
|
||||
@@ -46,6 +56,10 @@
|
||||
`Authorization: Bearer <access_token>`。`access` 30 分钟,`refresh` 14 天。
|
||||
`refresh_token` 只能用于 `/auth/refresh`(服务端校验 `typ`)。
|
||||
|
||||
**客户端要自己续期,别等用户被踢出去**:access 剩不到 90 秒就先换一对,
|
||||
撞上 `401/2002` 也先用 refresh 换一对、把原请求重放一次,换不动才清令牌回登录页。
|
||||
每次 `/auth/refresh` 都会返回新的 refresh,所以只要 14 天内用过一次,登录态就一直往后顺延。
|
||||
|
||||
### 0.4 幂等
|
||||
|
||||
所有 `POST/PATCH/DELETE` 都可带 `Idempotency-Key: <uuid>`。
|
||||
@@ -61,6 +75,53 @@
|
||||
|
||||
## 1. 认证 `/auth`
|
||||
|
||||
登录方式有四种,**默认只开「密码」和「邮箱注册」**。服务端把"现在开着哪些"告诉前端,
|
||||
前端据此渲染,免得用户点进一条关着的路:
|
||||
|
||||
### `GET /auth/methods`
|
||||
|
||||
```json
|
||||
{ "password": true, "email_password": true, "sms_code": false, "email_register": true }
|
||||
```
|
||||
|
||||
`SMS_LOGIN_ENABLED=false`(默认)时,`POST /auth/sms/send` 与 `POST /auth/login` 都回 `2017`
|
||||
「短信验证码登录暂未开放」。理由:dev 环境的固定码等于"知道手机号就能进任何账号",
|
||||
这条路必须显式打开,生产打开时还会强制要求真短信通道(见 `deploy` 文档的启动校验)。
|
||||
|
||||
### `POST /auth/email/code`
|
||||
|
||||
```json
|
||||
{ "email": "someone@example.com" }
|
||||
```
|
||||
|
||||
发一封注册验证码(6 位数字,`EMAIL_CODE_TTL_SECONDS` 默认 600 秒有效)。
|
||||
邮箱统一按小写处理;格式不对 → `2018`;**这个邮箱已经注册过 → `2019`**(让用户在填完密码前就知道);
|
||||
60 秒内重复请求 → `2022`;发信配置没填或发不出去 → `2020`(不会假装发出去);
|
||||
`EMAIL_REGISTER_ENABLED=false` → `2017`。
|
||||
|
||||
响应 `data: { sent: true, expires_in: 600 }`。**真发信时不回显验证码**;
|
||||
只有本机联调(`EMAIL_CODE_DEV_ECHO=true`)时才带 `dev_code` 且不发信——这个开关在生产会直接拒绝启动。
|
||||
|
||||
### `POST /auth/register`
|
||||
|
||||
```json
|
||||
{ "email": "someone@example.com", "code": "123456", "password": "dadao2026", "nickname": "大道" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "user": { "id": "…", "phone": "", "email": "someone@example.com", "email_verified": true, … },
|
||||
"tokens": { "access_token": "…", "refresh_token": "…", "token_type": "bearer", "expires_in": 1800 } }
|
||||
```
|
||||
|
||||
注册成功即登录态(直接返回 `tokens`)。规则:
|
||||
|
||||
- 验证码不对 / 过期 / 试错超过 `EMAIL_CODE_MAX_ATTEMPTS`(默认 5)次 → `2021`;用完即作废,不能重放
|
||||
- 密码强度不够 → `2015`;邮箱已被占用 → `2019`;`EMAIL_REGISTER_ENABLED=false` → `2017`
|
||||
- 昵称不填就取邮箱 `@` 前面那段
|
||||
- 建出来的账号**没有手机号**(`app_user.phone` 可空),所以 `user.phone` 回空串,不是星号串
|
||||
|
||||
邮箱是这类账号的唯一账号名与联系方式,所以注册必须真验一次码——"随手填一个"会让账号变成没人能找回的孤岛。
|
||||
|
||||
### `POST /auth/sms/send`
|
||||
|
||||
```json
|
||||
@@ -68,7 +129,7 @@
|
||||
```
|
||||
|
||||
响应 `data: { dev_code?: "123456" }`。`ENV=dev` 时才回显验证码,生产没有这个字段。
|
||||
60 秒内重复请求 → `2010`。
|
||||
60 秒内重复请求 → `2010`。短信登录关着时 → `2017`。
|
||||
|
||||
### `POST /auth/login`
|
||||
|
||||
@@ -89,11 +150,87 @@
|
||||
{ "refresh_token": "…" }
|
||||
```
|
||||
|
||||
返回同形状的 `tokens`。无状态 JWT,**服务端不维护黑名单**——token 泄露只能等过期,这是 M0 已知取舍。
|
||||
返回同形状的 `tokens`(**扁平**,没有 `tokens` 信封)。无状态 JWT,**服务端不维护黑名单**——
|
||||
token 泄露只能等过期,这是 M0 已知取舍;换来的是刷新不需要查库。
|
||||
|
||||
换的时候会看一眼账号状态:账号被停用 → `2012`(403),停用的人不能靠 refresh 一直续下去。
|
||||
|
||||
### `POST /auth/login/password`
|
||||
|
||||
手机号和邮箱都走这一个入口,`phone` 与 `email` **二选一**(都给或都不给 → 422):
|
||||
|
||||
```json
|
||||
{ "phone": "13800000000", "password": "dadao2026" }
|
||||
{ "email": "shufen@126.com", "password": "dadao2026" }
|
||||
```
|
||||
|
||||
返回与验证码登录同形状的 `{ user, tokens }`。**没设过密码的账号一律回 `2013`**,与"密码错"同一个码
|
||||
(不区分账号是否存在,少一个探测面;手机号与邮箱两种账号也不会互相暴露)。15 分钟内连错 10 次 → `2016`,
|
||||
期间正确密码也挡住,计数键是"手机号或邮箱"本身。
|
||||
|
||||
邮箱按 `lower(email)` 比较,绑定与登录都做 `strip` + 小写归一,所以 `Shufen@126.com` 与
|
||||
`shufen@126.com` 是同一个账号。
|
||||
|
||||
密码校验是 scrypt(`n=2^14, r=8, p=1`,标准库实现,不引 passlib),库里存
|
||||
`scrypt$n$r$p$salt$hash`。`password_hash` 这一列**永不出现在任何响应里**。
|
||||
|
||||
### `POST /auth/password`
|
||||
|
||||
```json
|
||||
{ "password": "dadao2026", "old_password": "old-one-1" }
|
||||
```
|
||||
|
||||
设密码 / 改密码共用。已经有密码时 `old_password` 必填且必须正确;新旧密码相同 → `2015`。
|
||||
强度:8–64 位,至少一个字母 + 一个数字。返回 `{ "has_password": true }`。
|
||||
|
||||
### `PATCH /auth/me`
|
||||
|
||||
```json
|
||||
{ "nickname": "大道", "email": "shufen@126.com" }
|
||||
```
|
||||
|
||||
字段都可选,只改传上来的。邮箱留空串表示**解绑**(之后不能再用邮箱登录)。
|
||||
格式不对 → `2018`,已被别的账号占用 → `2019`。返回 `{ "user": { … } }`。
|
||||
空昵称(strip 后为空)→ `1002`。手机号暂不支持自助换号。
|
||||
|
||||
### `GET /auth/me`
|
||||
|
||||
返回 `{ user, workspaces[] }`,`workspaces[].role` 是当前用户在该工作区的角色。
|
||||
`user` 里带 `has_password`(前端据此显示"设置密码"还是"修改密码")、`email`、
|
||||
`email_verified`(这个邮箱是不是真验过)、`platform_admin`(能不能进「系统设置」)。
|
||||
|
||||
---
|
||||
|
||||
## 1.5 系统设置 `/settings`
|
||||
|
||||
平台级配置,**全站一份、不挂租户**,只有 `PLATFORM_ADMIN_PHONES` 里的人能读写(否则 `3004`)。
|
||||
目前只有发信邮箱一项——它承载找回密码与以后的邮件通知,属于"改一次全站受影响"的东西,
|
||||
放后台可改而不用登服务器改 env。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/settings/smtp` | 当前配置(**不含授权码**) |
|
||||
| PUT | `/settings/smtp` | 保存,立即生效,不重启 |
|
||||
| POST | `/settings/smtp/test` | 发一封测试邮件,确认通道通不通 |
|
||||
|
||||
```json
|
||||
// PUT /settings/smtp
|
||||
{ "host": "smtp.126.com", "port": 465, "username": "shufenhf@126.com",
|
||||
"password": "客户端授权码", "from_name": "达道Task", "use_ssl": true }
|
||||
```
|
||||
|
||||
`password` 传 `null` 或空串表示**沿用旧值**(改别的字段时不用重填授权码)。
|
||||
|
||||
```json
|
||||
// GET /settings/smtp
|
||||
{ "host": "smtp.126.com", "port": 465, "username": "shufenhf@126.com",
|
||||
"password_set": true, "from_name": "达道Task", "use_ssl": true,
|
||||
"configured": true, "source": "db", "updated_at": "2026-11-02T12:00:00Z" }
|
||||
```
|
||||
|
||||
`source` 说明当前值来自数据库(`db`)、服务器环境变量兜底(`env`),还是根本没配(`none`)。
|
||||
发信失败统一 `2020`(`502`),消息里写清是认证失败还是收件人被拒——授权码要用邮箱服务商的
|
||||
**客户端授权码**,不是登录密码。
|
||||
|
||||
---
|
||||
|
||||
@@ -227,6 +364,20 @@ canceled→ todo (恢复)
|
||||
工单**不是**另一张表里的另一种东西,它是一条 `kind='ticket'` 的 item 加一行 `ticket_ext`。
|
||||
所以详情仍然走 `GET /items/{item_id}`,响应里多一个 `ticket` 字段。
|
||||
|
||||
### 4.0 `POST /tickets`(手工开单)
|
||||
|
||||
```json
|
||||
{ "title": "核对 9 月发票", "body_md": "可选说明",
|
||||
"assignee_id": "uuid", "priority": 2, "due_at": "2026-10-30T10:00:00+08:00" }
|
||||
```
|
||||
|
||||
返回与详情同构的工单对象(item + `ticket`)。与 4.1 的区别只有一处:**没有来源任务**,
|
||||
`ticket.parent_item_id` 为 `null`,因此验收通过后不会回流任何任务(但同样会沉淀一份知识草稿)。
|
||||
|
||||
- `title` 必填、`assignee_id` 必须是工作区内 `active` 成员,否则 `1001`。
|
||||
- 初始状态 `open`,被指派人立刻收到 `ticket_assigned` 通知。
|
||||
- 手工单与升级单共用同一条 `item`,所以看板、评论、验收、知识沉淀全部复用,不需要另一套接口。
|
||||
|
||||
### 4.1 `POST /items/{item_id}/promote`(任务升级成工单)
|
||||
|
||||
```json
|
||||
@@ -286,8 +437,26 @@ canceled→ todo (恢复)
|
||||
| POST | `/notifications/{id}/read` | 标一条 |
|
||||
| POST | `/notifications/read-all` | 全标已读 |
|
||||
|
||||
`kind`:`ticket_assigned` / `ticket_submitted` / `ticket_approved` / `ticket_rejected` / `task_unblocked`。
|
||||
通知只发给当事人,同工作区里其他人看不到——这是"协同"与"广播"的区别。
|
||||
`kind` 一共七种,都是"某人在某件事上做了什么":
|
||||
|
||||
| kind | 什么时候发 | 发给谁 |
|
||||
|---|---|---|
|
||||
| `task_assigned` | 建任务/改任务时指派了人 | 被指派人 |
|
||||
| `task_status_changed` | 任务状态变化 | 创建人 + 办理人(除操作者本人) |
|
||||
| `item_commented` | 有人评论 | 创建人 + 办理人(除评论者本人) |
|
||||
| `ticket_assigned` | 升级成工单 / 手工开单 / 改派 | 办理人 |
|
||||
| `ticket_submitted` | 办理人提交验收 | 需求人 |
|
||||
| `ticket_approved` / `ticket_rejected` | 验收通过 / 退回 | 办理人 |
|
||||
| `task_unblocked` | 工单验收通过、原任务回流待办 | 原任务创建人 |
|
||||
|
||||
三条硬规则:
|
||||
|
||||
1. **不通知自己**:谁操作就跳过谁,否则每个人都会收到自己刚做的事。
|
||||
2. 通知里必须写清"谁 + 做了什么 + 在哪件事上",否则点开等于重新看一遍列表。
|
||||
3. 通知只是提示,不是凭证——点开之后目标接口会重新校验权限、重新取数据。
|
||||
|
||||
Web 端未读数由 `AppShell` 每 30 秒轮询一次;在通知页标已读后立刻派发 `notifications:changed` 事件刷新角标,不必等下一轮。
|
||||
安卓端同样按 30 秒/切页刷新。
|
||||
|
||||
### 4.6 工单的错误码
|
||||
|
||||
@@ -310,22 +479,30 @@ canceled→ todo (恢复)
|
||||
| `status` | 可重复;`draft`/`in_review`/`published`/`archived`,不传为全部 |
|
||||
| `doc_type` | `note`/`howto`/`faq`/`postmortem`/`decision` |
|
||||
| `source_item_id` | 按来源事项筛 |
|
||||
| `category_id` | 按分类筛,**包含子分类** |
|
||||
| `uncategorized` | `true` 只看未分类 |
|
||||
| `tag` | 按标签名筛(不区分大小写) |
|
||||
| `q` | 标题 / 正文 / 摘要子串 |
|
||||
| `limit` | 1–100,默认 30 |
|
||||
| `cursor` | 上一页的 `next_cursor` |
|
||||
|
||||
返回 `{items, next_cursor, has_more}`。每条是 `item` 字段 + `knowledge` + `source`:
|
||||
返回 `{items, next_cursor, has_more}`。每条是 `item` 字段 + `knowledge` + `category` + `source`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "…", "kind": "knowledge", "title": "发票原件必须一起寄",
|
||||
"body_md": "…", "status": "published", "tags": [],
|
||||
"body_md": "…", "status": "published", "tags": [{"id": "…", "name": "财务"}],
|
||||
"knowledge": {
|
||||
"doc_type": "howto", "summary": "…", "status_hint": "published",
|
||||
"source_kind": "task", "source_item_id": "…", "generated_by": "human",
|
||||
"published_at": "2026-10-28T05:54:44Z", "archived_at": null,
|
||||
"chunk_count": 0, "author_id": "…"
|
||||
},
|
||||
"category": {
|
||||
"id": "…", "parent_id": "…", "name": "回滚", "depth": 3,
|
||||
"sort_order": 0, "version": 1,
|
||||
"path": ["研发", "部署", "回滚"], "full_path": "研发 / 部署 / 回滚"
|
||||
},
|
||||
"source": {"id": "…", "kind": "task", "title": "发票原件必须一起寄", "status": "done"}
|
||||
}
|
||||
```
|
||||
@@ -336,25 +513,76 @@ canceled→ todo (恢复)
|
||||
|
||||
### 5.2 `GET /knowledge/{item_id}`
|
||||
|
||||
详情,附最近 50 条 `activities`。不存在或跨租户统一 `7001`。
|
||||
详情,附最近 50 条 `activities`。不存在、跨租户、**已经删掉**的统一 `7001`
|
||||
(删除是软删,但对读的人来说就是"没了",否则收藏的链接还会打开一篇已删除的知识)。
|
||||
|
||||
### 5.2.1 `GET /knowledge/tags` — 侧栏那串标签
|
||||
|
||||
```
|
||||
GET /knowledge/tags?status=published&status=in_review
|
||||
```
|
||||
|
||||
```json
|
||||
{"items": [{"name": "发布", "count": 12}, {"name": "回滚", "count": 3}]}
|
||||
```
|
||||
|
||||
- `count` 是**有多少条知识用了它**(`count(distinct item_id)`),不是引用次数。
|
||||
- 只统计未删除的知识;带了 `status` 就只数这些状态,和列表的 tab 对得上。
|
||||
- 按条数从多到少排,同数按名字。标签是分类之外的第二根轴:分类回答"属于哪一块",标签回答"跟什么有关"。
|
||||
|
||||
### 5.2.2 `DELETE /knowledge/{item_id}?confirm=true` — 删除
|
||||
|
||||
- 必须显式带 `confirm=true`,否则 `1002`「删除不能撤销,带 confirm=true 再来一次」(防手滑)。
|
||||
- 只有**作者本人**或 `owner`/`admin` 能删,否则 `3004`。
|
||||
- 软删:`item.deleted_at` 落时间戳,列表、详情、检索、标签统计里都不再出现;
|
||||
**检索切片真删**(`chunk` 表),否则"删掉的知识还能被搜出来"比删不掉更吓人。
|
||||
- 重复删不回错误,回 `message: "已经删过了"`,让重试的客户端不必区分。
|
||||
|
||||
### 5.3 `POST /knowledge` — 手记或指定来源
|
||||
|
||||
```json
|
||||
{"title": "报销单要贴发票原件", "body_md": "…", "doc_type": "note",
|
||||
"tag_names": ["财务", "报销"], "category_id": "可选的分类 id",
|
||||
"source_item_id": "可选的来源事项 id"}
|
||||
```
|
||||
|
||||
- 不带 `source_item_id` → `source_kind=manual`(手记),允许没有来源。
|
||||
- 带 → 来源必须是同工作区的 `task`/`ticket`,否则 `7005`。
|
||||
- `category_id` 可省(未分类);跨工作区的分类一律按不存在处理(`7006`)。
|
||||
- `tag_names` 按名字去重创建,空格/逗号分隔由前端拆好再传。
|
||||
- 新知识一律 `draft`,不会直接对外可见。
|
||||
|
||||
### 5.4 `PATCH /knowledge/{item_id}` — 修改内容
|
||||
|
||||
必须带 `version`(乐观锁,冲突 `4009`)。可改 `title`/`body_md`/`summary`/`doc_type`。
|
||||
**已发布的不许直接改**(`7003`,先撤回审议或归档),避免"发布后被悄悄改写"。
|
||||
必须带 `version`(乐观锁,冲突 `4009`)。可改 `title`/`body_md`/`summary`/`doc_type`/`tag_names`/`category_id`;
|
||||
传 `clear_category: true` 表示移到未分类。
|
||||
|
||||
### 5.5 `POST /knowledge/{item_id}/status` — 状态动作
|
||||
**已发布的可以直接改**:改的是"团队现在看到的这一版",改完立刻重建检索切片(`chunk_count` 当场更新),
|
||||
避免"正文改了、搜出来还是旧句子"。版本号照旧 `+1`,历史仍能靠审计追。
|
||||
**已归档的不许直接改**(`7003`,`allowed_actions: ["restore"]`):先恢复成草稿再改,
|
||||
免得改完还挂着"现役"的样子。
|
||||
|
||||
### 5.5 知识分类 `/knowledge/categories`
|
||||
|
||||
分类是工作区级的三级小树,用来给知识安一个位置;它**不是权限边界**,只是归类。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/knowledge/categories` | 扁平列表,但按**树的先序**排好(父分类后紧跟它的子分类,兄弟按 `sort_order`、`name`),前端照 `depth` 缩进即可;每条带 `doc_count`、`path`、`full_path`;另有 `max_depth=3` |
|
||||
| POST | `/knowledge/categories` | `{name, parent_id?, sort_order?}` |
|
||||
| PATCH | `/knowledge/categories/{id}` | `{name?, parent_id?, move?, sort_order?, version}` |
|
||||
| DELETE | `/knowledge/categories/{id}?force=false` | 有子分类不让删;有知识要 `force=true`,删后知识变未分类 |
|
||||
|
||||
三条规则:
|
||||
|
||||
1. **最多三级**。第四级不是"更细",是"找不到",创建/移动越界一律 `7007`。
|
||||
2. **同一层不能重名**(不区分大小写),否则 `7008`;不同层可以同名。
|
||||
3. **移动带着子树走**:把一棵三层树挪到第二层下面会让最深节点到第五层,这种移动直接拒绝(`7007`),
|
||||
不静默改层级。
|
||||
|
||||
`PATCH` 里 `move=true` 才处理 `parent_id`,避免"只改名字"被误当成移动。改名的 `version` 冲突是 `4009`。
|
||||
|
||||
### 5.6 `POST /knowledge/{item_id}/status` — 状态动作
|
||||
|
||||
| action | 允许的前置状态 | 结果 | 谁能做 |
|
||||
|---|---|---|---|
|
||||
@@ -369,7 +597,7 @@ canceled→ todo (恢复)
|
||||
|
||||
**发布要 owner/admin 的理由**:发布等于团队为这条结论背书,不能谁都能推。
|
||||
|
||||
### 5.6 `POST /items/{item_id}/knowledge` — 把一件事沉淀成知识
|
||||
### 5.7 `POST /items/{item_id}/knowledge` — 把一件事沉淀成知识
|
||||
|
||||
Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;工单会带出验收结论。
|
||||
返回与 5.2 相同的知识对象;同一来源重复沉淀 → `7002`。
|
||||
@@ -377,7 +605,45 @@ Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;
|
||||
**工单验收通过时,后端会自动调用这条逻辑**(`generated_by=rule`,`doc_type=postmortem`,
|
||||
正文开头固定一句"这份草稿由系统根据来源自动整理,请先核对再发布"),所以用户不需要记得点。
|
||||
|
||||
### 5.7 知识的错误码
|
||||
### 5.8 检索 `GET /knowledge/search`
|
||||
|
||||
Query:
|
||||
| 参数 | 说明 |
|
||||
|---|---|
|
||||
| `q` | 必填,1–200 字 |
|
||||
| `limit` | 1–30,默认 10 |
|
||||
| `mode` | `hybrid`(默认)/ `vector` / `keyword`,后两个用于对比效果 |
|
||||
|
||||
返回 `{query, mode, items[]}`,每项:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `item_id` / `title` | 来源知识,前端"打开原文"用 |
|
||||
| `doc_type` / `heading` | 类型与所在小节 |
|
||||
| `idx` / `content` | 第几片、切片正文 |
|
||||
| `score` | 加权总分,低于 `SEARCH_MIN_SCORE`(0.06)的结果**不返回** |
|
||||
| `vector_score` / `lexical_score` | 向量分与 trigram 词法分,便于排查"为什么没搜到" |
|
||||
|
||||
三条约定:
|
||||
|
||||
1. **只检索 `published`**。草稿是还没被人认可的话,进检索等于把猜测当结论引用出去——过滤写在 SQL 里,调用方改不了。
|
||||
2. **结果必须带来源**,能点回原文。这是 D3(知识必须有来源)的延伸:不给"出处不存在"的答案。
|
||||
3. **宁可返回空,也不凑数**。检索失败比误导便宜。
|
||||
|
||||
发布(`5.6` 的 `publish`)时会自动重建索引,用户发布完立刻能搜到;
|
||||
但改完正文不会自动重建,需要调用下面这条。
|
||||
|
||||
### `POST /knowledge/{item_id}/reindex` — 重建切片
|
||||
|
||||
返回 `{item_id, chunk_count, searchable}`。`searchable=false` 表示内容已建好切片但还不是 `published`,
|
||||
搜不到——这不是错误。
|
||||
|
||||
实现:切片存 `chunk` 表(`content` + `embedding real[]`),相似度用 SQL 函数 `app_cosine()` 计算。
|
||||
本机没有可用的 pgvector 实例(Docker 起不来,fnsvr-01 明确不动),所以先用 `real[]` 把链路跑通;
|
||||
将来换 pgvector 只改列类型与索引,接口不变。换 embedding 模型(`EMBEDDING_PROVIDER`)后
|
||||
**必须整体重建**,否则新旧向量混在一张表里,分数没法比较。
|
||||
|
||||
### 5.9 知识的错误码
|
||||
|
||||
| 码 | HTTP | 含义 |
|
||||
|---|---|---|
|
||||
@@ -386,10 +652,223 @@ Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;
|
||||
| 7003 | 409 | 当前状态不允许这个动作 |
|
||||
| 7004 | 403 | 权限不足(如非管理员发布) |
|
||||
| 7005 | 409 | 来源事项无效或缺失 |
|
||||
| 7006 | 404 | 分类不存在,或不是本工作区的 |
|
||||
| 7007 | 422 | 分类层级超过三级,或移动后会让子树超过三级 |
|
||||
| 7008 | 409 | 分类重名 / 还有子分类 / 分类下还有知识(需 `force`) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 健康与版本
|
||||
## 6. 附件与富文本
|
||||
|
||||
### 6.1 存储格式:Markdown + 附件表
|
||||
|
||||
正文类字段(任务 `body_md`、工单说明、知识正文、评论、验收结论)**一律存 Markdown**,不存 HTML。
|
||||
理由:能导出、能全文检索、能直接喂给 LLM;HTML 每次渲染都要重新清洗一遍,是隐患。
|
||||
|
||||
图片不是 base64 塞进正文,而是走附件接口拿地址。**库里存的是不带凭据的相对路径**:
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| POST | `/workspaces/{ws_id}/items/{item_id}/attachments` | multipart,字段名 `file`;返回 201 + 附件对象 |
|
||||
| GET | `/workspaces/{ws_id}/items/{item_id}/attachments` | `{items: [...]}`,每条含 `raw_url` |
|
||||
| GET | `/workspaces/{ws_id}/attachments/{id}/download` | 下载(`Content-Disposition: attachment`) |
|
||||
| GET | `/workspaces/{ws_id}/attachments/{id}/raw?exp=&sig=` | 直链,给 `<img src>` 用(签名见 6.2) |
|
||||
| DELETE | `/workspaces/{ws_id}/attachments/{id}?confirm=true` | 上传者或管理员可删;不传 confirm 报 `8004` |
|
||||
|
||||
附件对象的字段:`id / item_id / filename / content_type / size / sha256 / uploader_id / created_at / raw_url / can_delete`。
|
||||
`raw_url` 是**后端签发的短期直链**(默认 30 分钟),客户端直接用它渲染,不需要自己拼令牌。
|
||||
附件可挂在 `kind` 为 `task / ticket / knowledge` 的事项上(`ALLOWED_KINDS`),知识与任务、工单共用同一套接口;
|
||||
事项与知识的列表接口会附带 `attachment_count`,详情接口附带 `attachments` 全量。
|
||||
|
||||
新建知识时条目还不存在,前端用「懒建草稿」:点上传(或往正文里贴图)时先 `POST /knowledge` 存一条草稿拿到 `item_id`,
|
||||
再上传到 `/items/{item_id}/attachments`,之后的保存一律走 `PATCH /knowledge/{item_id}`。没传过附件就取消的人,库里不会留下空条目。
|
||||
|
||||
### 6.2 图片直链:短期签名,而不是把令牌写进正文
|
||||
|
||||
`<img>` 发不出 `Authorization` 头。两种解法:把图片读成 base64 内联(正文膨胀几十倍,破坏检索),
|
||||
或者给一个短期签名地址。取后者:
|
||||
|
||||
- `raw_url` 形如 `…/raw?exp=1730000000&sig=<hmac>`,签名载荷是 `workspace_id:attachment_id:exp`,
|
||||
密钥从 `JWT_SECRET` 域分离派生(`attachment-raw-v1`),不与登录令牌共用。
|
||||
- 校验只看签名与过期时间,**不需要用户已登录**;签名里绑了 `workspace_id`,A 工作区签的链接
|
||||
打到 B 工作区会被拒(`8003`)。
|
||||
- 过期不宽限,刷新页面即可拿到新链接。
|
||||
- 兼容旧路径:不带 `sig` 时仍可用 `?token=<access>` 走成员校验,供老客户端过渡。
|
||||
|
||||
为什么非要这样:令牌写进正文会落库,几天后过期就是满篇红叉;用户换设备后旧令牌失效,图片全挂。
|
||||
把凭据放在"每次渲染时现签"的 URL 上,正文才能长期存活。
|
||||
|
||||
### 6.3 附件错误码
|
||||
|
||||
| 码 | HTTP | 含义 |
|
||||
|---|---|---|
|
||||
| 8001 | 404 | 附件不存在,或不属于这个工作区 |
|
||||
| 8002 | 413 | 超过 `MAX_UPLOAD_MB`(默认 20) |
|
||||
| 8003 | 403 | 不是上传者也不是管理员 |
|
||||
| 8004 | 409 | 需要 `?confirm=true`(删除是破坏性动作) |
|
||||
|
||||
### 6.4 前端约定
|
||||
|
||||
- 编辑器写 Markdown,工具栏/快捷键 `Ctrl+B`、`Ctrl+I` 只管插入语法;预览用 `renderMarkdown()`
|
||||
渲染(先转义再上标签,链接协议白名单 `http(s)/mailto//`)。
|
||||
- 粘贴或拖入图片时:先上传 → 拿到 `id` → 在光标处插入 `)`。
|
||||
- 上传接口自己带 `Idempotency-Key`,**不要**手动设 `Content-Type`(浏览器要自己带 boundary)。
|
||||
|
||||
---
|
||||
|
||||
## 6.5 外部接入(微信 clawbot,页面叫「机器人」)
|
||||
|
||||
微信里那个机器人连上本服务之后,发一句话就变成任务或知识草稿。
|
||||
走的是腾讯 iLink Bot 协议:**扫码登录 → 后端长轮询收消息 → 用 context_token 回复**。
|
||||
所以这一组里只有「扫码」是免登录的(机器人自己不带我们的令牌),其余都要工作区 admin 以上。
|
||||
|
||||
### 6.5.1 扫码与通道(工作区 admin 以上)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/workspaces/{ws}/integrations` | 列出通道(含 `status`、`connected`、`polling`、`last_seen_at`、`last_error`) |
|
||||
| POST | `/workspaces/{ws}/integrations/wechat/qr` | 取一张二维码,返回 `{channel_id, qrcode_id, payload, image, expires_in}` |
|
||||
| GET | `/workspaces/{ws}/integrations/wechat/qr/{qrcode_id}` | 问扫码结果:`waiting`/`scaned`/`confirmed`/`expired` |
|
||||
| PATCH | `/workspaces/{ws}/integrations/{id}` | `{"status":"active"|"disabled"}`,停用就停轮询 |
|
||||
| DELETE | `/workspaces/{ws}/integrations/{id}?confirm=true` | 断开:清掉登录态与游标,回到 `pending` |
|
||||
| GET | `/workspaces/{ws}/integrations/{id}/messages?limit=5` | 最近收到的消息与它变成了什么 |
|
||||
|
||||
取二维码的响应(`image` 是可以直接塞进 `<img>` 的 data URL,本地渲染,不是图片地址):
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_id": "4f0a…",
|
||||
"qrcode_id": "30c97db83291e779217cbe20c5373e91",
|
||||
"payload": "https://liteapp.weixin.qq.com/q/7GiQu1?qrcode=30c97…&bot_type=3",
|
||||
"image": "data:image/svg+xml;base64,PD94bWw…",
|
||||
"expires_in": 300
|
||||
}
|
||||
```
|
||||
|
||||
扫码成功那一次(`status == "confirmed"`)除了回 `status`,还带 `channel`:登录态已加密存进
|
||||
`bot_channel.bot_token_enc`(Fernet,密钥由 `JWT_SECRET` 派生),**不会回给任何接口**,轮询任务同时被拉起。
|
||||
|
||||
`status` 的四个值:`pending`(还没连)、`active`(连上了、正在收)、`disabled`(管理员停用)、
|
||||
`expired`(登录态失效,需要重新扫码)。`polling` 说明后台任务此刻是否在跑。
|
||||
|
||||
### 6.5.2 消息怎么变成事项(不分接口,轮询里做)
|
||||
|
||||
给机器人发的话这样理解:
|
||||
|
||||
| 开头 | 落成什么 |
|
||||
|---|---|
|
||||
| `任务:`/`待办:`/`todo:` 或不带前缀 | 任务(`status = "inbox"`) |
|
||||
| `知识:`/`笔记:`/`note:`/`kb:` | 知识草稿(`status = "draft"`,`doc_type = "note"`) |
|
||||
| `帮助`/`?`/`help` | 只回一段用法,不建任何东西 |
|
||||
|
||||
正文第一行当标题(超过 180 字截断),其余当正文;`#标签` 会被摘出来加到事项上(纯数字的不算标签)。
|
||||
建完之后会用 `context_token` 在微信里回一句「已保存任务:标题」/「已保存知识草稿:标题」。
|
||||
**回话里不带链接**:站点地址对手机不一定打得开(内网 IP、端口、还没上 HTTPS 的域名),
|
||||
发过去只会让人以为崩了。
|
||||
同一条消息(按 `message_id`)重复推送只会落一次,重推直接跳过。
|
||||
|
||||
图片/文件/视频目前只在 `bot_message.raw` 里留痕,正文不搬;语音如果服务端给了转写就用转写文本。
|
||||
|
||||
### 6.5.2.1 发一条链接:抓正文存成知识
|
||||
|
||||
整个消息就是一条 `http://` / `https://` 链接时(前后带标点不算),不当任务,而是去把文章抓下来:
|
||||
|
||||
| 环节 | 行为 |
|
||||
|---|---|
|
||||
| 判断 | `extract_url()`:去掉首尾标点后只剩一个链接;带了别的话("看下 https://… 这个")仍按任务处理 |
|
||||
| 抓取 | `httpx` 取页面(25 秒超时、8 MB 上限),`trafilatura` 抽正文;标题取 `og:title`/`<h1>`/`<title>` |
|
||||
| 公众号(旧页) | `mp.weixin.qq.com` 且页面有 `#js_content`:标题 `#activity-name`、作者 `#js_name`、正文 `#js_content`,图片取 `data-src`(通用抽取器会把公众号的图丢光) |
|
||||
| 公众号(新版页) | 没有 `#js_content` 时(`appmsg_type = '9'` / `'10002'`)整篇在 `window.cgiDataNew` 里:从 `content_noencode` / `desc` 的 JS 字符串字面量读正文,按 `\xNN`、`\uNNNN` 反转义,话题标签先 `html.unescape` 再走 DOM;配图字段(`cdn_url`/`img_url`/`pic_url`)排在文字之后,前后各找 2500 字符合图;作者兜底取 `nick_name` |
|
||||
| 清洗 | 导航/页脚按行边界裁掉(`_trim_chrome`);`trafilatura` 结果太短(<120 字)就退到容器打分兜底 |
|
||||
| 配图 | 正文里的外链图下载进我们自己的存储,最多 12 张、单张 8 MB,正文改写成 `/attachments/{id}/raw?签名`——公众号一换图,外链就是一排红叉 |
|
||||
| 落库 | 知识草稿(`status="draft"`,`doc_type="note"`),正文首行是 `> 原文:<url>` |
|
||||
| 回话 | `已存成知识草稿:标题`(存了图再加「,连图 N 张」) |
|
||||
| 失败 | `这个链接没抓下来(原因)。想直接记成任务的话,加「任务:」前缀再发一次。` |
|
||||
|
||||
安全:抓取入口对任何能给机器人发消息的人开放,所以**只允许公网地址**——`127.0.0.1`、内网段、
|
||||
链路本地、保留地址、解析不出来的域名一律拒绝(`ArticleError`),否则就是个 SSRF 跳板。
|
||||
反爬/需登录的页面(微信"环境异常"验证页、Cloudflare 302、SPA 空壳)会回上面那句失败提示,不静默吞掉。
|
||||
|
||||
### 6.5.3 错误码
|
||||
|
||||
| 场景 | code | HTTP |
|
||||
|---|---|---|
|
||||
| 取二维码 / 问状态时微信返回错误 | `9002` | 404 |
|
||||
| 通道不存在、或不属于这个工作区 | `9002` | 404 |
|
||||
| 登录态失效(微信 errcode `-14`) | —(通道转 `expired`,接口正常返回) | 200 |
|
||||
|
||||
### 6.5.4 表与隔离
|
||||
|
||||
`bot_channel`(通道、登录态、游标)与 `bot_message`(收到的消息留痕)都挂 `workspace_id`,
|
||||
走标准 RLS。轮询任务从 `channel_id` 起步、此刻还没有工作区上下文,所以迁移 `0014`
|
||||
额外给了一条 `app.bot_channel` 的按主键口子(permissive 策略,与原隔离策略 OR)。
|
||||
|
||||
|
||||
## 7. 安卓发布与升级 `/app`
|
||||
|
||||
安装包不进版本库:`backend/static/apk/` 由 `publish_apk.py` 写入,里面有 APK 与一份
|
||||
`latest.json` 清单。接口只读这份清单,**没有清单就等于没发布过**(9001)。
|
||||
|
||||
### 7.1 `GET /app/release/latest`
|
||||
|
||||
| 参数 | 说明 |
|
||||
|---|---|
|
||||
| `version_code` | 可选,客户端当前版本号;给了才会算 `has_update` |
|
||||
|
||||
免登录:老版本客户端可能连"登录"这一步都还没走到,检查更新不该需要令牌。
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"platform": "android",
|
||||
"version_code": 2,
|
||||
"version_name": "0.2.0",
|
||||
"notes": "新增应用内升级;图标换成和 Web 一样",
|
||||
"file": "dadaotask-0.2.0.apk",
|
||||
"size": 8123456,
|
||||
"sha256": "…",
|
||||
"min_sdk": 26,
|
||||
"published_at": "2026-10-31T03:20:00Z",
|
||||
"download_url": "/api/v1/app/release/download/dadaotask-0.2.0.apk",
|
||||
"has_update": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`download_url` 是**相对路径**:同一份清单要被内网、公网、模拟器三个入口共用,
|
||||
写死域名只会让其中一个坏掉;客户端用自己配置的 API 地址拼。
|
||||
|
||||
`has_update` 的判据只有 `version_code`,不看版本名字符串——字符串比较会把
|
||||
`0.10.0` 判成比 `0.9.0` 旧。
|
||||
|
||||
### 7.2 `GET /app/release/download/{file}`
|
||||
|
||||
只认清单里登记过的文件名,其它一律 9001(含 `..%2F` 这类路径穿越尝试)。
|
||||
返回 `application/vnd.android.package-archive`。同样免登录:下载地址本身就是
|
||||
清单里给出的,能拿到清单就说明已经过了一道。
|
||||
|
||||
### 7.3 发布包的约定
|
||||
|
||||
- APK 必须用**同一把证书**签名,否则新包装不上去(系统只会报"应用未安装")。
|
||||
证书放在发布者本机(`android/keystore.properties` 指向它),不进仓库。
|
||||
- 上传清单带 `sha256`,客户端下完先校验再交给系统安装器:内网代理缓存旧包时,
|
||||
这是唯一能挡住"装了个旧版本"的地方。
|
||||
- 「允许安装未知应用」是系统开关,App 只能把用户送到设置页,不能代点。
|
||||
|
||||
### 7.4 错误码
|
||||
|
||||
| 码 | HTTP | 含义 |
|
||||
|---|---|---|
|
||||
| 9001 | 404 | 还没发布过安装包,或下载的文件不在清单里 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 健康与版本
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# 部署(生产 · 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`。
|
||||
|
||||
两个踩过的坑:
|
||||
|
||||
- **`CORS_ORIGINS` 必须写成 JSON 数组**(`'["http://...","http://..."]'`)。它是 list 字段,逗号分隔的字符串会让 pydantic-settings 当 JSON 解析并抛 `SettingsError`,alembic 这一步就挂。
|
||||
- **Redis 有口令**:fnsvr-01 的 redis 开了 `requirepass`(口令看 `docker inspect redis`),写进 URL 时要百分号编码(`#` → `%23`),否则连不上、发码/登录直接 500。
|
||||
|
||||
**当前状态是内测模式**:短信通道还没接(非 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. 安卓安装包发布(OTA)
|
||||
|
||||
APK 不进版本库,也不走 `deploy.sh`:代码发布和"发一个安装包"是两件事,
|
||||
前者每次上线都做,后者只在安卓端有改动时做。
|
||||
|
||||
### 5.1 出包(本机)
|
||||
|
||||
```bash
|
||||
cd android
|
||||
export JAVA_HOME=<JDK 17> # AGP 8.7 不认 JDK 25
|
||||
export GRADLE_USER_HOME=<可写的 gradle 目录>
|
||||
gradle -Dorg.gradle.jvmargs="-Djava.io.tmpdir=<可写 tmp>" assembleRelease
|
||||
# 产物:android/app/build/outputs/apk/release/app-release.apk
|
||||
```
|
||||
|
||||
- 版本号在 `android/app/build.gradle.kts`:`versionCode` 是 OTA 的唯一判据,**每次发版必须 +1**,
|
||||
否则客户端查不到更新;`versionName` 只是给人看的。
|
||||
- 签名证书在 `android/keystore.properties`(本机文件,`.gitignore` 已排除)指向的 `.jks`。
|
||||
**换证书等于换应用**:装了旧包的手机再也装不上新包,只能卸载重装。
|
||||
- `dadao.apiBase`(`android/local.properties`)决定包里写死的后端地址。正式包指向
|
||||
`http://192.168.8.188:8200`,模拟器调试包指向 `http://10.0.2.2:8100`。
|
||||
|
||||
### 5.2 发布清单
|
||||
|
||||
```bash
|
||||
python backend/scripts/publish_apk.py android/app/build/outputs/apk/release/app-release.apk \
|
||||
--version-code 2 --version-name 0.2.0 --notes "新增应用内升级;图标与 Web 一致"
|
||||
```
|
||||
|
||||
脚本把 APK 复制成 `backend/static/apk/dadaotask-<版本>.apk`,并写 `latest.json`
|
||||
(含 `sha256`、`size`、`published_at`)。接口读的就是这份清单。
|
||||
|
||||
### 5.3 传到服务器
|
||||
|
||||
```bash
|
||||
scp backend/static/apk/* openclaw-01:~/app/dadaotask/backend/static/apk/
|
||||
```
|
||||
|
||||
`deploy.sh` 的 `git reset --hard` 不会删未跟踪文件,所以已发布的 APK 在服务器上能留住;
|
||||
但换了机器或清了目录就要重新传一次。**不需要重启服务**:接口每次请求都现读清单与文件。
|
||||
|
||||
### 5.4 验证
|
||||
|
||||
```bash
|
||||
curl -s "http://192.168.8.188:8200/api/v1/app/release/latest?version_code=1"
|
||||
# data.version_code 为新版号、has_update=true、download_url 可下载
|
||||
curl -sI http://192.168.8.188:8200/api/v1/app/release/download/dadaotask-0.2.0.apk | head -1 # 200
|
||||
```
|
||||
|
||||
手机端:装上新包 → 下次启动自动查一次;也可以在首页底部点「检查更新」。
|
||||
装完再启动,`version_code` 已是最新,接口返回 `has_update=false`,不会再弹。
|
||||
|
||||
## 6. Web 装到桌面(PWA)
|
||||
|
||||
前端已经打成 PWA:`/manifest.webmanifest` 与 `/sw.js` 由后端静态层直接返回(`_SPAStaticFiles` 挂在 `/`,
|
||||
不需要额外配置),`node_modules/.bin` 之外没有额外依赖。发布流程不变。
|
||||
|
||||
**装的条件是安全上下文**——浏览器只允许 `https` 或 `localhost` 注册 Service Worker,也就是说:
|
||||
|
||||
| 入口 | 能不能装 |
|
||||
|---|---|
|
||||
| `https://<域名>/` | 能,手机和电脑都会出现安装入口 |
|
||||
| `http://127.0.0.1:8200/`(本机打开) | 能,仅限这台机器 |
|
||||
| `http://192.168.8.188:8200/` | **不能**,非安全上下文:不弹安装、SW 也不注册 |
|
||||
|
||||
内网 IP 目前就是这种情况,所以真要在手机上装到桌面,得给入口套一层 HTTPS:
|
||||
在能装 nginx 的机器上(或 fnsvr-01 的 1Panel OpenResty)加一个 `443 → 127.0.0.1:8200` 的反代就好,
|
||||
应用本身不用改;示例见 `deploy/prod/nginx.dadaotask.conf.example`。也可以用 Cloudflare Tunnel 之类的
|
||||
隧道拿一个 https 域名,同样不用碰应用代码。
|
||||
|
||||
装好之后的两个行为:
|
||||
|
||||
- **图标**:来自 `web/public/icons/`(any + maskable 两套,iOS 吃 `apple-touch-icon.png`),
|
||||
与 Web 页头、安卓桌面图标是同一枚。
|
||||
- **发版提示**:SW 采用 `registerType: "prompt"`,检测到新版本只在页面底部提示「有新版了」,
|
||||
用户点「刷新」才切换。**故意不自动刷新**:Vditor、图表是懒加载分片,后台把 chunk 换掉之后,
|
||||
用户下一次点开面板就是 404(编辑器白屏)。
|
||||
- **离线**:只预缓存页面骨架(构建产物 + 图标,约 0.8MB),`/api/` 一律 NetworkOnly——
|
||||
宁可报错,也不拿旧数据糊弄人。
|
||||
|
||||
## 7. 运维
|
||||
|
||||
```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` 默认值,两处要一致)
|
||||
|
||||
## 8. 已知限制(当前阶段)
|
||||
|
||||
| 限制 | 影响 | 解除方式 |
|
||||
|---|---|---|
|
||||
| 没有 HTTPS / 域名 | 只能内网 IP 访问,令牌明文传输;**PWA 装不上**(非安全上下文) | 域名 + nginx + 证书(需 root) |
|
||||
| 内测固定验证码 | 谁都能用任意手机号登录 | 接短信通道,关 `INTERNAL_TEST_LOGIN` |
|
||||
| 无 pgvector | 语义检索走 `real[]` 全表扫描,数据量大了会慢 | 上独立 pgvector 实例(`deploy/docker-compose.vector.yml`) |
|
||||
| 上传目录在本机磁盘 | 机器坏了附件就没了 | 备份 `backend/var/uploads`,或将来到对象存储 |
|
||||
| 共享 PG / Redis | 别人占满连接或内存会波及本站 | 独立实例 |
|
||||
@@ -182,6 +182,8 @@ release 仍走白名单,线上域名必须 HTTPS。
|
||||
| 迁移报 `cannot insert multiple commands into a prepared statement` | asyncpg 走预备语句,一次只能一条 SQL | 多语句 DDL 拆成每语句一次 `op.execute()` |
|
||||
| 源文件里的中文变成 `宸ュ崟鍩?` | 用 PowerShell `Get-Content`/`Set-Content` 批量改文件:读时按 GBK 解码,写回 UTF-8,不可逆 | **含中文的源文件只能用编辑器/`edit` 工具改**;真要写脚本,必须显式 `[IO.File]::ReadAllText($p, [Text.Encoding]::UTF8)` + `WriteAllText(..., UTF8Encoding($false))` |
|
||||
| `npm` 报 `无法加载文件 npm.ps1,因为在此系统上禁止运行脚本` | PowerShell 执行策略 | 用 `npm.cmd`,或直接 `node node_modules/vite/bin/vite.js build` |
|
||||
| 富文本编辑器一直转圈、没有工具栏 | `web/public/vditor` 缺失(它被 gitignore,由脚本生成) | `cd web && npm run vendor`,或走一遍 `predev`/`prebuild` |
|
||||
| 编辑器能开但排版是源码、不是所见即所得 | `lute.min.js` 没自托管成功,Vditor 退回纯文本模式 | 打开控制台看 `/vditor/dist/js/lute/lute.min.js` 是不是 404 |
|
||||
|
||||
### 团队共享开发库(fnsvr-01:8432)
|
||||
|
||||
|
||||
@@ -58,7 +58,8 @@
|
||||
- [x] 手机号 + 验证码注册/登录(dev 固定码 `123456` 并回显;`ENV=prod` 禁止 `SMS_PROVIDER=dev`)
|
||||
- [x] JWT:**access 30min / refresh 14d**(计划里写的 15min/30d,实际取宽了一点,理由是移动端弱网刷新成本高)
|
||||
- [x] 发码节流(`SMS_THROTTLE_SECONDS`,默认 60s,测试可置 0)
|
||||
- [ ] 邮箱 + 密码(argon2)(**未做**,M1 有空再说;手机号登录先行)
|
||||
- [x] 手机号 + **密码**登录(可选,第二把钥匙:设过密码就能用密码进,没设过的一律回 `2013`;scrypt 哈希,15 分钟连错 10 次 → `2016`)
|
||||
- [ ] 邮箱 + 密码(**未做**,M1 有空再说;手机号登录先行)
|
||||
- [ ] refresh 令牌落库可撤销(**未做**,当前是无状态 JWT;被移出团队的成员靠"每次请求查 member 表"兜底,refresh 本身不可撤销)
|
||||
- **验收**:✅ 注册 → 登录 → 刷新 → `/auth/me` 全链路通;错误码 2010/2011/2012 语义清晰
|
||||
|
||||
@@ -189,8 +190,8 @@
|
||||
- [ ] 下拉刷新、只读缓存(Room)未做
|
||||
|
||||
### 1.6 通知与提醒(0.8d)
|
||||
- [ ] 站内通知表与未读角标 —— **主动推迟到 M2**:通知的绝大多数触发源是工单(被指派、被催、验收),
|
||||
M1 只有自己的任务,做出来也没人收到东西。表随 M2 的工单一起建,避免建两次。
|
||||
- [x] 站内通知表与未读角标 —— 随 M2 一起落地,七类触发(指派、改状态、评论、工单全流程);
|
||||
未读角标 30 秒轮询,标已读立刻刷新。原计划"推迟到 M2",M2 已兑现。
|
||||
- [ ] 每日摘要邮件(同上推迟)
|
||||
|
||||
### 1.7 测试与验收(0.7d)
|
||||
@@ -230,6 +231,8 @@
|
||||
- [x] `POST /items/{id}/promote`、`/ticket/{accept,start,submit,verify,reassign,cancel}`
|
||||
- [x] `GET /tickets?role=assigned|requested|all&status=`
|
||||
- [x] `GET|POST /items/{id}/links`、`DELETE /items/{id}/links/{link_id}`
|
||||
- [x] `POST /tickets` 手工开单:不经过任务直接指定人,初始 `open`,与升级单共用同一条 item
|
||||
- [x] 指派前校验"在册成员"(`_ensure_member`),否则任务会挂在一个没人看得到的名字上
|
||||
- [x] `GET /notifications`、`/unread-count`、`/read`、`/read-all`
|
||||
- [x] 抽出 `app/serializers.py`:任务与工单共用一份序列化,**根除"工单响应缺 tags 导致前端白屏"**
|
||||
|
||||
@@ -238,17 +241,27 @@
|
||||
- [x] 详情抽屉:按角色显示受理、开工、提交、验收、驳回、改派、取消;工单态顶部显示需求人/办理人/退回次数
|
||||
- [x] 任务详情底部"升级成工单"面板(从成员里选人,成功后提示"这条任务现在处于被阻塞")
|
||||
- [x] 导航加未读角标(30 秒对齐一次,操作后立刻刷新)
|
||||
- [x] 通知页 `/w/:wsId/notifications`:未读 / 全部两档,点开即标已读并跳到目标;深链 `?open=` 落地即展开那一条
|
||||
- [x] 工单页"新建"面板:手工开单(选人、优先级、说明),成功切到"我发起的"并展开
|
||||
- [x] 登录支持 `?next=` 回跳,邀请链接不再"登录完掉回首页"
|
||||
|
||||
### 2.5 安卓(1.5d)
|
||||
- [ ] 工单列表与动作 —— 只做到任务列表;本机无 JDK/Gradle,仍是**未编译**状态
|
||||
- [x] 工单列表与动作 —— 仅按"我是需求人 / 办理人 / 管理员"显隐动作按钮,不再人人可见
|
||||
- [x] 通知页与手工开单:五个 tab、未读数 30 秒刷新、通知点开跳列表并清标记
|
||||
- [ ] 本次未编译验证:本机 JDK 已升级到 25,Gradle 8.7 / AGP 8.7.3 不兼容,需装回 JDK 17 再 `assembleDebug`
|
||||
|
||||
### 2.6 测试与验收(0.7d)
|
||||
- [x] `backend/tests/test_tickets.py` 11 个用例:升级建单+原任务阻塞+通知、全流程验收回流、驳回、
|
||||
自验 403、非法流转、重复升级、非成员指派、角色作用域、跨租户、关系挂载、通知已读、**响应必带 tags**
|
||||
- [x] 全量 `pytest -q` → **57 passed**;`ruff check` / `format --check` 全 0
|
||||
- [x] `backend/tests/test_notifications.py` 6 个用例:改状态只通知对方(自己不动自己)、指派必须是成员、
|
||||
手工单全流程(开出→被指派人收到→受理/提交→验收,且没有来源任务)、越界指派 400/1001、
|
||||
评论通知对方、未读数与全部已读
|
||||
- [x] 全量 `pytest -q` → **85 passed**;`ruff check` / `format --check` 全 0
|
||||
- [x] `web/e2e/tickets.mjs` 双浏览器上下文双用户 e2e,8 项断言全绿:
|
||||
升级 → B 受理/开工/提交 → A 验收通过 → 原任务回流"待办" → 两端控制台零报错
|
||||
- [x] `web/e2e/notifications.mjs` 12 项断言全绿:A 建工作区邀 B → B 自动加入 → A 手工开单 →
|
||||
这张单没有来源任务 → B 收到"派给你的工单" → 通知列表有条目 → 点通知跳到工单并展开 →
|
||||
未读数归零 → 两端控制台零报错
|
||||
- [x] `web/e2e/smoke.mjs` 回归复跑(抽屉多了"选人"下拉后修正选择器)7 项全绿
|
||||
|
||||
### 2.7 通知真实投递
|
||||
@@ -272,21 +285,39 @@
|
||||
| 3.5 | 手动沉淀:任务/工单详情里"沉淀成知识" | 0.5 | [x] 有 e2e |
|
||||
| 3.6 | 手记:没有来源的知识(唯一允许 `source_kind=manual` 的入口) | 0.5 | [x] 手记页 + 列表 |
|
||||
| 3.7 | 检索:标题/正文/摘要子串 + 类型/状态筛选 + 游标分页 | 1.0 | [x] trigram 索引在 0002 已建 |
|
||||
| 3.8 | 切片与向量索引(`chunk` 表、pgvector HNSW) | 2.0 | [ ] 见下方说明 |
|
||||
| 3.9 | 安卓知识列表 | 1.0 | [ ] 与工单一起延后到编译链就绪 |
|
||||
| 3.10 | 文档与 e2e | 1.0 | [x] `web/e2e/knowledge.mjs` 7 项断言绿 |
|
||||
| 3.8 | 切片与向量索引(`chunk` 表、pgvector HNSW) | 2.0 | [x] **用 `real[]` + SQL 余弦先跑通**(见下方说明) |
|
||||
| 3.8.1 | 切片:按标题归段、代码块整块保留、超长按句切、相邻片重叠 80 字 | 0.5 | [x] `app/domain/chunking.py` |
|
||||
| 3.8.2 | 向量:`hash`(字符二元组)与 `openai` 两种提供方,换实现不动业务 | 0.5 | [x] `app/services/embedding.py` |
|
||||
| 3.8.3 | 混合检索:向量 0.6 + trigram 0.4,低于阈值不返回,只认 `published` | 1.0 | [x] 5 个用例 + `e2e/retrieval.mjs` 6 项 |
|
||||
| 3.8.4 | 索引生命周期:发布自动建、改正文需 `reindex`、换模型整体重建 | 0.5 | [x] `POST /knowledge/{id}/reindex` |
|
||||
| 3.9 | 安卓知识列表 | 1.0 | [x] 只读列表 + 详情动作(编辑正文走安卓编辑器) |
|
||||
| 3.10 | 文档与 e2e | 1.0 | [x] `knowledge.mjs` 7 项 + `retrieval.mjs` 6 项 |
|
||||
| 3.11 | 知识分类:工作区三级小树(`knowledge_category` + 迁移 0007 + RLS),知识可归一类,列表按分类子树 / 未分类 / 标签筛选 | 1.0 | [x] 增删改移 + 重名 `7008`、超三级 `7007`、删分类要 `force`;列表按树的**先序**返回(父分类后紧跟子分类),侧栏照 `depth` 缩进就是一棵树 |
|
||||
| 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) |
|
||||
| 3.14 | 点开一条知识默认是「看」:Markdown 渲染成正文样式;要改再点「编辑」进编辑器,取消即丢弃未保存改动 | 1.0 | [x] `KnowledgeDrawer` 分 `editing` 两态(只读用 `Markdown` + `prose-dadao`,编辑才挂 `RichTextEditor`);**已发布也能编辑**(PATCH 后自动重建检索切片),归档的要先「恢复成草稿」;e2e 断言默认 `.vditor-ir` 为 0 且已发布也有「编辑」;截图 `2.Design/screens/knowledge-read-view.png` |
|
||||
| 3.15 | 知识列表 tab 顺序与编辑区高度:「全部」排第一并默认选中,正文编辑器占满剩余屏高 | 1.0 | [x] `TABS` 改 `all/draft/published/archived`,`tab` 默认 `"all"`;`useEditorHeight(reserve)` 按窗口高度算正文区(新建 420、编辑 340 留白,最低 360、最高 960),新建与编辑两处共用;截图 `2.Design/screens/knowledge-tab-all.png`、`knowledge-edit-tall.png` |
|
||||
|
||||
### M3 完成定义(DOD)
|
||||
- [x] 一件事办完 → 自动出现一份草稿 → 核对 → 发布,全程不用手动复制粘贴
|
||||
- [x] 每条知识都能追到来源(除手记),点开能看到来源事项
|
||||
- [x] 非管理员发布被拒;跨租户看不到别人的知识
|
||||
- [ ] 向量切片与语义检索(需要独立 pgvector 实例真正跑起来)
|
||||
- [x] 知识是备忘录:标题、正文、标签、三级分类、搜索;新建时就能把这些一次填完(Web 与安卓一致)
|
||||
- [x] 点开知识默认看到的是排好版的正文,不是编辑器的原文;要改才点「编辑」
|
||||
- [x] 切片与语义检索跑通(`real[]` + SQL 余弦;pgvector 仍待独立实例)
|
||||
- [ ] 真实用两周:新同事靠知识库自己解决过一次问题(需要真实团队)
|
||||
|
||||
**关于 3.8 的说明**:本机 Docker 起不来、fnsvr-01 上也没有 pgvector 扩展,
|
||||
所以 `deploy/docker-compose.vector.yml` 与 `VECTOR_DATABASE_URL` 只有配置,**没有跑过**。
|
||||
按"写完不算完成"的规矩,它在真跑起来之前保持未勾。切片表设计要点已写进
|
||||
`2.Design/PRODUCT-PLAN.md` 的 D8,落地时不要改 `EMBEDDING_DIM`(改了要重算全量)。
|
||||
**关于 3.8 的说明(2026-10-29 已落地,但与计划不同)**:计划里写的是 pgvector + HNSW,
|
||||
实际本机 Docker 起不来、fnsvr-01 上也没有 pgvector 扩展且用户明确要求别动那台机器,
|
||||
所以先用 `chunk` 表(`embedding real[]`)+ SQL 函数 `app_cosine()` 把整条链路跑通:
|
||||
切片 → 向量(内置 hash 提供方)→ 混合打分(向量 0.6 + trigram 0.4)→ 阈值过滤 → 带来源返回。
|
||||
`deploy/docker-compose.vector.yml` 与 `VECTOR_DATABASE_URL` 保留,等向量实例真起来之后再切过去,
|
||||
换 pgvector 只改列类型与索引,应用层接口不变。
|
||||
|
||||
两条必须记住的约束:
|
||||
1. **内置 hash 向量不是语义模型**,只是"零依赖、可复现"的基线;上线前换 `EMBEDDING_PROVIDER=openai`
|
||||
并整体重建(`reindex_workspace`),否则新旧向量混表、分数不可比。
|
||||
2. **检索只认 `published`**,草稿永远搜不到——这条写在 SQL 的 WHERE 里,不靠调用方自觉。
|
||||
|
||||
---
|
||||
|
||||
@@ -328,7 +359,7 @@
|
||||
重做不是换配色,而是**先立设计系统、再让页面去用它**,否则下次还是东拼西凑。
|
||||
|
||||
### 4.1 设计系统(两端共用一套 token)
|
||||
- [x] 色板:纸白 `paper`、墨绿 `primary`、陶土橙 `clay`,语义色 sage/wheat/rust/sky(Web `tailwind.config.js`,安卓 `ui/Theme.kt` 同名同值)
|
||||
- [x] 色板:冷白 `paper`、靛蓝 `primary`(`#4F46E5`)、陶土橙 `clay`,语义色 sage/wheat/rust/sky(Web `tailwind.config.js`,安卓 `ui/Theme.kt` 同名同值)(2026-11-01 主色由墨绿改蓝系;2026-11-02 再由沉靛蓝改为高饱和靛蓝 `#4F46E5`,中性色转 Slate 轴,语义色不变)
|
||||
- [x] 字号阶:11/12/13/14/15/18/22/26/36,正文 14、辅助 11/12;细描边 + 极轻投影 + 14dp 圆角
|
||||
- [x] 组件:卡片 / 按钮(primary、ghost、subtle、clay、danger)/ 状态 pill / 输入 / 空态 / 骨架屏 / 抽屉
|
||||
- [x] 图标:Web 用自绘线性图标集(`components/icons.tsx`),安卓用 Material Icons + 少量自绘,线宽一致
|
||||
@@ -349,3 +380,527 @@
|
||||
- [x] 模拟器(API 35)实跑:登录 → 工作区 → 任务勾选 → 工单 → 知识 → 成员,截图存 `2.Design/screens/android-*.png`
|
||||
- [~] 看板不做拖拽:手机屏幕上一列已占 76% 宽,拖拽命中率太差;改为点卡片选目标列,功能等价
|
||||
- [ ] 离线缓存(Room)、图片附件、消息推送:均未做,M5 再议
|
||||
|
||||
---
|
||||
|
||||
## 5. 富文本与附件(M2.5)
|
||||
|
||||
### 5.1 决策:正文存 Markdown,编辑用成熟组件 Vditor
|
||||
- [x] 任务备注、工单说明与验收结论、知识正文、评论,统一 `*_md` 字段存 Markdown
|
||||
- [x] 渲染用 `markdown-it` + `dompurify`(先渲染再消毒),链接协议白名单 `http(s)/mailto//`
|
||||
- [x] **编辑器不自研**(最初那版 textarea + 正则工具栏已删除):改用 [Vditor](https://github.com/Vanessa219/vditor) 4.0.1(MIT,中文优先,`ir` 模式所见即所得),落盘仍是 Markdown
|
||||
- [x] 选型依据:Vditor 11.4k★ / 依赖仅 `diff-match-patch` / 中文界面与文档齐全;TipTap 是 headless 且存 JSON 要另做序列化;`@toast-ui/react-editor` 停更在 2023 且 peer 依赖锁 React 17
|
||||
- [x] 运行时资源自托管(`web/scripts/vendor-vditor.mjs` → `public/vditor`,由 `predev`/`prebuild` 自动生成),**不走 unpkg CDN**——内网/离线环境下编辑器会一直卡在加载
|
||||
- [x] 表格:Vditor 里能插能编;只读渲染(评论、动态、预览)走 `markdown-it` 的 GFM 表,`display:block + overflow-x:auto` 让宽表在抽屉里横向滚动、不撑破布局(e2e 断言表格宽度 ≤ 900px,实测 561px)
|
||||
- [ ] HTML 片段:仍按文本显示(不开放 raw HTML 是有意的,避免把消毒责任交给调用方)
|
||||
|
||||
|
||||
### 5.2 附件
|
||||
- [x] 后端:`attachment` 表 + RLS + 迁移 0005;`POST/GET/DELETE` 与 `download`/`raw` 两个读取口
|
||||
- [x] 本地磁盘存储(`backend/app/services/storage.py`),key 由内容 sha256 决定;换对象存储只改这一个文件
|
||||
- [x] 上限 `MAX_UPLOAD_MB=20`,声明大小与流式读取双重拦截,超限 `8002`
|
||||
- [x] 列表带 `attachment_count`,详情带 `attachments`,避免一屏 N 次请求
|
||||
- [x] 下载走带 `Authorization` 的 fetch 转 blob;`<img>` 直链走**短期签名** `raw?exp=&sig=`(HMAC 密钥由 `JWT_SECRET` 域分离派生,默认 30 分钟,跨工作区拒绝)
|
||||
- [x] 图片直链改成签名 URL 后:正文里只存不带凭据的相对路径 `/raw`,凭据每次渲染现签,不再把会过期的令牌写进库
|
||||
- [ ] 缩略图、图片压缩、病毒扫描:未做
|
||||
|
||||
### 5.3 编辑器
|
||||
- [x] `RichTextEditor` = Vditor 封装:`mode: "ir"`、工具栏(标题/粗体/斜体/删除线/链接/列表/任务列表/引用/代码/行内代码/图片/表格/撤销/重做/全屏)、关掉 `cache`(草稿由我们自己的保存按钮负责)
|
||||
- [x] 粘贴与拖拽上传图片:`upload.handler` 里逐张落到附件接口,再 `insertMD` 插入 `)`。**Vditor 不会替我们插入 handler 的返回值**(它只把字符串当提示语 `tip.show`),这点踩过坑
|
||||
- [x] 令牌只在浏览器内存里存在:落库前 `stripAttachmentTokens`,渲染/装载前补回;`<img>` 带不了 Authorization 头,所以 `raw?token=` 是必要折中
|
||||
- [x] `AttachmentPanel`:上传/下载/删除(删除二次确认,对应后端 `?confirm=true`)
|
||||
- [x] 安卓:`RichTextEditorScreen`(Kotlin 手写 Markdown 工具栏:B/I/标题/列表/引用/代码/链接/图片,选区包裹与行首前缀;预览用 **Markwon** 渲染,图片用 **Coil** 加载签名直链),从任务详情与知识点打开,保存按 `body_md` 回写
|
||||
- [ ] 安卓编辑器保存后回到任务列表而不是原详情抽屉(体验缺口:用户要重新点开那条任务)
|
||||
|
||||
### 5.4 验证
|
||||
- [x] 后端 `pytest` → **74 passed**(附件 8 个用例:上传下载、超限、跨租户、删除确认与权限、raw 签名校验、签名篡改与过期、跨工作区签名)
|
||||
- [x] `web/e2e/richtext.mjs` → **15 项全绿**(Vditor 与 lute 就绪、`**粗体**`/`` `代码` ``/`- 列表` 就地渲染、落库正文不含凭据、贴图后 `/raw` 带签名且返回 200、附件重开仍在、表格渲染且不撑破抽屉、控制台无报错)
|
||||
- [x] 既有三套 e2e 回归通过(smoke 7 / tickets 8 / knowledge 7)
|
||||
- [x] `tsc -b` 0 错;`vite build` 主包 299.42 kB │ gzip 74.96 kB,Vditor 独立 chunk 385.24 kB │ gzip 136.05 kB(按需加载,只进详情抽屉)
|
||||
- [x] 安卓 `assembleDebug` 成功(APK 10.1 MB),模拟器上走通「详情 → 编辑正文 → 预览 → 保存」,截图 `2.Design/screens/android-editor{,-preview}.png`
|
||||
|
||||
## 6. 安卓分发:应用内升级(OTA,2026-10-31)
|
||||
|
||||
要解决的是"改完安卓端怎么送到手机上":不发商店,也不能每次让人手动传 APK。
|
||||
|
||||
### 6.1 后端
|
||||
- [x] `backend/app/api/app_release.py`:`GET /app/release/latest?version_code=` 读 `latest.json` 清单,`GET /app/release/download/{file}` 只认清单里登记的文件名(含 `..%2F` 一律 9001)
|
||||
- [x] 两个接口都免登录:还没装上新包的客户端未必带着有效令牌,检查更新不该被登录挡住
|
||||
- [x] `download_url` 是相对路径,客户端用自己配置的 API 地址拼接——同一份清单要在内网/公网/模拟器之间共用
|
||||
- [x] `has_update` 只比对 `version_code`,不看版本名字符串(`0.10.0` 与 `0.9.0` 的字典序是坑)
|
||||
- [x] `backend/scripts/publish_apk.py`:复制成 `dadaotask-<版本>.apk` 并写 `latest.json`(sha256/size/published_at);`backend/static/apk/` 已在 `.gitignore`
|
||||
- [x] `backend/tests/test_app_release.py` 5 个用例:未发布 9001、清单字段与 download_url、`has_update` 判据、下载字节与 content-type、路径穿越
|
||||
|
||||
### 6.2 安卓
|
||||
- [x] `data/AppUpdater.kt`:查清单 → 下到 `Android/data/<pkg>/files/updates/`(先写 `.part` 再改名)→ 校验 sha256 → 经 `FileProvider` 交给系统安装器
|
||||
- [x] `ui/UpdateDialog.kt`:当前/新版本号、更新说明、下载进度、失败重试;「允许安装未知应用」只跳系统设置,不代点
|
||||
- [x] 权限回来那一跳要接住:用户在系统设置里开完开关回到 App,再点同一个按钮时先复查权限,开了就直接装——否则按钮点了没反应
|
||||
- [x] 启动时自动查一次;首页底部有「检查更新」手动入口,结果用一句话回给用户
|
||||
- [x] 图标换成与 Web 的 `<Logo/>` 同一套图形(靛蓝圆角底 + 三条白色圆头线 + 淡紫点),生成 `mipmap-*/ic_launcher{,_round}.png` 与自适应图标 `mipmap-anydpi-v26/ic_launcher{,_round}.xml`,512px 预览存 `2.Design/screens/android-icon-512.png`
|
||||
- [x] 版本 `versionCode = 2` / `versionName = "0.2.0"`;发布签名从 `android/keystore.properties` 读,无该文件时回落调试签名(保证别人 clone 下来也能 `assembleRelease`)
|
||||
|
||||
### 6.3 构建环境(本机踩过的坑)
|
||||
- Gradle 8.7 被 AGP 8.7.3 拒绝(`Minimum supported Gradle version is 8.9`),用 **Gradle 8.9**(腾讯镜像下载)
|
||||
- 系统 `~/.gradle` 的 native 库坏了(`Failed to load native library 'native-platform.dll'`),必须 `GRADLE_USER_HOME` 指到一个干净目录
|
||||
- JDK 25 编译不过,用 **Temurin JDK 17**;Kotlin 编译还要把 `java.io.tmpdir` 指到工程内可写目录,否则报 `AccessDeniedException: kotlin-compiler-*.alive`
|
||||
- 完整命令见 `2.Design/DEPLOY.md` 第 5.1 节
|
||||
|
||||
### 6.4 验证
|
||||
- [x] 后端 `pytest` → **103 passed**;`ruff check` / `ruff format --check` 全绿
|
||||
- [x] `assembleRelease` 成功:`app-release.apk` 7.4 MB,`apksigner verify --print-certs` 显示证书 `CN=Dadao Task`
|
||||
- [x] 本地 `GET /api/v1/app/release/latest?version_code=1` → `has_update=true`、`download_url` 可下(200,7 762 912 字节),`version_code=2` → `false`
|
||||
- [x] 生产(`http://192.168.8.188:8200`)同上,安装包已随部署上传
|
||||
- [x] 模拟器上走通全流程:装 debug 包 → 登录 → 启动即弹「有新版」→ 下载 7 762 912 字节(与清单 `size` 一致,`.part` 已改名)→ 无安装权限时提示去开 → 拉系统安装器「是否更新此应用」→ 装完 `versionCode=2 / versionName=0.2.0`;截图 `2.Design/screens/android-update-{dialog,permission,installer}.png` 与 `android-home-check-update.png`
|
||||
|
||||
### 6.5 发版记录
|
||||
|
||||
| 版本 | 日期 | 这一版带上了什么 |
|
||||
|---|---|---|
|
||||
| 0.3.0(`versionCode=3`) | 2026-11-06 | 知识是备忘录(分类 / 标签 / 筛选 / 搜索)、知识能删、公众号链接抓成知识后手机上读得到、登录改成手机号或邮箱 + 密码(生产已关短信,旧包登不进去)。模拟器上从 0.2.0 走应用内升级到 0.3.0 全程通过:弹窗「有新版 0.3.0」→ 下载 7.5 MB → 系统安装器 → `versionCode=3` |
|
||||
| 0.2.0(`versionCode=2`) | 2026-10-31 | 第一个带应用内升级的包,图标换成与 Web 同一套 |
|
||||
|
||||
升级签名用同一把(`CN=Dadao Task`,SHA-256 `2555c7908f7d…`),所以老版本能直接覆盖安装;
|
||||
换签名的话只能卸了重装,OTA 也会失败。
|
||||
|
||||
---
|
||||
|
||||
## 7. 账号与外壳统一(Web,2026-10-31)
|
||||
|
||||
### 7.1 两个问题(用户提的)
|
||||
1. 首页是"居中窄栏",点进工作区却变成全屏——同一套产品两种壳,进出像换了个网站。
|
||||
2. 用户相关功能缺失:没有基本信息、没有改密码、没有一个统一的"退出登录"入口。
|
||||
|
||||
### 7.2 决定
|
||||
- **外壳统一到全宽 + 左侧栏**(不是把工作区收回窄栏):工作区里本来就要放下看板四列、知识分类树,
|
||||
收窄是倒退。首页跟着做同样的壳,宽度就不再跳。
|
||||
- **加密码登录,作为验证码之外的第二条路**。理由:验证码依赖短信通道,通道挂了人就进不来;
|
||||
密码是自助的第二把钥匙。但**不强制**:没设密码的人照旧只用验证码,少一道门槛。
|
||||
- 外壳抽成 `web/src/components/SideRail.tsx`,首页与账号页共用;工作区的 `AppShell` 保持自己的侧栏(有工作区切换与页签)。
|
||||
两处宽度一致(`w-[248px]` 级别的同一份样式),所以进出不跳。
|
||||
|
||||
### 7.3 后端
|
||||
- `app_user.password_hash`(迁移 `0008_user_password`,加列不加表)
|
||||
- `app/security.py`:scrypt(`n=2^14, r=8, p=1`,标准库 `hashlib`),存 `scrypt$n$r$p$salt$hash`;
|
||||
`password_problem()` 一处定义强度(8–64 位、字母 + 数字)
|
||||
- `POST /auth/login/password`、`POST /auth/password`(设/改共用,改要先验旧)、`PATCH /auth/me`(改昵称)
|
||||
- 用户输出统一走 `_user_out()`:**白名单字段**,`password_hash` 一个字节都不出去;对外只给 `has_password`
|
||||
- 错误码:`2013` 密码错/没设过、`2014` 未设密码(保留)、`2015` 太弱、`2016` 连错太多
|
||||
|
||||
### 7.4 Web
|
||||
- 首页:左侧栏 + 全宽内容区,工作区从列表改成卡片网格(`sm:2 / xl:3 / 2xl:4` 列),
|
||||
"新建工作区"是网格里的一张虚线卡片,点开就地展开表单
|
||||
- 新增 `/account`:基本信息(头像首字母、昵称、只读手机号、加入时间)、登录密码(设置/修改 + 确认输入)、退出登录
|
||||
- 登录页:顶部「验证码 / 密码」两段切换,同一个表单两种提交
|
||||
- 工作区 `AppShell` 底部用户块可点进 `/account`,旁边一个齿轮图标
|
||||
|
||||
### 7.5 验证
|
||||
- 后端 `pytest` → **113 passed**(新增 `tests/test_account.py` 10 项);`ruff check` / `ruff format --check` 全绿
|
||||
- Web `tsc --noEmit` 通过、`vite build` 通过
|
||||
- 新增 e2e `web/e2e/account.mjs`(**13 项全绿**):登录进首页 → 首页有侧栏 → 进工作区**侧栏宽度不变(差值 < 1px)**
|
||||
→ 账号页复用同一条侧栏 → 改昵称 → 设/改密码 → 刷新后仍显示"已设置密码" → 退出 →
|
||||
错密码被拒(`2013`)→ 密码登录成功 → `/auth/me` 里 `has_password=true` 且昵称是刚改的 → 验证码登录仍可用 → 控制台零报错
|
||||
- 截图:`2.Design/screens/web-home-rail.png`、`web-account-settings.png`、`web-home-mobile.png`
|
||||
- 脚本里两个坑写进了注释:① 短信 60 秒节流会让"发码→登录"整段失败(先看 429,别怪登录);
|
||||
② 改密码要先给旧密码,脚本把上一轮的密码记在 `.local/e2e-account-password.txt` 里自己维护
|
||||
- **上生产时抓到一个真 bug**(`web/src/lib/client.ts`):`crypto.randomUUID()` 与 `navigator.clipboard`
|
||||
只在**安全上下文**(https / localhost)里存在,内网入口是纯 http,于是所有写请求在拼
|
||||
`Idempotency-Key` 时抛 TypeError——登录页只显示"登录失败",GET 一切正常,所以一直没暴露;
|
||||
复制邀请链接也永远失败。改成自拼 v4(`getRandomValues` 不受限,退回 `Math.random`)+
|
||||
`execCommand("copy")` 兜底。生产核对:首页 / 工作区 / 账号页侧栏都是 **264px**,差值 0。
|
||||
|
||||
### 7.6 没做的
|
||||
- 安卓端登录页仍只有验证码,账号页也没有——下一步对齐(密码登录 + 改昵称 + 退出)
|
||||
- 手机号换绑、头像上传未做(头像现在只有首字母)
|
||||
|
||||
## 8. Web 装到桌面(PWA,2026-10-31)
|
||||
|
||||
### 8.1 要解决的问题
|
||||
"web版本帮我封装成PWA,这样方便安装到手机桌面和电脑桌面。"(用户原话)
|
||||
|
||||
现在每次用都要敲地址、开浏览器、找标签页;手机上还给不了"点了图标就进来"的感觉。
|
||||
|
||||
### 8.2 决定(写进注释了,别以后翻案)
|
||||
- 用 `vite-plugin-pwa`(本就在 devDependencies 里,2.0.0)生成 SW 与 manifest,不自己写
|
||||
- `registerType: "prompt"` + `injectRegister: null`:**有新版只提示,点「刷新」才换**。
|
||||
理由:Vditor、图表是懒加载分片,`autoUpdate` 会在用户开着页面时把 chunk 换掉,
|
||||
下一次点开面板就是 404(编辑器白屏)
|
||||
- 预缓存只放构建产物与图标(约 0.8MB);`/api/` 一律 NetworkOnly——离线宁可报错,也不端旧数据
|
||||
- 图标复用 Web Logo 的 32 单位坐标生成,和页头、安卓桌面图标同一枚
|
||||
|
||||
### 8.3 实现
|
||||
| 位置 | 内容 |
|
||||
|---|---|
|
||||
| `web/vite.config.ts` | manifest(standalone、主题色 `#4f46e5`、三个图标)+ workbox(`navigateFallback` 回退 `index.html`,`/vditor/` 走 CacheFirst,`/api/` NetworkOnly) |
|
||||
| `web/index.html` | favicon 改成同款 Logo(内联 SVG)、`theme-color`、`apple-touch-icon`、`viewport-fit=cover` |
|
||||
| `web/src/lib/pwa.ts` | 生产环境注册 SW、`dadao:pwa-update-ready` 事件、`applyServiceWorkerUpdate()`、`isStandalone()` |
|
||||
| `web/src/components/UpdatePrompt.tsx` | 底部卡片「有新版了」+ 稍后 / 刷新(**居中用 flex 包一层**:`animate-slide-up` 的 keyframes 自带 transform,会和 `-translate-x-1/2` 抢属性,手机窄屏上卡片会被推出屏幕右侧) |
|
||||
| `web/src/components/InstallHint.tsx` | 首页底部安装引导:能装就给「安装」按钮(拦 `beforeinstallprompt`),iOS 给"分享 → 添加到主屏幕",关掉记在 localStorage |
|
||||
| `web/public/icons/` | `icon-192` / `icon-512`(any)、`maskable-512`、`apple-touch-icon`(180,iOS 不吃透明) |
|
||||
|
||||
### 8.4 验证
|
||||
- `web/e2e/pwa.mjs`(**19 项全绿**):manifest 名字/standalone/三个图标 → 图标都能下载 200 →
|
||||
SW 已 `activated` 且作用域是整站 → 页面骨架已进预缓存 → 主题色 → 桌面尺寸控制台零报错 →
|
||||
窄屏有安装引导 → iOS 文案是「分享 → 添加到主屏幕」→ 关掉后刷新不再出现 →
|
||||
有新版会提示 → 点「稍后」收起 → **断网后页面骨架仍能打开**
|
||||
- 跑法:后端用 `WEB_DIST_DIR=<repo>/web/dist` 起一个同构站点(生产同形),
|
||||
然后 `node web/e2e/pwa.mjs`;`BASE` 可换环境
|
||||
- 截图:`2.Design/screens/web-pwa-install.png`、`web-pwa-update.png`;断网那张留在 `web/e2e/artifacts/pwa-offline.png`
|
||||
|
||||
### 8.5 还没解开的
|
||||
- **生产入口还是 http**:Service Worker 只在 https / localhost 注册,所以内网 IP 上装不了、
|
||||
也看不到安装提示(桌面用 `127.0.0.1` 打开可以)。要么加域名 + 证书反代(`443 → 127.0.0.1:8200`),
|
||||
要么用 Cloudflare Tunnel 之类拿个 https 域名;应用代码不用改。已写进 `DEPLOY.md` 第 6 节
|
||||
- 安卓端还在走自己的 OTA(原生包),和 PWA 是两条路;要不要让安卓也改成加载 Web,等产品定
|
||||
|
||||
---
|
||||
|
||||
## 9. 登录方式与系统设置(2026-11-02)
|
||||
|
||||
### 9.1 需求
|
||||
用户原话:"手机验证码我暂时不启动,所以把12345这种测试验证码取消了吧,我用账号密码来登陆……
|
||||
再增加一种登陆方式就是邮箱……并且这个邮箱是可以后台配置的啊;"(同时要求三个列表的标题样式统一)
|
||||
|
||||
### 9.2 决定
|
||||
- **短信登录默认关**(`SMS_LOGIN_ENABLED=false`):dev 环境的固定码等于"知道手机号就能进任何账号"。
|
||||
关掉后 `/auth/sms/send` 与 `/auth/login` 统一回 `2017`,不是静默失败;登录页按 `GET /auth/methods`
|
||||
渲染,关着的路根本不显示(少一次"点进去才发现不能用")。
|
||||
- **邮箱是账号名**,不是通知地址:绑了就能用「邮箱 + 密码」登录,`lower(email)` 唯一。
|
||||
- **SMTP 存数据库、后台可改**:发信账号是平台级运维参数,改它不该要人登服务器改 env 再重启。
|
||||
只有平台管理员(`PLATFORM_ADMIN_PHONES` 白名单)能读写,因为系统设置是跨租户的。
|
||||
- 密码哈希继续用标准库 scrypt,不引 passlib/bcrypt(多一个升级债,换不来安全)。
|
||||
|
||||
### 9.3 后端
|
||||
| 位置 | 内容 |
|
||||
|---|---|
|
||||
| `app/config.py` | `sms_login_enabled=False`、`platform_admin_phones`(`NoDecode` + CSV 拆分,别让 pydantic 按 JSON 解析)、`smtp_*` 兜底字段;生产校验改为"开着短信登录就必须是真通道" |
|
||||
| `app/security.py` | `hash_password` / `verify_password` / `password_problem`(scrypt,8–64 位且字母+数字) |
|
||||
| `app/models.py` | `AppUser.email`;新表 `system_setting(key, value JSONB, updated_by)`,**不挂租户、无 RLS**,写权限靠 `deps.platform_admin` |
|
||||
| `alembic/versions/0010_email_and_settings.py` | `system_setting` 建表 + `app_user.email` + `uq_app_user_email`(`lower(email)` 部分索引)+ GRANT |
|
||||
| `app/services/mail.py` | 读库优先、环境变量兜底;465 走 SSL、587 走 STARTTLS;认证失败/收件人被拒/网络失败都归 `2020` 但消息不同 |
|
||||
| `app/api/auth.py` | `GET /auth/methods`、`POST /auth/login/password`(`phone`/`email` 二选一)、`PATCH /auth/me`(改昵称/绑解绑邮箱)、`sms` 两条加开关闸门 |
|
||||
| `app/api/settings.py` | `GET/PUT /settings/smtp`、`POST /settings/smtp/test`,全部 `PlatformAdminDep` |
|
||||
| `app/deps.py` | `platform_admin` / `PlatformAdminDep` |
|
||||
|
||||
### 9.4 Web
|
||||
- `pages/Login.tsx`:先问 `/auth/methods` 再渲染;账号档「手机号 / 邮箱」,密码框;短信关着时不出现「验证码」档
|
||||
- `pages/Settings.tsx`(新):SMTP 表单(服务器/端口/账号/授权码/显示名/SSL)+ 当前状态卡(能不能发信、授权码设没设、上次修改、来源 db/env)+ 测试发信
|
||||
- `pages/Account.tsx`:基本信息加「邮箱」(说明"留空保存就是解绑")、按钮改「保存资料」、平台管理员多一个「系统设置」入口卡
|
||||
- `components/SideRail.tsx`:平台管理员才多一条「系统设置」(移动端顶条同理),图标 `Sliders`
|
||||
- `pages/Tasks.tsx:262` / `pages/Tickets.tsx:248`:列表标题统一成 `text-[15px] font-semibold leading-6`(与知识一致)
|
||||
|
||||
### 9.5 验证
|
||||
- `backend/tests/test_login_email.py`(7 例)+ `test_settings_smtp.py`(6 例)+ 改写的 `test_config_prod.py`;
|
||||
全量 `pytest` 130 passed、`ruff check` / `format --check` 全绿
|
||||
- `web/e2e/login-email-smtp.mjs`(**15 项全绿**):methods 说短信关着 → 发码回 2017 → 登录页没有验证码档 →
|
||||
手机号密码进首页 → 绑邮箱 → 邮箱登录(大小写不敏感)→ 解绑后回 2013 → 侧栏出现「系统设置」 →
|
||||
存授权码(接口不回显)→ **真发一封测试邮件成功** → 账号页回填邮箱 → 控制台零报错
|
||||
- 截图:`2.Design/screens/web-login-password.png`、`web-account-email.png`、`web-settings-smtp.png`
|
||||
|
||||
### 9.6 踩过的坑
|
||||
- `platform_admin_phones` 是 `list[str]`,pydantic-settings 会把它当 JSON 解析,`A,B` 直接启动失败:
|
||||
用 `Annotated[list[str], NoDecode]` 交给 before-validator 拆逗号
|
||||
- 迁移号写成 `0009` 会撞 `0009_knowledge_category_color.py`,pytest 报 `Multiple head revisions`
|
||||
- e2e 里 `getByLabel("账号")` 会同时命中侧栏 `aria-label="账号设置"`,要用 `input[aria-label="账号"]`;
|
||||
侧栏的「系统设置」要等 `/auth/me` 落地才渲染,断言得 `waitForFunction` 而不是立刻 `count()`
|
||||
|
||||
### 9.7 上线情况(2026-11-02)
|
||||
|
||||
- 生产 `~/.config/dadaotask/env.production` 追加 `SMS_LOGIN_ENABLED=false`、
|
||||
`PLATFORM_ADMIN_PHONES=13605513683`;重启后 `/auth/methods` 回 `sms_code:false`,发码接口回 `2017`。
|
||||
- 生产账号 13605513683 设了密码、绑了 `shufenhf@126.com`:手机号登录、全大写邮箱登录都通。
|
||||
- 生产 SMTP 从「系统设置」页存进库(`configured:true, source:"db"`),当场发测试信成功。
|
||||
- **短信登录只在本机开着**(`backend/.env` 的 `SMS_LOGIN_ENABLED=true`):新账号目前只能靠"验证码登录
|
||||
自动注册"造出来,关掉连 e2e 都造不出测试用户。生产是关的,所以生产**暂时没有注册入口**——
|
||||
要开放注册得先做「邮箱验证码注册 / 邀请注册」,这是下一步的事。
|
||||
- 生产实拍:`2.Design/screens/prod-account-email.png`、`prod-settings-smtp.png`。
|
||||
|
||||
## 10. 邮箱验证码注册(2026-11-03)
|
||||
|
||||
### 10.1 需求
|
||||
|
||||
手机上短信登录关掉后,生产没有注册入口。用户原话:"默认用邮件验证码注册吧;"
|
||||
|
||||
### 10.2 决定
|
||||
|
||||
- **先发码再注册**:`POST /auth/email/code` → `POST /auth/register`,两步而不是"注册时顺带发信"。
|
||||
理由:用户填完密码才被告知"这个邮箱注册过了"最气人,所以发码前就查重(`2019`)。
|
||||
- **邮箱既是账号名也是唯一联系方式**:注册必须真验一次码,"随手填一个"会让账号变成没人能找回的孤岛。
|
||||
验证通过时间落 `app_user.email_verified_at`,`/auth/me` 回 `email_verified`。
|
||||
- **`app_user.phone` 改为可空**(迁移 0011):邮箱注册的账号本来就没有手机号。
|
||||
老账号不受影响;`user.phone` 对这类账号回空串而不是星号串(成员列表同理)。
|
||||
- **防刷**:同邮箱 60 秒内只能发一次(`2022`);验证码 10 分钟有效;错 5 次即作废(`2021`)——
|
||||
6 位数可以被刮,不设上限等于把注册口子交给脚本。
|
||||
- **本机联调开关** `EMAIL_CODE_DEV_ECHO`:为 true 时把码直接回给调用方且**不发真信**,
|
||||
否则每跑一次 e2e 就往真实收件箱塞一封。生产打开会让 `validate_runtime()` 拒绝启动。
|
||||
|
||||
### 10.3 后端
|
||||
|
||||
| 位置 | 内容 |
|
||||
|---|---|
|
||||
| `app/config.py` | `email_register_enabled`、`email_code_ttl_seconds=600`、`email_code_throttle_seconds=60`、`email_code_max_attempts=5`、`email_code_dev_echo`(生产校验拒绝) |
|
||||
| `app/errors.py` | `EMAIL_CODE_INVALID=2021`(400)、`EMAIL_CODE_TOO_FREQUENT=2022`(429) |
|
||||
| `app/models.py` | `AppUser.phone` 可空、`email_verified_at`;`AuthUser.phone: str \| None` |
|
||||
| `alembic/versions/0011_email_signup.py` | `phone DROP NOT NULL` + `email_verified_at` + GRANT(downgrade 先给空手机号补占位值) |
|
||||
| `app/stores.py` | `email_code_key` / `email_tries_key` / `email_throttle_key` |
|
||||
| `app/services/mail.py` | `send_verify_code(session, to=…, code=…, minutes=…)`,纯文本 + HTML 两版 |
|
||||
| `app/api/auth.py` | `POST /auth/email/code`、`POST /auth/register`、`/auth/methods` 加 `email_register` |
|
||||
|
||||
### 10.4 Web
|
||||
|
||||
- `pages/Register.tsx`(新):邮箱 / 验证码(60 秒倒计时)/ 密码(前端先按同一套规则提示)/ 确认密码 / 昵称(可不填,留空用邮箱前缀);
|
||||
注册成功直接写令牌进首页。服务端关了注册时整页只显示"联系管理员开通"。
|
||||
- `pages/Login.tsx`:`/auth/methods` 里 `email_register` 为真才显示「还没有账号?用邮箱注册」。
|
||||
- `App.tsx`:新增 `/register` 路由。
|
||||
|
||||
### 10.5 验证
|
||||
|
||||
- `backend/tests/test_email_signup.py`(9 例):真走 SMTP(monkeypatch 记账)、dev echo 不发信、
|
||||
注册即登录、码用完不能重放、错码/过期、试错上限、邮箱占用、发码节流、弱密码、注册关闭时两条接口都回 2017。
|
||||
- e2e `web/e2e/register.mjs`:登录页进注册页 → 拿码(dev echo)→ 注册 → 落进首页 → 退出用「邮箱 + 密码」登回来 →
|
||||
同一个邮箱再发码回 `2019`。7 项全绿。
|
||||
- `pytest` 139 passed、`ruff check` / `format --check` 全绿、`tsc --noEmit` 0、`vite build` 通过;
|
||||
回归 `account.mjs`(14 项)、`knowledge.mjs`、`login-email-smtp.mjs`(15 项,含真发一封测试信)全绿。
|
||||
|
||||
### 10.6 上线情况(2026-11-03,commit b5170ad)
|
||||
|
||||
- 生产 `/auth/methods` → `{"password":true,"email_password":true,"sms_code":false,"email_register":true}`。
|
||||
- 已注册的邮箱再发码回 `2019`;未注册的地址 `POST /auth/email/code` 返回 `sent:true`(真走了 126 的 SMTP)。
|
||||
- 生产登录页出现「用邮箱注册」,注册页渲染正常、控制台零报错(`web/e2e/prod-register-check.mjs`,
|
||||
截图 `web/e2e/artifacts/prod-register.png`)。
|
||||
- 生产 env **不带** `EMAIL_CODE_DEV_ECHO`:码只发到邮箱,页面不回显。
|
||||
- 踩到的坑:dev 库要先 `alembic upgrade head` 到 0011,否则注册接口报
|
||||
`column "email_verified_at" of relation "app_user" does not exist` 直接 500——迁移写好不等于跑过。
|
||||
|
||||
## 11. 知识的阅读与编辑分开(2026-11-03)
|
||||
|
||||
用户原话:"知识页,浏览内容时是打开抽屉页然后展示,这不符合人类的阅读习惯,因为这样的内容完全偏右,
|
||||
长时间浏览很难受。抽屉效果只能用于编辑,因为这是短时间的,也不会经常编辑同一篇文章。"
|
||||
|
||||
### 11.1 决定
|
||||
|
||||
- **阅读走整页**:`/w/:wsId/knowledge/:itemId`。抽屉在屏幕右侧,正文永远偏在右边,
|
||||
长文读十几屏眼睛一直在右半边;整页可以把正文列限宽居中,行宽回到五十来个汉字。
|
||||
- **抽屉只留给编辑**:编辑是短时动作,改完就走,抽屉"压在当前列表上"反而合适,
|
||||
也省掉从编辑页退回列表时的滚动位置丢失。
|
||||
- 阅读页复用列表页那套文案常量,避免两处各写一份以后对不上(`lib/knowledge.ts`)。
|
||||
|
||||
### 11.2 Web
|
||||
|
||||
| 位置 | 内容 |
|
||||
|---|---|
|
||||
| `lib/knowledge.ts`(新) | `DOC_TYPE_LABEL` / `STATUS_LABEL` / `SOURCE_LABEL` / `ACTION_LABEL` / `CATEGORY_PALETTE` / `categoryColor()`,从列表页搬出来 |
|
||||
| `pages/KnowledgeDoc.tsx`(新) | 阅读页:面包屑(返回知识库 / 分类路径)、类型+状态+分类 chip、28px 标题、`更新于 / 发布于 / 检索切片 N 段`、标签、动作行(编辑/发布/归档/恢复)、`Markdown` 渲染、来源卡片、附件(只读) |
|
||||
| `pages/Knowledge.tsx` | 卡片与「打开原文」改成 `navigate("/w/<ws>/knowledge/<id>")`;删掉 `openId` 状态与抽屉渲染;`KnowledgeDrawer` 加 `initialEditing`(进来就是编辑器,保存或取消都直接关) |
|
||||
| `App.tsx` | 新增 `/w/:wsId/knowledge/:itemId` 路由 |
|
||||
|
||||
卡片 `max-w-[78rem]`、正文列 `max-w-[56rem]`(约 55 个汉字一行)居中;换一篇自动回顶。
|
||||
第一版按 40rem / 46rem 做的,1546 宽的屏幕上一量,留白比正文还宽——用户直接发了截图。
|
||||
留给以后的界线:正文再宽眼睛就要来回扫行,56rem 是"宽屏不空、又不至于扫行"中间那条线。
|
||||
编辑仍然是抽屉:`initialEditing` 时没有「看」这一态,点「取消」直接关掉,不留半截改动。
|
||||
|
||||
### 11.3 验证
|
||||
|
||||
- e2e `web/e2e/knowledge-read.mjs`(新,8 项):点卡片进独立页、`h1` 是标题、正文是渲染后的 `.prose-dadao`
|
||||
且 `.vditor-ir` 计数为 0、正文列宽度落在 720–920px(宽屏不留大片空白)、正文列在 `main` 里左右留白差 < 40px、点「编辑」才出现编辑器、
|
||||
取消后编辑器 detach、控制台零报错。
|
||||
- 回归:`knowledge-notebook.mjs`(17 项,含"侧栏分类按树排"与"抽屉默认渲染样式")、`knowledge.mjs`(9 项)、
|
||||
`knowledge-paging.mjs`(5 项)全绿。
|
||||
- `tsc --noEmit` 0、`vite build` 通过。
|
||||
- 截图:`2.Design/screens/knowledge-read-page.png`(长文整页)、`knowledge-edit-drawer.png`(抽屉编辑态)。
|
||||
|
||||
### 11.4 顺带修掉的脚本坑
|
||||
|
||||
- `knowledge-notebook.mjs` 里"关掉抽屉"那一步在新版里没有按钮可点(保存后直接进阅读页),
|
||||
改成等 URL 落到 `/knowledge/<id>` 再点「返回知识库」。
|
||||
- 侧栏分类行里的色点是按钮但没文字,`allInnerTexts()` 会多出一个空串,比较树顺序前先 `.filter(Boolean)`。
|
||||
- `knowledge-paging.mjs` 原来靠短信发码登录,生产短信已关;改成优先用 `PASSWORD`
|
||||
(或 `.local/e2e-account-password.txt`)走密码登录,没密码时才回落到短信。
|
||||
|
||||
### 11.5 上线情况(2026-11-03,commit d34a6ac)
|
||||
|
||||
- 生产 `http://192.168.8.188:8200` 已切到整页阅读:正文列居中限宽、`编辑` 才开抽屉。
|
||||
- `web/e2e/knowledge-read.mjs` 直接打生产(106 的手机号 + 密码登录)8 项全绿,
|
||||
截图 `2.Design/screens/prod-knowledge-read-page.png`。
|
||||
- 脚本都换成了密码登录(`PASSWORD` env 优先),生产短信登录关着,别再指望发验证码。
|
||||
|
||||
## 12. 登录态续期(2026-11-03)
|
||||
|
||||
用户反馈:"web端登陆后好像没多久就退出登陆了,现在系统对登陆时限是怎么设计的?"
|
||||
|
||||
### 12.1 原来的设计(也就是问题所在)
|
||||
|
||||
- access 30 分钟、refresh 14 天,服务端两个都签好、都发给了客户端。
|
||||
- 但**没有任何客户端用过 refresh**:web 与安卓拿到 `401/2002` 都是直接清令牌、回登录页。
|
||||
- 结果"14 天有效"只是纸面参数:真实登录寿命就是 30 分钟,到点下一次请求就被扔出去,
|
||||
正在写的工单、正在编辑的知识都不会留下。
|
||||
|
||||
### 12.2 改法
|
||||
|
||||
- `web/src/lib/client.ts`:新增 `refreshSession()`(并发只发一次),请求前若 access 剩不到 90 秒先换;
|
||||
收到 401 先换再重放一次原请求,换不动才清令牌发 `authExpired`。`uploadFile` 走同一条路。
|
||||
- `android/app/src/main/java/com/dadao/task/data/Api.kt`:`call()` 拆成 `call` + `callOnce`,
|
||||
2002 时 `refreshTokens(stale)` 用 `Mutex` 串行刷新(等锁期间别人换好了就直接用新的),重放一次。
|
||||
- 后端 `/auth/refresh` 顺手补一条:账号被停用 → `2012`,别让停用账号靠 refresh 一直续。
|
||||
|
||||
### 12.3 验证
|
||||
|
||||
- `backend/tests/test_auth.py::test_disabled_account_cannot_refresh`(停用账号换令牌 → 403/2012),
|
||||
全量 **140 passed**、`ruff check` / `format --check` 全绿。
|
||||
- 新增 `web/e2e/session-refresh.mjs`(6 项):access 换成假串但 refresh 是真的 → 不被踢、首页正常、
|
||||
拿到新令牌、新令牌能调接口;access 与 refresh 都假 → 才回登录页;控制台零异常。
|
||||
- 安卓 `assembleDebug` 通过(JDK 17 + Gradle 8.9)。
|
||||
|
||||
## 13. 微信接入(clawbot,2026-11-05)
|
||||
|
||||
### 13.1 要解决的问题
|
||||
|
||||
记事的动作发生在手机上,而任务和知识都在浏览器里。人正在外面、手上只有微信的时候,
|
||||
"回去再记"通常等于"再也想不起来"。所以要有一条从微信直接落到工作区里的路。
|
||||
|
||||
### 13.2 决定
|
||||
|
||||
1. **走腾讯官方 iLink Bot 协议**:`get_bot_qrcode` 拿二维码 → 手机扫码确认 →
|
||||
拿到 `bot_token` → 后端**长轮询** `getupdates` 收消息 → 用 `context_token` 回话。
|
||||
这是 clawbot / openclaw / dsh-im 走的同一条路,扫码是配对的前提。
|
||||
2. **不依赖 DSH**:协议自己实现(`backend/app/services/wechat_ilink.py`),
|
||||
因为达道Task 是独立项目,不该为了收消息去依赖另一个产品的运行环境。
|
||||
3. **登录态加密存库**:`bot_channel.bot_token_enc` 用 Fernet(密钥由 `JWT_SECRET` 派生),
|
||||
`bot_token` 不落明文、不回调给任何接口、日志里也没有。
|
||||
4. **轮询者是我们自己**:每个已连接的通道一个 asyncio 任务,进程启动时按库里的
|
||||
`status='active'` 恢复;token 失效(微信 errcode `-14`)就把通道转 `expired`,让用户重新扫。
|
||||
5. **一条消息只落一次**:按 `message_id` 去重,微信重推不会变成第二条任务。
|
||||
6. **每条消息都留痕**(`bot_message`,含文本、动作、item_id、回复、raw),
|
||||
出问题时能直接回答"那条任务到底进没进"。
|
||||
7. **一个工作区一条微信通道**:同一时间只允许一条 `kind='wechat_clawbot'` 的记录承载登录态;
|
||||
断开就是清登录态回到 `pending`,再连就重新扫。
|
||||
|
||||
### 13.3 协议要点(实现对照)
|
||||
|
||||
| 用途 | 请求 |
|
||||
|---|---|
|
||||
| 取二维码 | `POST https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode?bot_type=3` → `{qrcode, qrcode_img_content}` |
|
||||
| 问扫码结果 | `POST /ilink/bot/get_qrcode_status?qrcode=<id>` → `waiting`/`scaned`/`confirmed`/`expired` |
|
||||
| 收消息 | `POST /ilink/bot/getupdates` body `{get_updates_buf, base_info:{channel_version}}`,长轮询 70s |
|
||||
| 回消息 | `POST /ilink/bot/sendmessage` body `{msg:{to_user_id, context_token, item_list:[{type:1,text_item:{text}}]}}` |
|
||||
|
||||
请求头固定带 `iLink-App-ClientVersion: 1` 与 `X-WECHAT-UIN`(随机数 base64);
|
||||
登录后加 `Authorization: Bearer <bot_token>` + `AuthorizationType: ilink_bot_token`。
|
||||
二维码不是图片地址,`qrcode_img_content` 是待渲染的 payload,服务端用 `qrcode` 库画成 SVG data URL。
|
||||
|
||||
### 13.4 后端
|
||||
|
||||
| 位置 | 内容 |
|
||||
|---|---|
|
||||
| `backend/app/services/wechat_ilink.py` | 只封协议:取码、问状态、长轮询、回话、消息解析、token 加解密 |
|
||||
| `backend/app/services/wechat_intake.py` | 文本 → 事项:前缀识别、`#标签`、标题/正文切分、建任务/知识、回复文案 |
|
||||
| `backend/app/services/wechat_bot.py` | `BotManager`:起停轮询任务、去重、留痕、游标、`-14` 转 `expired` |
|
||||
| `backend/app/api/integrations.py` | `POST /workspaces/{ws}/integrations/wechat/qr`、`GET …/qr/{id}`、`PATCH`、`DELETE`、`GET …/messages` |
|
||||
| `backend/app/models_integrations.py` | `BotChannel`(`bot_token_enc`、`ilink_user_id`、`sync_buf`、`context_token`、`last_seen_at`、`last_error`)、`BotMessage` |
|
||||
| `backend/alembic/versions/0013_wechat_bot_login.py` | 加登录态相关列,`status` 默认 `pending` |
|
||||
| `backend/alembic/versions/0014_bot_channel_worker_policy.py` | 轮询入口没有工作区上下文,按 `app.bot_channel` 主键开一条 permissive 策略 |
|
||||
| `backend/app/main.py` | lifespan 里 `manager.start_all()` / `manager.shutdown()` |
|
||||
|
||||
正文解析:首行是标题(180 字封顶),其余是正文;`#标签` 摘出来挂事项上(纯数字不算);
|
||||
前缀 `知识:`/`笔记:` → 知识草稿,`任务:`/`todo:` → 任务,没有前缀就是任务,`帮助`/`?` 只回用法。
|
||||
|
||||
### 13.5 Web
|
||||
|
||||
新增 `/w/:wsId/integrations`「机器人」页(左侧栏最后一项;栏目名从「接入」改过来,因为用户要的是「机器人」这件事,而不是「接入」这个后台动作):
|
||||
|
||||
- 「扫码连接微信」→ 页面上直接画出二维码,每 2 秒问一次状态(待扫 → 已扫待确认 → 成功/过期);
|
||||
- 连上之后显示通道卡片:接收中/登录已失效/已停用、最后收信时间、"微信用户 xxx"、
|
||||
`最近消息`(最近 5 条,标"建了任务/建了知识")、重新扫码、断开;
|
||||
- 只有 owner/admin 能扫码与断开,其他成员只读。
|
||||
|
||||

|
||||
|
||||
### 13.6 验证
|
||||
|
||||
- `backend/tests/test_wechat_bot.py` 9 例:取二维码(SVG data URL、通道 `pending`)、
|
||||
扫码确认后登录态加密入库且能解回、过期二维码回 `expired`、断开清登录态、
|
||||
纯文本成任务、`知识:`前缀成草稿带标签、`帮助`不建事项、同一 `message_id` 只落一条并只回一次、
|
||||
登录态失效后通道转 `expired` 且带原因。
|
||||
- `web/e2e/integrations.mjs` 7 项(真跑,拿的是微信真二维码):接入页打开 → 点扫码 →
|
||||
二维码是页面里的 SVG → 提示用微信扫 → 后端通道 `pending` → 没登录态不轮询 → 控制台零报错。
|
||||
- 全量 `pytest` 151 passed;`ruff check` / `ruff format --check` 干净。
|
||||
|
||||
### 13.6.1 上线后修的问题
|
||||
|
||||
1. **扫码确认那一刻页面报「服务器内部错误」**:`bot_channel` 的轮询口子(迁移 `0014`)直接
|
||||
`current_setting('app.bot_channel', true)::uuid`,而事务级 `set_config` 提交后会把连接上的
|
||||
变量还原成**空串**而不是 NULL,空串一 cast 就
|
||||
`InvalidTextRepresentationError: invalid input syntax for type uuid: ""`。
|
||||
迁移 `0015` 改成 `nullif(current_setting(...), '')::uuid`,与 `app_ws_id()` 同一写法。
|
||||
2. **回话别再带站点链接**:`PUBLIC_BASE_URL` 是内网地址,手机上打不开;
|
||||
现在只回「已保存任务:…」/「已保存知识草稿:…」。
|
||||
3. **栏目名从「接入」改成「机器人」**:用户要的是「我和这个机器人说话」,不是「配置接入」。
|
||||
4. **重启之后机器人静默不收消息**:生产是 `gunicorn -w 2`,两个进程各跑一遍 lifespan,
|
||||
同一个通道会被起两条长轮询(消息被一个消费、回复从另一个发出,游标互相覆盖);
|
||||
而且 `start_all()` 是"没有工作区上下文"的查询,被 RLS 全藏起来,一行都查不到。
|
||||
- 租约:会话级 `pg_try_advisory_lock`,抢到的进程收,另一个每 10 秒重试;进程挂了连接断、
|
||||
锁自动释放,备用进程接上。页面「接收中」只认抢到租约的那个。
|
||||
- 迁移 `0016` 补一条只读策略:`current_setting('app.bot_list') = 'on'` 时允许 SELECT,
|
||||
`start_all()` 先设这个 GUC 再列通道。
|
||||
|
||||
### 13.7 教训:第一版是猜的
|
||||
|
||||
第一版没找到资料,按"webhook + 令牌在 URL 里"自己编了一套(用户看到的回调地址那版)。
|
||||
用户当场指出"微信要先扫码配对",一查才发现协议在
|
||||
`~/.dsh/profiles/desktop/node_modules/@michengai/dsh-im-connect/src/channels/weixin.ts` 里写着。
|
||||
**教训:接入第三方协议,先去已有实现里找协议,别从直觉倒推。** 那一版的迁移 `0012` 保留
|
||||
(`bot_channel` / `bot_message` 两张表还在用),`token_hash` 列留着但不再使用。
|
||||
|
||||
### 13.8 发一条链接,抓成知识
|
||||
|
||||
需求原话:「如果发送给机器人的是一个链接,那么就需要把链接内的文章内容给抓取下来,然后保存为知识;
|
||||
就算是微信公众号的链接也需要抓取下来」。
|
||||
|
||||
- 选型 `trafilatura==2.0.0`(中文正文抽取最稳)+ `lxml`;公众号单独走 DOM(`#js_content`、图片在
|
||||
`data-src`),因为通用抽取器会把公众号的图全丢掉。
|
||||
- 抓下来的图下载进自己的存储(最多 12 张、单张 8 MB),正文改写成本地 `raw?签名` 地址——
|
||||
外链图在公众号侧一换就全红叉。
|
||||
- 只允许公网地址:谁都能给机器人发消息,不拦住内网就是 SSRF 跳板(`127.0.0.1`、`192.168.x`、链路本地、
|
||||
保留段、解析不出的域名一律拒绝)。
|
||||
- 失败要说话:反爬验证页、Cloudflare 302、SPA 空壳统一回「这个链接没抓下来(原因)。想直接记成任务
|
||||
的话,加「任务:」前缀再发一次。」
|
||||
- **装依赖的坑**:`pip install trafilatura` 在本机会卡死(依赖解析完就挂住),要逐个装
|
||||
`lxml / courlan / htmldate / jusText / lxml_html_clean / …` 再 `pip install --no-deps trafilatura`;
|
||||
`lxml` 6 起 `lxml.html.clean` 拆成 `lxml_html_clean`,不装就 `ImportError`。
|
||||
|
||||
#### 13.8.1 用户发来真链接,结果只抓到一个「。」
|
||||
|
||||
上线后用户发了 `https://mp.weixin.qq.com/s/ZEOsi4R4JcmZ_mhS6qpcww`,库里只有 67 个字符的正文。
|
||||
复盘:**公众号换了渲染方式**——新版页面(`window.appmsg_type = '9'` / `'10002'`)整篇塞进
|
||||
`window.cgiDataNew` 这个 JS 对象、由前端渲染,页面里根本没有 `id="js_content"`;
|
||||
三种 UA(桌面 / iPhone Safari / MicroMessenger)拿到的字节数完全一样,所以也不是 UA 的问题。
|
||||
|
||||
修法(`backend/app/services/article.py`):
|
||||
|
||||
- 从 JS 字符串字面量里读正文:`content_noencode` / `desc`,按 `\xNN`、`\uNNNN` 反转义;
|
||||
话题标签是双重转义的 HTML(`\x26lt;a class=\x26quot;wx_topic_link\x26quot;\x26gt;#调料…`),
|
||||
先 `html.unescape` 再走 DOM。
|
||||
- 配图字段(`cdn_url` / `img_url` / `pic_url`)**排在文字字段之后**,所以配图要前后各找 2500 字符;
|
||||
剩下没用过的 `mmbiz.qpic.cn` 图按 12 张上限补在末尾(图文型「小绿书」的图不在文字附近)。
|
||||
- 解析顺序:旧 DOM(`#js_content`)→ 新版 JS 数据 → 通用抽取,一层层退。
|
||||
- 作者兜底从 `nick_name` 取,标题取 `og:title`。
|
||||
|
||||
结果:那篇 2373 字符 + 12 张图,蒜蓉酱那篇 2772 字符 + 13 张图,作者都取到了。
|
||||
**教训:抓取器写死在一种页面结构上,等于给自己埋一个定时炸弹;线上先看真实 HTML 再改。**
|
||||
|
||||
### 13.9 知识库:标签清单与删除
|
||||
|
||||
用户提两件:"文章都缺少删除功能"、"分类有列表显示,但是分类的下面应该还有标签的列表"。
|
||||
|
||||
- `GET /knowledge/tags`:按标签统计"有多少条知识用了它",带 `status` 就跟着 tab 数;
|
||||
侧栏在分类树下面渲染成一排 `#标签 + 条数`,点一下筛选、再点取消。
|
||||
- `DELETE /knowledge/{item_id}?confirm=true`:软删 + 真删检索切片;只有作者或 `owner`/`admin` 能删;
|
||||
列表卡片(悬停出现)和阅读页动作行各有一个入口。删完详情也回 `7001`,
|
||||
否则收藏的链接还能打开一篇"已经删掉的知识"。
|
||||
|
||||

|
||||
|
||||
### 13.10 还没做
|
||||
|
||||
- 只做了"进"(收消息建事项),没做"出"(任务有进展回一条微信通知)。
|
||||
- 消息不认人:按通道创建者记账;要认人得先做微信身份绑定。
|
||||
- 语音只在服务端给了转写时使用,图片/文件/视频只留痕不搬。
|
||||
|
||||
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 58 KiB |
@@ -6,6 +6,7 @@
|
||||
- M0–M1 实施清单(含真实进度):[`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md)
|
||||
- 接口约定(已实现部分):[`2.Design/API.md`](2.Design/API.md)
|
||||
- 本地开发:[`2.Design/DEVELOPMENT.md`](2.Design/DEVELOPMENT.md)
|
||||
- 生产部署(openclaw-01):[`2.Design/DEPLOY.md`](2.Design/DEPLOY.md)
|
||||
- 中文命名方案:[`2.Design/NAMING.md`](2.Design/NAMING.md)
|
||||
|
||||
## 技术栈
|
||||
@@ -27,9 +28,10 @@
|
||||
| 安卓端任务 / 看板 / 工单 / 知识 / 成员五个页面 | ✅ 编译成 APK(10.1 MB),模拟器上逐页跑通,截图见 [`2.Design/screens/`](2.Design/screens) |
|
||||
| 工单:升级不搬数据、受理→办理→提交→验收、驳回、改派、站内通知 | ✅ 后端 + Web + 安卓已通,双人 e2e 8 项全绿 |
|
||||
| 知识:工单验收自动生成草稿、核对后发布、来源可追、手记 | ✅ 后端 + Web + 安卓已通,e2e 7 项全绿 |
|
||||
| 界面:两端统一设计系统(纸白 / 墨绿 / 陶土橙,语义色、字号阶、组件库) | ✅ 已重做,见 [`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md) 第 4 节 |
|
||||
| 后端测试 | ✅ 66 passed,ruff 干净 |
|
||||
| 知识切片与语义检索(pgvector) | ⬜ 配置已就绪(`deploy/docker-compose.vector.yml`),本机 Docker 起不来,未跑过 |
|
||||
| 界面:两端统一设计系统(冷白 / 靛蓝 #4F46E5 / 陶土橙,语义色、字号阶、组件库) | ✅ 已重做,见 [`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md) 第 4 节 |
|
||||
| 富文本:备注 / 工单说明 / 知识正文支持图文混排与附件 | ✅ Web 用 Vditor 编辑、安卓用 Markwon 预览编辑(e2e 15 项全绿);接口见 [`2.Design/API.md`](2.Design/API.md) 第 6 节 |
|
||||
| 知识切片与语义检索(混合打分、只搜已发布、带来源) | ✅ 已跑通:`real[]` + SQL 余弦(pgvector 待独立实例),`pytest` 5 个用例 + `e2e/retrieval.mjs` 6 项 |
|
||||
| 后端测试 | ✅ 79 passed,ruff 干净 |
|
||||
| LLM 能力(结构化录入、问答带引用、人物画像) | ⬜ M4 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -79,7 +79,9 @@ release 仍然只允许 `network_security_config.xml` 里列出的域,别把
|
||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
首次打开 → 手机号登录(开发环境验证码在接口响应里回显,形如 `开发环境验证码:123456`)。
|
||||
首次打开 → **手机号或邮箱 + 密码**登录(短信验证码登录默认关闭;开发环境需要验证码登录时,
|
||||
后端设 `INTERNAL_TEST_LOGIN=true`,固定验证码 `123456`,登录页会自动出现「验证码登录」切换)。
|
||||
没有账号就点「用邮箱注册」:收邮箱验证码 → 设密码 → 直接进工作区。
|
||||
|
||||
## 结构
|
||||
|
||||
@@ -90,11 +92,11 @@ app/src/main/java/com/dadao/task/
|
||||
│ ├── Api.kt OkHttp + kotlinx.serialization;信封解包、业务码抛 ApiException
|
||||
│ └── TokenStore.kt 令牌本地存储(TODO:换 EncryptedSharedPreferences)
|
||||
└── ui/
|
||||
├── Theme.kt 与 Web 一致的墨青 + 陶土色板
|
||||
├── LoginScreen.kt 手机号 + 验证码
|
||||
├── Theme.kt 与 Web 一致的靛蓝 #4F46E5 + 陶土色板
|
||||
├── LoginScreen.kt 手机号/邮箱 + 密码(短信登录只在后端开启时出现)、邮箱验证码注册
|
||||
├── HomeScreen.kt 工作区列表 / 新建 / 生成邀请码
|
||||
├── TaskListScreen.kt 任务视图切换、快速新建、勾选完成、详情与评论
|
||||
└── KnowledgeScreen.kt 知识四态 tab、手记、按状态给动作(发布需 owner/admin)
|
||||
└── KnowledgeScreen.kt 知识四态 tab、手记、按状态给动作(发布需 owner/admin)、分类管理与配色
|
||||
```
|
||||
|
||||
## 模拟器(本机搭建记录,别重复踩)
|
||||
|
||||
@@ -26,8 +26,9 @@ android {
|
||||
applicationId = "com.dadao.task"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
// 版本号是 OTA 的唯一判据:后端只比对 version_code,改这里才算"有新版本"
|
||||
versionCode = 3
|
||||
versionName = "0.3.0"
|
||||
buildConfigField("String", "API_BASE", "\"$apiBase\"")
|
||||
}
|
||||
|
||||
@@ -51,6 +52,37 @@ android {
|
||||
keyAlias = "androiddebugkey"
|
||||
keyPassword = "android"
|
||||
}
|
||||
/**
|
||||
* 发布签名从 android/keystore.properties 读,证书本身不进仓库。
|
||||
*
|
||||
* 没有这个文件(别人 clone 下来、CI 没注入)时回落到调试签名:
|
||||
* assembleRelease 依然能跑,只是装不上已经装了正式版的机器——
|
||||
* 而这正是应该发生的:发布包的签名只能由发布者本人持有。
|
||||
*/
|
||||
create("release") {
|
||||
val file = rootProject.file("keystore.properties")
|
||||
if (file.exists()) {
|
||||
val props = Properties()
|
||||
file.inputStream().use { props.load(it) }
|
||||
storeFile = rootProject.file(props.getProperty("storeFile"))
|
||||
storePassword = props.getProperty("storePassword")
|
||||
keyAlias = props.getProperty("keyAlias")
|
||||
keyPassword = props.getProperty("keyPassword")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// 没有正式证书时回落调试签名,保证任何机器都能 assembleRelease
|
||||
signingConfig = if (rootProject.file("keystore.properties").exists()) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
isMinifyEnabled = false
|
||||
isShrinkResources = false
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -69,9 +101,17 @@ dependencies {
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.activity:activity-compose:1.9.3")
|
||||
// FileProvider(应用内升级把安装包交给系统安装器)就在 core 里,显式声明不靠传递依赖
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
// Markdown 预览用成熟库渲染,不自己写解析器
|
||||
implementation("io.noties.markwon:core:4.6.2")
|
||||
// 表格与删除线:Web 端正文支持,安卓不能把 | 管道符原样吐给读者
|
||||
implementation("io.noties.markwon:ext-tables:4.6.2")
|
||||
implementation("io.noties.markwon:ext-strikethrough:4.6.2")
|
||||
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
}
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- 应用内升级:下好的 APK 交给系统安装器 -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="达道Task"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.DadaoTask">
|
||||
|
||||
@@ -26,5 +30,16 @@
|
||||
<data android:scheme="https" android:host="dadaotask.com" android:pathPrefix="/join/" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- 安装包只从应用自己的 updates 目录出,不外扩到其它路径 -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -17,16 +17,23 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.dadao.task.data.Api
|
||||
import com.dadao.task.data.AppRelease
|
||||
import com.dadao.task.data.AppUpdater
|
||||
import com.dadao.task.data.TokenStore
|
||||
import com.dadao.task.data.Workspace
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import com.dadao.task.ui.BoardScreen
|
||||
import com.dadao.task.ui.DadaoTheme
|
||||
import com.dadao.task.ui.EditRequest
|
||||
import com.dadao.task.ui.HomeScreen
|
||||
import com.dadao.task.ui.KnowledgeScreen
|
||||
import com.dadao.task.ui.LoginScreen
|
||||
import com.dadao.task.ui.MembersScreen
|
||||
import com.dadao.task.ui.NotificationsScreen
|
||||
import com.dadao.task.ui.RichTextEditorScreen
|
||||
import com.dadao.task.ui.TaskListScreen
|
||||
import com.dadao.task.ui.TicketsScreen
|
||||
import com.dadao.task.ui.UpdateDialog
|
||||
import com.dadao.task.ui.WorkspaceScaffold
|
||||
import com.dadao.task.ui.WorkspaceTab
|
||||
|
||||
@@ -68,6 +75,17 @@ private fun AppRoot(joinUri: String?) {
|
||||
var current by remember { mutableStateOf<Workspace?>(null) }
|
||||
var tab by remember { mutableStateOf(WorkspaceTab.Tasks) }
|
||||
var unread by remember { mutableStateOf(0) }
|
||||
// 通知页标了已读之后要立刻刷新角标:改这个数会触发下面的 LaunchedEffect
|
||||
var unreadRefresh by remember { mutableStateOf(0) }
|
||||
// 打开富文本编辑器时非空;关掉就恢复原来的界面
|
||||
var editing by remember { mutableStateOf<EditRequest?>(null) }
|
||||
// 编辑器保存成功的那条:回到工作区后重新打开它的详情,然后清掉
|
||||
var reopenItem by remember { mutableStateOf<String?>(null) }
|
||||
// 上面那条是任务还是知识:决定回哪个页面去重开
|
||||
var reopenKnowledge by remember { mutableStateOf(false) }
|
||||
// 应用内升级:启动后查一次,查到了就把对话框摆出来
|
||||
val updater = remember { AppUpdater(context, api) }
|
||||
var update by remember { mutableStateOf<AppRelease?>(null) }
|
||||
|
||||
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
|
||||
LaunchedEffect(screen) {
|
||||
@@ -96,17 +114,39 @@ private fun AppRoot(joinUri: String?) {
|
||||
.onFailure { pendingJoin = null }
|
||||
}
|
||||
|
||||
// 工单未读数:进工作区就拉一次,切页面时再刷。
|
||||
LaunchedEffect(current?.id, tab) {
|
||||
// 未读数:进工作区、切页面、或通知页标了已读(unreadRefresh)时刷新
|
||||
LaunchedEffect(current?.id, tab, unreadRefresh) {
|
||||
val ws = current ?: return@LaunchedEffect
|
||||
unread = api.unreadCount(ws.id)
|
||||
}
|
||||
|
||||
// 版本检查:登录成功后查一次就够,查不到(网络、没发布过包)就当没有新版
|
||||
LaunchedEffect(screen) {
|
||||
if (screen != Screen.Home && screen != Screen.Workspace) return@LaunchedEffect
|
||||
if (update != null) return@LaunchedEffect
|
||||
update = runCatching { updater.check() }.getOrNull()
|
||||
}
|
||||
|
||||
/** 手动检查:结果用一句话回给首页显示,查到新版顺便把对话框摆出来。 */
|
||||
suspend fun checkUpdate(): String = runCatching { updater.checkStrict() }.fold(
|
||||
onSuccess = { release ->
|
||||
if (release == null) {
|
||||
"已经是最新版 ${updater.currentVersionName}"
|
||||
} else {
|
||||
update = release
|
||||
"发现新版本 ${release.versionName}"
|
||||
}
|
||||
},
|
||||
onFailure = { "检查更新失败:${it.message ?: "网络不通"}" },
|
||||
)
|
||||
|
||||
when (screen) {
|
||||
Screen.Splash -> Unit
|
||||
Screen.Login -> LoginScreen(api = api, onLoggedIn = { screen = Screen.Home })
|
||||
Screen.Home -> HomeScreen(
|
||||
api = api,
|
||||
versionName = updater.currentVersionName,
|
||||
onCheckUpdate = { checkUpdate() },
|
||||
onLogout = {
|
||||
tokens.clear()
|
||||
screen = Screen.Login
|
||||
@@ -119,8 +159,45 @@ private fun AppRoot(joinUri: String?) {
|
||||
)
|
||||
Screen.Workspace -> {
|
||||
val ws = current
|
||||
val request = editing
|
||||
if (ws == null) {
|
||||
screen = Screen.Home
|
||||
} else if (request != null) {
|
||||
// 编辑器**替换**整页,而不是叠在上面:抽屉/对话框都是独立窗口,
|
||||
// 叠一层会被它们盖住,用户只能看到编辑器的一条边。
|
||||
RichTextEditorScreen(
|
||||
api = api,
|
||||
workspaceId = ws.id,
|
||||
itemId = request.itemId,
|
||||
initialTitle = request.title,
|
||||
initialBody = request.body,
|
||||
titleEnabled = request.titleEnabled,
|
||||
save = { title, body ->
|
||||
if (request.knowledge) {
|
||||
api.updateKnowledge(
|
||||
workspaceId = ws.id,
|
||||
itemId = request.itemId,
|
||||
version = request.version,
|
||||
title = title,
|
||||
body = body,
|
||||
)
|
||||
} else {
|
||||
api.updateItem(
|
||||
workspaceId = ws.id,
|
||||
itemId = request.itemId,
|
||||
version = request.version,
|
||||
title = title,
|
||||
body = body,
|
||||
)
|
||||
}
|
||||
// 保存成功才标记"回到哪条":取消退出不该改变任何界面状态。
|
||||
reopenItem = request.itemId
|
||||
reopenKnowledge = request.knowledge
|
||||
},
|
||||
// saved 参数由编辑器给:true 时上面已经设过 reopenItem,
|
||||
// 这里只负责把编辑器收起来,回到工作区后由对应页面重开详情。
|
||||
onClose = { _ -> editing = null },
|
||||
)
|
||||
} else {
|
||||
WorkspaceScaffold(
|
||||
workspace = ws,
|
||||
@@ -130,13 +207,54 @@ private fun AppRoot(joinUri: String?) {
|
||||
onBackHome = { screen = Screen.Home },
|
||||
) {
|
||||
when (tab) {
|
||||
WorkspaceTab.Tasks -> TaskListScreen(api = api, workspace = ws)
|
||||
WorkspaceTab.Tickets -> TicketsScreen(api = api, workspace = ws)
|
||||
WorkspaceTab.Knowledge -> KnowledgeScreen(api = api, workspace = ws)
|
||||
WorkspaceTab.Tasks -> TaskListScreen(
|
||||
api = api,
|
||||
workspace = ws,
|
||||
onEdit = { editing = it },
|
||||
reopenItem = if (reopenItem != null && !reopenKnowledge) reopenItem else null,
|
||||
onReopened = { reopenItem = null },
|
||||
)
|
||||
|
||||
WorkspaceTab.Tickets -> TicketsScreen(
|
||||
api = api,
|
||||
workspace = ws,
|
||||
focusId = reopenItem,
|
||||
onFocusHandled = { reopenItem = null },
|
||||
)
|
||||
WorkspaceTab.Knowledge -> KnowledgeScreen(
|
||||
api = api,
|
||||
workspace = ws,
|
||||
onEdit = { editing = it },
|
||||
reopenItem = if (reopenItem != null && reopenKnowledge) reopenItem else null,
|
||||
onReopened = { reopenItem = null },
|
||||
)
|
||||
|
||||
WorkspaceTab.Members -> MembersScreen(api = api, workspace = ws)
|
||||
|
||||
WorkspaceTab.Notifications -> NotificationsScreen(
|
||||
api = api,
|
||||
workspaceId = ws.id,
|
||||
onOpenItem = { itemId, isTicket ->
|
||||
// 通知点开后跳到对应列表并展开那一条;通知本身不带数据,页面自己回源
|
||||
reopenItem = itemId
|
||||
reopenKnowledge = false
|
||||
tab = if (isTicket) WorkspaceTab.Tickets else WorkspaceTab.Tasks
|
||||
},
|
||||
onUnreadChanged = { unreadRefresh += 1 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新对话框是独立窗口,摆在哪一层都能盖住当前界面
|
||||
update?.let { release ->
|
||||
UpdateDialog(
|
||||
release = release,
|
||||
currentVersionName = updater.currentVersionName,
|
||||
updater = updater,
|
||||
onLater = { update = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.dadao.task.data
|
||||
|
||||
import com.dadao.task.BuildConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerialName
|
||||
@@ -10,6 +12,8 @@ import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
@@ -40,10 +44,34 @@ private data class Envelope<T>(
|
||||
@Serializable
|
||||
data class User(
|
||||
val id: String,
|
||||
val phone: String,
|
||||
/**
|
||||
* 邮箱注册的账号没有手机号;有手机号的也只给脱敏值(138****8000),
|
||||
* 与后端 `_user_out` 一致。所以这里必须可空——否则邮箱账号一登录就解析失败。
|
||||
*/
|
||||
val phone: String? = null,
|
||||
val email: String? = null,
|
||||
@SerialName("email_verified") val emailVerified: Boolean = false,
|
||||
@SerialName("has_password") val hasPassword: Boolean = false,
|
||||
val nickname: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* 这台服务现在开放哪些登录入口(后端 `/auth/methods`)。
|
||||
*
|
||||
* 生产早就把短信登录关了,客户端不能"照着界面上有就点"——
|
||||
* 点进去才发现这条路关着最气人,所以登录页先问一次再渲染。
|
||||
*/
|
||||
@Serializable
|
||||
data class AuthMethods(
|
||||
val password: Boolean = true,
|
||||
@SerialName("email_password") val emailPassword: Boolean = true,
|
||||
@SerialName("sms_code") val smsCode: Boolean = false,
|
||||
@SerialName("email_register") val emailRegister: Boolean = false,
|
||||
) {
|
||||
/** 一条路都没有时至少让密码登录留在界面上,免得白屏。 */
|
||||
val usable: Boolean get() = password || emailPassword || smsCode
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Tokens(
|
||||
@SerialName("access_token") val accessToken: String,
|
||||
@@ -136,8 +164,29 @@ data class ItemDetail(
|
||||
val tags: List<Tag> = emptyList(),
|
||||
val version: Int = 1,
|
||||
val activities: List<Activity> = emptyList(),
|
||||
val attachments: List<Attachment> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* 附件。[rawUrl] 是后端签发的短时直链(带 exp/sig),
|
||||
* 正文里的图片就用它,**不要**把长期凭据写进正文。
|
||||
*/
|
||||
@Serializable
|
||||
data class Attachment(
|
||||
val id: String,
|
||||
@SerialName("item_id") val itemId: String = "",
|
||||
val filename: String = "",
|
||||
@SerialName("content_type") val contentType: String = "application/octet-stream",
|
||||
val size: Long = 0,
|
||||
@SerialName("uploader_id") val uploaderId: String = "",
|
||||
@SerialName("created_at") val createdAt: String = "",
|
||||
@SerialName("raw_url") val rawUrl: String? = null,
|
||||
@SerialName("can_delete") val canDelete: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AttachmentPage(val items: List<Attachment> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class Invitation(
|
||||
val code: String,
|
||||
@@ -213,6 +262,44 @@ data class SourceRef(
|
||||
val status: String = "",
|
||||
)
|
||||
|
||||
/** 服务端发布的安卓安装包(OTA)。`hasUpdate` 由服务端按 version_code 比较后给。 */
|
||||
@Serializable
|
||||
data class AppRelease(
|
||||
val platform: String = "android",
|
||||
@SerialName("version_code") val versionCode: Int = 0,
|
||||
@SerialName("version_name") val versionName: String = "",
|
||||
val notes: String = "",
|
||||
val file: String = "",
|
||||
val size: Long = 0,
|
||||
val sha256: String = "",
|
||||
@SerialName("min_sdk") val minSdk: Int = 26,
|
||||
@SerialName("published_at") val publishedAt: String? = null,
|
||||
@SerialName("download_url") val downloadUrl: String = "",
|
||||
@SerialName("has_update") val hasUpdate: Boolean = false,
|
||||
)
|
||||
|
||||
/** 知识分类:工作区级的三级小树,fullPath 形如「研发 / 部署 / 回滚」。 */
|
||||
@Serializable
|
||||
data class KnowledgeCategory(
|
||||
val id: String,
|
||||
val name: String = "",
|
||||
@SerialName("parent_id") val parentId: String? = null,
|
||||
/** 分类色(#RRGGBB),卡片上边线用它;没设时前端按 id 稳定挑一个默认色。 */
|
||||
val color: String? = null,
|
||||
val depth: Int = 1,
|
||||
@SerialName("sort_order") val sortOrder: Int = 0,
|
||||
val version: Int = 1,
|
||||
val path: List<String> = emptyList(),
|
||||
@SerialName("full_path") val fullPath: String = "",
|
||||
@SerialName("doc_count") val docCount: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KnowledgeCategoryPage(
|
||||
val items: List<KnowledgeCategory> = emptyList(),
|
||||
@SerialName("max_depth") val maxDepth: Int = 3,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KnowledgeDoc(
|
||||
val id: String,
|
||||
@@ -222,7 +309,9 @@ data class KnowledgeDoc(
|
||||
val version: Int = 1,
|
||||
val tags: List<Tag> = emptyList(),
|
||||
val knowledge: KnowledgeInfo = KnowledgeInfo(),
|
||||
val category: KnowledgeCategory? = null,
|
||||
val source: SourceRef? = null,
|
||||
val attachments: List<Attachment> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -234,6 +323,25 @@ data class KnowledgePage(
|
||||
|
||||
// ---- 请求体。每个都要 @Serializable,否则 encodeToString 找不到序列化器 ----
|
||||
|
||||
@Serializable
|
||||
data class AppNotification(
|
||||
val id: String,
|
||||
val kind: String = "",
|
||||
@SerialName("item_id") val itemId: String? = null,
|
||||
@SerialName("item_title") val itemTitle: String? = null,
|
||||
val title: String = "",
|
||||
val body: String = "",
|
||||
@SerialName("read_at") val readAt: String? = null,
|
||||
@SerialName("created_at") val createdAt: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NotificationPage(
|
||||
val notifications: List<AppNotification> = emptyList(),
|
||||
@SerialName("next_cursor") val nextCursor: String? = null,
|
||||
@SerialName("has_more") val hasMore: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class UnreadCount(@SerialName("unread") val unread: Int = 0)
|
||||
|
||||
@@ -243,6 +351,25 @@ private data class SmsRequest(val phone: String)
|
||||
@Serializable
|
||||
private data class LoginRequest(val phone: String, val code: String)
|
||||
|
||||
/** 手机号或邮箱二选一:只发一个字段,另一个为 null 时不会被序列化出去。 */
|
||||
@Serializable
|
||||
private data class PasswordLoginRequest(
|
||||
val phone: String? = null,
|
||||
val email: String? = null,
|
||||
val password: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class EmailCodeRequest(val email: String)
|
||||
|
||||
@Serializable
|
||||
private data class RegisterRequest(
|
||||
val email: String,
|
||||
val code: String,
|
||||
val password: String,
|
||||
val nickname: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class RefreshRequest(@SerialName("refresh_token") val refreshToken: String)
|
||||
|
||||
@@ -282,16 +409,59 @@ private data class KnowledgeCreateRequest(
|
||||
val title: String,
|
||||
@SerialName("body_md") val bodyMd: String,
|
||||
@SerialName("doc_type") val docType: String,
|
||||
@SerialName("tag_names") val tagNames: List<String> = emptyList(),
|
||||
@SerialName("category_id") val categoryId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class KnowledgeActionRequest(val action: String)
|
||||
|
||||
@Serializable
|
||||
private data class CategoryCreateRequest(
|
||||
val name: String,
|
||||
@SerialName("parent_id") val parentId: String? = null,
|
||||
@SerialName("sort_order") val sortOrder: Int = 0,
|
||||
val color: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 分类改名 / 改色。`version` 是乐观锁,必填。
|
||||
*
|
||||
* color 传空串表示"取消上色",不传(null)表示"别动颜色"——
|
||||
* 靠 `explicitNulls = false` 把 null 字段整个省掉来区分这两件事。
|
||||
*/
|
||||
@Serializable
|
||||
private data class CategoryPatchRequest(
|
||||
val version: Int,
|
||||
val name: String? = null,
|
||||
val color: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class TicketCreateRequest(
|
||||
val title: String,
|
||||
@SerialName("body_md") val bodyMd: String? = null,
|
||||
@SerialName("assignee_id") val assigneeId: String,
|
||||
val priority: Int = 1,
|
||||
@SerialName("due_at") val dueAt: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class ItemUpdateRequest(
|
||||
val version: Int,
|
||||
val title: String? = null,
|
||||
@SerialName("body_md") val bodyMd: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class KnowledgeUpdateRequest(
|
||||
val version: Int,
|
||||
val title: String? = null,
|
||||
@SerialName("body_md") val bodyMd: String? = null,
|
||||
@SerialName("tag_names") val tagNames: List<String>? = null,
|
||||
@SerialName("category_id") val categoryId: String? = null,
|
||||
// 想把分类清成"未分类"就传 clear_category=true:只传 null 会被服务端当成"不改"
|
||||
@SerialName("clear_category") val clearCategory: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -372,6 +542,58 @@ class Api(private val tokens: TokenStore) {
|
||||
|
||||
suspend fun me(): MeResult = call(path = "/auth/me", auth = true)
|
||||
|
||||
/** 这台服务开放哪些登录入口;登录页拿到之后才决定渲染哪几个 tab。 */
|
||||
suspend fun authMethods(): AuthMethods = call(path = "/auth/methods")
|
||||
|
||||
/**
|
||||
* 手机号 / 邮箱 + 密码登录。
|
||||
*
|
||||
* 账号里有没有 `@` 决定发哪个字段:后端两个都收,但带着一个空字符串 phone
|
||||
* 会被 pattern 校验挡掉,所以这里只发一个。
|
||||
*/
|
||||
suspend fun loginPassword(account: String, password: String): LoginResult {
|
||||
val who = account.trim()
|
||||
val body = if (who.contains('@')) {
|
||||
PasswordLoginRequest(email = who, password = password)
|
||||
} else {
|
||||
PasswordLoginRequest(phone = who, password = password)
|
||||
}
|
||||
val result: LoginResult = call(
|
||||
path = "/auth/login/password",
|
||||
body = json.encodeToString(body),
|
||||
)
|
||||
tokens.save(result.tokens.accessToken, result.tokens.refreshToken)
|
||||
return result
|
||||
}
|
||||
|
||||
/** 邮箱注册验证码。生产真发信;`dev_code` 只在开了 EMAIL_CODE_DEV_ECHO 的本机才回显。 */
|
||||
suspend fun sendEmailCode(email: String): JsonElement? = call(
|
||||
path = "/auth/email/code",
|
||||
body = json.encodeToString(EmailCodeRequest(email.trim())),
|
||||
)
|
||||
|
||||
/** 邮箱 + 验证码 + 密码一步建号,注册完就是登录态(服务端直接给令牌)。 */
|
||||
suspend fun register(
|
||||
email: String,
|
||||
code: String,
|
||||
password: String,
|
||||
nickname: String? = null,
|
||||
): LoginResult {
|
||||
val result: LoginResult = call(
|
||||
path = "/auth/register",
|
||||
body = json.encodeToString(
|
||||
RegisterRequest(
|
||||
email = email.trim(),
|
||||
code = code.trim(),
|
||||
password = password,
|
||||
nickname = nickname?.trim()?.ifBlank { null },
|
||||
),
|
||||
),
|
||||
)
|
||||
tokens.save(result.tokens.accessToken, result.tokens.refreshToken)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表类接口服务端返回的是 `{"items": [...]}` 而不是裸数组
|
||||
* (`backend/app/api/workspaces.py` 的 list_workspaces),
|
||||
@@ -386,6 +608,13 @@ class Api(private val tokens: TokenStore) {
|
||||
auth = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* 查最新安装包。不带 auth:还没登录就可能在检查更新,
|
||||
* 传当前 versionCode 让服务端直接算好 `hasUpdate`。
|
||||
*/
|
||||
suspend fun latestAppRelease(versionCode: Int): AppRelease =
|
||||
call(path = "/app/release/latest?version_code=$versionCode")
|
||||
|
||||
suspend fun members(workspaceId: String): List<Member> =
|
||||
call<MemberPage>(path = "/workspaces/$workspaceId/members", auth = true).items
|
||||
|
||||
@@ -396,6 +625,48 @@ class Api(private val tokens: TokenStore) {
|
||||
auth = true,
|
||||
)
|
||||
|
||||
// ---- M4 通知 ----
|
||||
|
||||
/** 通知默认只看未读;"全部"是查历史用的。 */
|
||||
suspend fun notifications(
|
||||
workspaceId: String,
|
||||
unreadOnly: Boolean = true,
|
||||
): NotificationPage = call(
|
||||
path = "/workspaces/$workspaceId/notifications?unread_only=$unreadOnly&limit=50",
|
||||
auth = true,
|
||||
)
|
||||
|
||||
// OkHttp 的 POST 必须带 body,这里发个空对象,服务端不需要入参
|
||||
suspend fun markNotificationRead(workspaceId: String, id: String): JsonElement = call(
|
||||
path = "/workspaces/$workspaceId/notifications/$id/read",
|
||||
body = "{}",
|
||||
auth = true,
|
||||
)
|
||||
|
||||
suspend fun markAllNotificationsRead(workspaceId: String): JsonElement = call(
|
||||
path = "/workspaces/$workspaceId/notifications/read-all",
|
||||
body = "{}",
|
||||
auth = true,
|
||||
)
|
||||
|
||||
// ---- M2 工单(手工开单)----
|
||||
|
||||
/** 不经过任务,直接开一张工单交给别人。 */
|
||||
suspend fun createTicket(
|
||||
workspaceId: String,
|
||||
title: String,
|
||||
assigneeId: String,
|
||||
bodyMd: String? = null,
|
||||
priority: Int = 1,
|
||||
dueAt: String? = null,
|
||||
): Ticket = call(
|
||||
path = "/workspaces/$workspaceId/tickets",
|
||||
body = json.encodeToString(
|
||||
TicketCreateRequest(title, bodyMd?.ifBlank { null }, assigneeId, priority, dueAt),
|
||||
),
|
||||
auth = true,
|
||||
)
|
||||
|
||||
/** 未读工单通知数,导航角标用。失败就当 0,不要因为角标让整页报错。 */
|
||||
suspend fun unreadCount(workspaceId: String): Int = runCatching {
|
||||
call<UnreadCount>(path = "/workspaces/$workspaceId/notifications/unread-count", auth = true).unread
|
||||
@@ -451,6 +722,67 @@ class Api(private val tokens: TokenStore) {
|
||||
auth = true,
|
||||
)
|
||||
|
||||
// ---- 附件与富文本 ----
|
||||
|
||||
suspend fun attachments(workspaceId: String, itemId: String): List<Attachment> =
|
||||
call<AttachmentPage>(
|
||||
path = "/workspaces/$workspaceId/items/$itemId/attachments",
|
||||
auth = true,
|
||||
).items
|
||||
|
||||
/**
|
||||
* 上传附件。返回的 [Attachment.rawUrl] 是短时签名直链,
|
||||
* 直接写进 Markdown 的 `` 即可,不需要把登录令牌塞进正文。
|
||||
*/
|
||||
suspend fun uploadAttachment(
|
||||
workspaceId: String,
|
||||
itemId: String,
|
||||
filename: String,
|
||||
mimeType: String,
|
||||
bytes: ByteArray,
|
||||
): Attachment = withContext(Dispatchers.IO) {
|
||||
val form = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart(
|
||||
"file",
|
||||
filename,
|
||||
bytes.toRequestBody(mimeType.toMediaTypeOrNull()),
|
||||
)
|
||||
.build()
|
||||
|
||||
val builder = Request.Builder()
|
||||
.url("$base/workspaces/$workspaceId/items/$itemId/attachments")
|
||||
.post(form)
|
||||
.header("Accept", "application/json")
|
||||
tokens.accessToken?.let { builder.header("Authorization", "Bearer $it") }
|
||||
|
||||
try {
|
||||
http.newCall(builder.build()).execute().use { response ->
|
||||
val text = response.body?.string().orEmpty()
|
||||
val envelope = runCatching { json.decodeFromString<Envelope<Attachment>>(text) }
|
||||
.getOrElse { throw ApiException(1500, "上传失败(HTTP ${response.code})") }
|
||||
if (envelope.code != 0) throw ApiException(envelope.code, envelope.message, envelope.requestId)
|
||||
envelope.data ?: throw ApiException(1500, "上传响应缺少 data")
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
throw unreachable(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存正文/标题。PATCH 必须带 version,冲突由服务端返回 4009。 */
|
||||
suspend fun updateItem(
|
||||
workspaceId: String,
|
||||
itemId: String,
|
||||
version: Int,
|
||||
title: String? = null,
|
||||
body: String? = null,
|
||||
): ItemDetail = call(
|
||||
path = "/workspaces/$workspaceId/items/$itemId",
|
||||
body = json.encodeToString(ItemUpdateRequest(version, title, body)),
|
||||
auth = true,
|
||||
method = "PATCH",
|
||||
)
|
||||
|
||||
// ---- M2 工单 ----
|
||||
|
||||
/** role: assigned(派给我的)/ requested(我发起的)/ all。 */
|
||||
@@ -504,20 +836,83 @@ class Api(private val tokens: TokenStore) {
|
||||
workspaceId: String,
|
||||
statuses: List<String> = listOf("draft", "in_review"),
|
||||
query: String? = null,
|
||||
categoryId: String? = null,
|
||||
uncategorized: Boolean = false,
|
||||
): KnowledgePage {
|
||||
val params = buildString {
|
||||
append("?limit=30")
|
||||
statuses.forEach { append("&status=").append(it) }
|
||||
if (!query.isNullOrBlank()) append("&q=").append(java.net.URLEncoder.encode(query, "UTF-8"))
|
||||
if (!query.isNullOrBlank()) {
|
||||
append("&q=").append(java.net.URLEncoder.encode(query, "UTF-8"))
|
||||
}
|
||||
if (categoryId != null) append("&category_id=").append(categoryId)
|
||||
if (uncategorized) append("&uncategorized=true")
|
||||
}
|
||||
return call(path = "/workspaces/$workspaceId/knowledge$params", auth = true)
|
||||
}
|
||||
|
||||
/** 工作区三级分类小树(扁平返回,depth 表示层级)。 */
|
||||
suspend fun knowledgeCategories(workspaceId: String): KnowledgeCategoryPage =
|
||||
call(path = "/workspaces/$workspaceId/knowledge/categories", auth = true)
|
||||
|
||||
/** 建分类;同层重名会被服务端拒(4006)。color 不传就是不上色。 */
|
||||
suspend fun createKnowledgeCategory(
|
||||
workspaceId: String,
|
||||
name: String,
|
||||
parentId: String? = null,
|
||||
color: String? = null,
|
||||
): KnowledgeCategory = call(
|
||||
path = "/workspaces/$workspaceId/knowledge/categories",
|
||||
body = json.encodeToString(
|
||||
CategoryCreateRequest(name = name.trim(), parentId = parentId, color = color),
|
||||
),
|
||||
auth = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* 改名 / 改色,必须带 version。
|
||||
*
|
||||
* color 传 `""` 表示取消上色,传 null 表示别动颜色——这个区别由
|
||||
* `explicitNulls = false`(null 字段直接不序列化)兜住。
|
||||
*/
|
||||
suspend fun patchKnowledgeCategory(
|
||||
workspaceId: String,
|
||||
categoryId: String,
|
||||
version: Int,
|
||||
name: String? = null,
|
||||
color: String? = null,
|
||||
): KnowledgeCategory = call(
|
||||
path = "/workspaces/$workspaceId/knowledge/categories/$categoryId",
|
||||
body = json.encodeToString(CategoryPatchRequest(version, name?.trim(), color)),
|
||||
auth = true,
|
||||
method = "PATCH",
|
||||
)
|
||||
|
||||
/** 删分类。force=true 才能删有内容的分类,里面的知识会退成未分类。 */
|
||||
suspend fun deleteKnowledgeCategory(
|
||||
workspaceId: String,
|
||||
categoryId: String,
|
||||
force: Boolean = false,
|
||||
): JsonElement = call(
|
||||
path = "/workspaces/$workspaceId/knowledge/categories/$categoryId?force=$force",
|
||||
auth = true,
|
||||
method = "DELETE",
|
||||
)
|
||||
|
||||
/** 手记:没有来源的知识,得自己写清楚。 */
|
||||
suspend fun createKnowledge(workspaceId: String, title: String, body: String = ""): KnowledgeDoc =
|
||||
suspend fun createKnowledge(
|
||||
workspaceId: String,
|
||||
title: String,
|
||||
body: String = "",
|
||||
docType: String = "note",
|
||||
tags: List<String> = emptyList(),
|
||||
categoryId: String? = null,
|
||||
): KnowledgeDoc =
|
||||
call(
|
||||
path = "/workspaces/$workspaceId/knowledge",
|
||||
body = json.encodeToString(KnowledgeCreateRequest(title, body, "note")),
|
||||
body = json.encodeToString(
|
||||
KnowledgeCreateRequest(title, body, docType, tags, categoryId),
|
||||
),
|
||||
auth = true,
|
||||
)
|
||||
|
||||
@@ -547,18 +942,67 @@ class Api(private val tokens: TokenStore) {
|
||||
version: Int,
|
||||
title: String? = null,
|
||||
body: String? = null,
|
||||
tags: List<String>? = null,
|
||||
categoryId: String? = null,
|
||||
clearCategory: Boolean = false,
|
||||
): KnowledgeDoc = call(
|
||||
path = "/workspaces/$workspaceId/knowledge/$itemId",
|
||||
body = json.encodeToString(KnowledgeUpdateRequest(version, title, body)),
|
||||
body = json.encodeToString(
|
||||
KnowledgeUpdateRequest(version, title, body, tags, categoryId, clearCategory),
|
||||
),
|
||||
auth = true,
|
||||
method = "PATCH",
|
||||
)
|
||||
|
||||
/**
|
||||
* 访问令牌只有 30 分钟,refresh 有 14 天。
|
||||
*
|
||||
* 撞上 2002(登录已过期)时先用 refresh 换一对新令牌,把这次请求原样重放一次;
|
||||
* 换不动才清令牌让界面回登录页。以前是直接清,用户用一会儿就被踢出去。
|
||||
* 并发请求共用一把锁:刷新只发一次,其余等它换完直接用新令牌。
|
||||
*/
|
||||
private suspend inline fun <reified T> call(
|
||||
path: String,
|
||||
body: String? = null,
|
||||
auth: Boolean = false,
|
||||
method: String? = null,
|
||||
): T {
|
||||
val stale = tokens.accessToken
|
||||
return try {
|
||||
callOnce<T>(path, body, auth, method)
|
||||
} catch (e: ApiException) {
|
||||
if (!auth || e.code != 2002 || !refreshTokens(stale)) throw e
|
||||
callOnce<T>(path, body, auth, method)
|
||||
}
|
||||
}
|
||||
|
||||
private val refreshLock = Mutex()
|
||||
|
||||
private suspend fun refreshTokens(stale: String?): Boolean {
|
||||
val refresh = tokens.refreshToken ?: return false
|
||||
return refreshLock.withLock {
|
||||
// 等锁期间别人可能已经换完了:本地令牌已经不是失败的那一个,直接用新的
|
||||
val current = tokens.accessToken
|
||||
if (current != null && current != stale) return@withLock true
|
||||
try {
|
||||
val fresh: Tokens = call(
|
||||
path = "/auth/refresh",
|
||||
body = json.encodeToString(RefreshRequest(refresh)),
|
||||
)
|
||||
tokens.save(fresh.accessToken, fresh.refreshToken)
|
||||
true
|
||||
} catch (e: ApiException) {
|
||||
tokens.clear()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T> callOnce(
|
||||
path: String,
|
||||
body: String? = null,
|
||||
auth: Boolean = false,
|
||||
method: String? = null,
|
||||
): T = withContext(Dispatchers.IO) {
|
||||
val builder = Request.Builder().url(base + path)
|
||||
val verb = method ?: if (body == null) "GET" else "POST"
|
||||
@@ -582,7 +1026,6 @@ class Api(private val tokens: TokenStore) {
|
||||
throw ApiException(1500, "响应格式异常(HTTP ${response.code})")
|
||||
}
|
||||
if (envelope.code != 0) {
|
||||
if (envelope.code == 2002 && auth) tokens.clear()
|
||||
throw ApiException(envelope.code, envelope.message, envelope.requestId)
|
||||
}
|
||||
envelope.data ?: throw ApiException(1500, "响应缺少 data")
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.dadao.task.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.core.content.FileProvider
|
||||
import com.dadao.task.BuildConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* 应用内升级(OTA)。
|
||||
*
|
||||
* 流程刻意做得很短:查清单 → 下到应用自己的目录 → 交给系统安装器。
|
||||
* 不申请存储权限、不自己实现安装界面:APK 走 [FileProvider] 授权给系统,
|
||||
* 「允许安装未知应用」这类系统开关也必须由用户在系统设置里点,不能被代劳。
|
||||
*/
|
||||
class AppUpdater(private val context: Context, private val api: Api) {
|
||||
|
||||
private val http = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val currentVersionName: String = BuildConfig.VERSION_NAME
|
||||
|
||||
/**
|
||||
* 有新版就返回它,否则 null。
|
||||
*
|
||||
* 网络失败、后端还没发布过安装包都当成"没有更新":启动时不打扰用户,
|
||||
* 手动检查那次再把异常抛给界面去提示。
|
||||
*/
|
||||
suspend fun check(): AppRelease? = runCatching {
|
||||
api.latestAppRelease(BuildConfig.VERSION_CODE)
|
||||
}.getOrNull()?.takeIf { it.hasUpdate && it.downloadUrl.isNotBlank() }
|
||||
|
||||
/** 同 [check],但失败会抛异常——给「检查更新」按钮用。 */
|
||||
suspend fun checkStrict(): AppRelease? {
|
||||
val release = api.latestAppRelease(BuildConfig.VERSION_CODE)
|
||||
return release.takeIf { it.hasUpdate && it.downloadUrl.isNotBlank() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 把安装包下到 `Android/data/<pkg>/files/updates/` 下。
|
||||
*
|
||||
* 先写 .part 再改名:下到一半断网不会留下一个"看起来能用"的坏 APK。
|
||||
* 清单带了 sha256 就校验,防止内网代理/缓存给出旧包。
|
||||
*/
|
||||
suspend fun download(
|
||||
release: AppRelease,
|
||||
onProgress: (downloaded: Long, total: Long) -> Unit = { _, _ -> },
|
||||
): File = withContext(Dispatchers.IO) {
|
||||
val dir = File(context.getExternalFilesDir(null) ?: context.filesDir, "updates")
|
||||
dir.mkdirs()
|
||||
val safeName = release.file.ifBlank { "dadaotask-${release.versionName}.apk" }
|
||||
val target = File(dir, safeName)
|
||||
val partial = File(dir, "$safeName.part")
|
||||
|
||||
val url = api.currentOrigin() + release.downloadUrl
|
||||
val response = try {
|
||||
http.newCall(Request.Builder().url(url).build()).execute()
|
||||
} catch (e: IOException) {
|
||||
throw ApiException(1, "下载失败:连不上 $url")
|
||||
}
|
||||
response.use { res ->
|
||||
if (!res.isSuccessful) throw ApiException(1, "下载失败:HTTP ${res.code}")
|
||||
val body = res.body ?: throw ApiException(1, "下载失败:响应为空")
|
||||
val total = if (release.size > 0) release.size else body.contentLength()
|
||||
partial.outputStream().use { out ->
|
||||
body.byteStream().use { input ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
var downloaded = 0L
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read <= 0) break
|
||||
out.write(buffer, 0, read)
|
||||
downloaded += read
|
||||
onProgress(downloaded, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (release.sha256.isNotBlank() && sha256Of(partial) != release.sha256.lowercase()) {
|
||||
partial.delete()
|
||||
throw ApiException(1, "安装包校验不通过(sha256 不一致),请稍后重试")
|
||||
}
|
||||
if (target.exists()) target.delete()
|
||||
if (!partial.renameTo(target)) throw ApiException(1, "安装包保存失败,检查存储空间")
|
||||
target
|
||||
}
|
||||
|
||||
/** 应用没有「安装未知应用」权限时,先把用户送到系统设置页。 */
|
||||
fun canInstall(): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.O || context.packageManager.canRequestPackageInstalls()
|
||||
|
||||
fun openInstallPermission(): Boolean {
|
||||
if (canInstall()) return false
|
||||
val intent = Intent(
|
||||
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:${context.packageName}"),
|
||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
runCatching { context.startActivity(intent) }
|
||||
return true
|
||||
}
|
||||
|
||||
/** 交给系统安装器。返回 false 表示还没给"安装未知应用"权限。 */
|
||||
fun install(file: File): Boolean {
|
||||
if (!canInstall()) return false
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun sha256Of(file: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().use { input ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read <= 0) break
|
||||
digest.update(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
@@ -29,11 +29,12 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.dadao.task.data.Workspace
|
||||
|
||||
/** 手机端的五个去处。看板不是独立入口,而是任务页顶部的一个切换(屏幕太窄,塞不下六个 tab)。 */
|
||||
/** 手机端的去处。看板不是独立入口,而是任务页顶部的一个切换(屏幕太窄,塞不下更多 tab)。 */
|
||||
enum class WorkspaceTab(val label: String) {
|
||||
Tasks("任务"),
|
||||
Tickets("工单"),
|
||||
Knowledge("知识"),
|
||||
Notifications("通知"),
|
||||
Members("成员"),
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,13 @@ private fun roleLabel(role: String?): String = when (role) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(api: Api, onLogout: () -> Unit, onOpenWorkspace: (Workspace) -> Unit) {
|
||||
fun HomeScreen(
|
||||
api: Api,
|
||||
versionName: String,
|
||||
onCheckUpdate: suspend () -> String,
|
||||
onLogout: () -> Unit,
|
||||
onOpenWorkspace: (Workspace) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var user by remember { mutableStateOf<User?>(null) }
|
||||
var workspaces by remember { mutableStateOf<List<Workspace>>(emptyList()) }
|
||||
@@ -71,6 +77,9 @@ fun HomeScreen(api: Api, onLogout: () -> Unit, onOpenWorkspace: (Workspace) -> U
|
||||
var name by remember { mutableStateOf("") }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
// 手动检查更新的回话:查到/没查到/失败都用一句话说清楚
|
||||
var updateNotice by remember { mutableStateOf<String?>(null) }
|
||||
var checking by remember { mutableStateOf(false) }
|
||||
|
||||
suspend fun reload() {
|
||||
runCatching { api.me() }
|
||||
@@ -234,6 +243,38 @@ fun HomeScreen(api: Api, onLogout: () -> Unit, onOpenWorkspace: (Workspace) -> U
|
||||
item { SoftPill(notice, Rust, RustSoft, Modifier.fillMaxWidth()) }
|
||||
}
|
||||
|
||||
item {
|
||||
Column(Modifier.padding(top = 6.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"达道Task v$versionName",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = InkMuted,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
if (checking) "检查中…" else "检查更新",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Primary,
|
||||
modifier = Modifier
|
||||
.clickable(enabled = !checking) {
|
||||
checking = true
|
||||
scope.launch {
|
||||
updateNotice = onCheckUpdate()
|
||||
checking = false
|
||||
}
|
||||
}
|
||||
.padding(vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
val note = updateNotice
|
||||
if (note != null) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(note, style = MaterialTheme.typography.labelSmall, color = InkMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(40.dp)) }
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,12 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -19,6 +20,7 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -32,23 +34,45 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.dadao.task.data.Api
|
||||
import com.dadao.task.data.ApiException
|
||||
import com.dadao.task.data.AuthMethods
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/** 手机号 11 位、邮箱只要有 @ 就放行——最终格式由服务端判,前端别提前拦。 */
|
||||
private fun looksLikeEmail(input: String): Boolean = input.contains('@')
|
||||
|
||||
private fun accountReady(account: String): Boolean {
|
||||
val text = account.trim()
|
||||
return if (looksLikeEmail(text)) text.length >= 6 else text.length == 11
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// 登录入口以服务端为准:生产早就关了短信登录,界面不能还摆着一条走不通的路。
|
||||
var methods by remember { mutableStateOf<AuthMethods?>(null) }
|
||||
var mode by remember { mutableStateOf("password") }
|
||||
|
||||
var account by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
|
||||
var phone by remember { mutableStateOf("") }
|
||||
var code by remember { mutableStateOf("") }
|
||||
var seconds by remember { mutableStateOf(0) }
|
||||
var devCode by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var registerOpen by remember { mutableStateOf(false) }
|
||||
|
||||
// 后端地址可改:包里默认是模拟器地址 10.0.2.2,真机必须换成电脑的局域网地址。
|
||||
var serverOpen by remember { mutableStateOf(false) }
|
||||
@@ -86,6 +110,15 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching { api.authMethods() }
|
||||
.onSuccess {
|
||||
methods = it
|
||||
// 只有短信这一条路时,默认切到它,别让人对着密码框发呆
|
||||
if (!it.password && !it.emailPassword && it.smsCode) mode = "code"
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(seconds) {
|
||||
if (seconds > 0) {
|
||||
delay(1000)
|
||||
@@ -93,6 +126,25 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
fun report(e: Throwable, fallback: String) {
|
||||
val c = (e as? ApiException)?.code ?: 0
|
||||
if (c == 1) serverOpen = true
|
||||
error = verifyStatusCode(c).ifBlank { e.message ?: fallback }
|
||||
}
|
||||
|
||||
fun submitPassword() {
|
||||
scope.launch {
|
||||
busy = true
|
||||
runCatching { api.loginPassword(account, password) }
|
||||
.onSuccess {
|
||||
error = null
|
||||
onLoggedIn()
|
||||
}
|
||||
.onFailure { report(it, "登录没成功") }
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
fun send() {
|
||||
scope.launch {
|
||||
busy = true
|
||||
@@ -102,16 +154,12 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
devCode = data?.jsonObject?.get("dev_code")?.jsonPrimitive?.content
|
||||
error = null
|
||||
}
|
||||
.onFailure {
|
||||
val c = (it as? ApiException)?.code ?: 0
|
||||
if (c == 1) serverOpen = true
|
||||
error = verifyStatusCode(c).ifBlank { it.message ?: "验证码没发出去" }
|
||||
}
|
||||
.onFailure { report(it, "验证码没发出去") }
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
fun submitCode() {
|
||||
scope.launch {
|
||||
busy = true
|
||||
runCatching { api.login(phone.trim(), code.trim()) }
|
||||
@@ -119,15 +167,14 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
error = null
|
||||
onLoggedIn()
|
||||
}
|
||||
.onFailure {
|
||||
val c = (it as? ApiException)?.code ?: 0
|
||||
if (c == 1) serverOpen = true
|
||||
error = verifyStatusCode(c).ifBlank { it.message ?: "登录没成功" }
|
||||
}
|
||||
.onFailure { report(it, "登录没成功") }
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
val smsAvailable = methods?.smsCode == true
|
||||
val registerAvailable = methods?.emailRegister == true
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@@ -160,39 +207,45 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
Text("登录", style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
"手机号 + 验证码,不用记密码。第一次登录会自动建好账号。",
|
||||
if (smsAvailable) {
|
||||
"手机号或邮箱 + 密码,也可以收验证码登录。"
|
||||
} else {
|
||||
"手机号或邮箱 + 密码。第一次来先注册一个账号。"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(22.dp))
|
||||
OutlinedTextField(
|
||||
value = phone,
|
||||
onValueChange = { phone = it.filter { c -> c.isDigit() }.take(11) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("手机号") },
|
||||
placeholder = { Text("11 位手机号", color = InkMuted) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
// 两条路都可以走时才给切换 tab,只有一条就别摆出来当摆设
|
||||
if (smsAvailable) {
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SurfaceChip(
|
||||
text = "密码登录",
|
||||
selected = mode == "password",
|
||||
onClick = { mode = "password"; error = null },
|
||||
)
|
||||
SurfaceChip(
|
||||
text = "验证码登录",
|
||||
selected = mode == "code",
|
||||
onClick = { mode = "code"; error = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(Modifier.height(18.dp))
|
||||
if (mode == "password") {
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = { code = it.filter { c -> c.isDigit() }.take(6) },
|
||||
modifier = Modifier.weight(1f),
|
||||
label = { Text("验证码") },
|
||||
value = account,
|
||||
onValueChange = { account = it.take(80) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("手机号或邮箱") },
|
||||
placeholder = { Text("13800138000 或 you@example.com", color = InkMuted) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = if (looksLikeEmail(account)) KeyboardType.Email else KeyboardType.Text,
|
||||
),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
@@ -200,23 +253,76 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
GhostButton(
|
||||
text = if (seconds > 0) "${seconds}s" else "获取验证码",
|
||||
enabled = phone.length == 11 && seconds == 0 && !busy,
|
||||
onClick = { send() },
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it.take(64) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("密码") },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = phone,
|
||||
onValueChange = { phone = it.filter { c -> c.isDigit() }.take(11) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("手机号") },
|
||||
placeholder = { Text("11 位手机号", color = InkMuted) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val hint = devCode
|
||||
if (hint != null) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
SoftPill(
|
||||
"开发环境验证码:$hint",
|
||||
Primary,
|
||||
PrimarySoft,
|
||||
Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = { code = it.filter { c -> c.isDigit() }.take(6) },
|
||||
modifier = Modifier.weight(1f),
|
||||
label = { Text("验证码") },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
GhostButton(
|
||||
text = if (seconds > 0) "${seconds}s" else "获取验证码",
|
||||
enabled = phone.length == 11 && seconds == 0 && !busy,
|
||||
onClick = { send() },
|
||||
)
|
||||
}
|
||||
|
||||
val hint = devCode
|
||||
if (hint != null) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
SoftPill(
|
||||
"开发环境验证码:$hint",
|
||||
Primary,
|
||||
PrimarySoft,
|
||||
Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val notice = error
|
||||
@@ -226,12 +332,36 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(22.dp))
|
||||
PrimaryButton(
|
||||
text = if (busy) "请稍候…" else "登录",
|
||||
enabled = phone.length == 11 && code.length >= 4 && !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = { submit() },
|
||||
)
|
||||
if (mode == "password") {
|
||||
PrimaryButton(
|
||||
text = if (busy) "请稍候…" else "登录",
|
||||
enabled = accountReady(account) && password.isNotEmpty() && !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = { submitPassword() },
|
||||
)
|
||||
} else {
|
||||
PrimaryButton(
|
||||
text = if (busy) "请稍候…" else "登录",
|
||||
enabled = phone.length == 11 && code.length >= 4 && !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = { submitCode() },
|
||||
)
|
||||
}
|
||||
|
||||
if (registerAvailable) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"还没有账号?",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = InkMuted,
|
||||
)
|
||||
TextButton(
|
||||
enabled = !busy,
|
||||
onClick = { error = null; registerOpen = true },
|
||||
) { Text("用邮箱注册") }
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -338,4 +468,190 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
}
|
||||
Spacer(Modifier.height(40.dp))
|
||||
}
|
||||
|
||||
if (registerOpen) {
|
||||
RegisterDialog(
|
||||
api = api,
|
||||
onDismiss = { registerOpen = false },
|
||||
onRegistered = {
|
||||
registerOpen = false
|
||||
onLoggedIn()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱注册:验证码 + 密码,一步建号。
|
||||
*
|
||||
* 注册完服务端直接回令牌,所以这里不再跳回登录页——落地即登录,少一次输入。
|
||||
*/
|
||||
@Composable
|
||||
private fun RegisterDialog(api: Api, onDismiss: () -> Unit, onRegistered: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var email by remember { mutableStateOf("") }
|
||||
var code by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var nickname by remember { mutableStateOf("") }
|
||||
var seconds by remember { mutableStateOf(0) }
|
||||
var devCode by remember { mutableStateOf<String?>(null) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(seconds) {
|
||||
if (seconds > 0) {
|
||||
delay(1000)
|
||||
seconds -= 1
|
||||
}
|
||||
}
|
||||
|
||||
fun fail(e: Throwable, fallback: String) {
|
||||
val c = (e as? ApiException)?.code ?: 0
|
||||
error = verifyStatusCode(c).ifBlank { e.message ?: fallback }
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth(0.94f)
|
||||
.fillMaxHeight(0.9f)
|
||||
.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.height(10.dp))
|
||||
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
|
||||
Text(
|
||||
"用邮箱收一个验证码,设好密码就是账号。邮箱也是以后找回密码的唯一途径,别填错。",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = InkMuted,
|
||||
)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it.take(80) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("邮箱") },
|
||||
placeholder = { Text("you@example.com", color = InkMuted) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = { code = it.filter { c -> c.isDigit() }.take(6) },
|
||||
modifier = Modifier.weight(1f),
|
||||
label = { Text("验证码") },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
GhostButton(
|
||||
text = if (seconds > 0) "${seconds}s" else "发验证码",
|
||||
enabled = email.contains('@') && seconds == 0 && !busy,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
runCatching { api.sendEmailCode(email) }
|
||||
.onSuccess {
|
||||
seconds = 60
|
||||
devCode = it?.jsonObject
|
||||
?.get("dev_code")
|
||||
?.jsonPrimitive
|
||||
?.content
|
||||
error = null
|
||||
}
|
||||
.onFailure { fail(it, "验证码没发出去") }
|
||||
busy = false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
val hint = devCode
|
||||
if (hint != null) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
SoftPill("开发环境验证码:$hint", Primary, PrimarySoft, Modifier.fillMaxWidth())
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it.take(64) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("密码") },
|
||||
placeholder = { Text("至少 8 位,别用生日", color = InkMuted) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = nickname,
|
||||
onValueChange = { nickname = it.take(32) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("称呼(可不填)") },
|
||||
placeholder = { Text("别人在成员列表里看到的名字", color = InkMuted) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
val notice = error
|
||||
if (notice != null) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
SoftPill(notice, Rust, RustSoft, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
PrimaryButton(
|
||||
text = if (busy) "请稍候…" else "注册并登录",
|
||||
enabled = email.contains('@') && code.length >= 4 && password.isNotEmpty() && !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
runCatching { api.register(email, code, password, nickname) }
|
||||
.onSuccess { onRegistered() }
|
||||
.onFailure { fail(it, "注册没成功") }
|
||||
busy = false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
package com.dadao.task.ui
|
||||
|
||||
import android.content.Context
|
||||
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.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import coil.compose.AsyncImage
|
||||
import com.dadao.task.data.Api
|
||||
import com.dadao.task.data.ApiException
|
||||
import com.dadao.task.data.Attachment
|
||||
import io.noties.markwon.Markwon
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** 打开编辑器需要的最小上下文:改的是哪条、改前是什么。 */
|
||||
data class EditRequest(
|
||||
val itemId: String,
|
||||
val title: String,
|
||||
val body: String,
|
||||
val version: Int,
|
||||
val titleEnabled: Boolean = true,
|
||||
val knowledge: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* 富文本编辑器(Markdown 存储)。
|
||||
*
|
||||
* 为什么存 Markdown 而不是 HTML:任务备注、工单说明、知识正文都要能被检索、
|
||||
* 被 LLM 读、被导出;HTML 里混着行内样式,清洗成本远高于收益。
|
||||
* 因此编辑区写 Markdown,预览用 Markwon 渲染,图片传附件后用签名直链插入。
|
||||
*/
|
||||
@Composable
|
||||
fun RichTextEditorScreen(
|
||||
api: Api,
|
||||
workspaceId: String,
|
||||
itemId: String,
|
||||
initialTitle: String,
|
||||
initialBody: String,
|
||||
/** 保存动作由调用方决定:事项走 PATCH /items,知识走 PATCH /knowledge。 */
|
||||
save: suspend (title: String?, body: String) -> Unit,
|
||||
titleEnabled: Boolean = true,
|
||||
onClose: (saved: Boolean) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var title by remember { mutableStateOf(initialTitle) }
|
||||
var field by remember { mutableStateOf(TextFieldValue(initialBody)) }
|
||||
var preview by remember { mutableStateOf(false) }
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var images by remember { mutableStateOf(emptyList<Attachment>()) }
|
||||
|
||||
// 传过的图片:进来先拉一份,预览区与「图片」栏都用它
|
||||
LaunchedEffect(itemId) {
|
||||
runCatching { api.attachments(workspaceId, itemId) }
|
||||
.onSuccess { images = it.filter { row -> row.contentType.startsWith("image/") } }
|
||||
}
|
||||
|
||||
val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
scope.launch {
|
||||
saving = true
|
||||
try {
|
||||
val bytes = withContext(Dispatchers.IO) {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}
|
||||
if (bytes == null) {
|
||||
error = "读不到这张图片,换个来源试试"
|
||||
return@launch
|
||||
}
|
||||
val name = displayName(context, uri) ?: "image-${System.currentTimeMillis()}.png"
|
||||
val mime = context.contentResolver.getType(uri) ?: "image/png"
|
||||
val created = api.uploadAttachment(workspaceId, itemId, name, mime, bytes)
|
||||
images = images + created
|
||||
// 插入用签名直链:它会过期,所以库里存的是干净的相对路径
|
||||
field = insert(field, "})\n")
|
||||
} catch (e: ApiException) {
|
||||
error = e.message
|
||||
} catch (e: Exception) {
|
||||
error = "上传失败:${e.message ?: "未知错误"}"
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Paper)
|
||||
.statusBarsPadding(),
|
||||
) {
|
||||
// 顶部:取消 / 状态 / 预览 / 保存
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
GhostButton("取消", onClick = { onClose(false) })
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
if (preview) "预览" else "编辑",
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Ink,
|
||||
)
|
||||
Text(
|
||||
"${field.text.length} 字 · Markdown",
|
||||
fontSize = 11.sp,
|
||||
color = InkMuted,
|
||||
)
|
||||
}
|
||||
if (saving) {
|
||||
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp, color = Primary)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
PrimaryButton(if (preview) "继续写" else "预览", onClick = { preview = !preview })
|
||||
Spacer(Modifier.width(8.dp))
|
||||
PrimaryButton(
|
||||
"保存",
|
||||
enabled = !saving,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
saving = true
|
||||
error = null
|
||||
try {
|
||||
save(if (titleEnabled) title else null, field.text)
|
||||
onClose(true)
|
||||
} catch (e: ApiException) {
|
||||
error = if (e.code == 4009) "刚被别人改过,请退出重进再改一次" else e.message
|
||||
} catch (e: Exception) {
|
||||
error = e.message ?: "保存失败"
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
HorizontalDivider(color = Line)
|
||||
|
||||
error?.let {
|
||||
Text(
|
||||
it,
|
||||
color = Rust,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(RustSoft)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// 编辑区做成一张纸:标题、工具栏、正文在同一张纸上,边界清楚
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(Color.White)
|
||||
.border(1.dp, Line, RoundedCornerShape(14.dp)),
|
||||
) {
|
||||
if (titleEnabled) {
|
||||
BasicTextField(
|
||||
value = title,
|
||||
onValueChange = { title = it },
|
||||
singleLine = true,
|
||||
textStyle = TextStyle(
|
||||
fontSize = 19.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Ink,
|
||||
),
|
||||
cursorBrush = SolidColor(Primary),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
decorationBox = { inner ->
|
||||
if (title.isBlank()) Text("标题", fontSize = 19.sp, color = InkMuted)
|
||||
inner()
|
||||
},
|
||||
)
|
||||
HorizontalDivider(color = Line)
|
||||
} else if (preview) {
|
||||
Text(
|
||||
title.ifBlank { initialTitle },
|
||||
fontSize = 19.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Ink,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
)
|
||||
HorizontalDivider(color = Line)
|
||||
}
|
||||
|
||||
if (preview) {
|
||||
PreviewBody(field.text, images, Modifier.weight(1f))
|
||||
} else {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ToolButton("B", bold = true) { field = wrap(field, "**") }
|
||||
ToolButton("I", italic = true) { field = wrap(field, "*") }
|
||||
ToolButton("标题") { field = prefixLines(field, "## ") }
|
||||
ToolButton("列表") { field = prefixLines(field, "- ") }
|
||||
ToolButton("引用") { field = prefixLines(field, "> ") }
|
||||
ToolButton("代码") { field = wrap(field, "`") }
|
||||
ToolButton("链接") { field = insert(field, "[文字](https://)") }
|
||||
ToolButton("图片") { picker.launch("image/*") }
|
||||
}
|
||||
HorizontalDivider(color = Line)
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
) {
|
||||
BasicTextField(
|
||||
value = field,
|
||||
onValueChange = { field = it },
|
||||
textStyle = TextStyle(fontSize = 15.sp, color = Ink, lineHeight = 24.sp),
|
||||
cursorBrush = SolidColor(Primary),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding(),
|
||||
decorationBox = { inner ->
|
||||
if (field.text.isBlank()) {
|
||||
Text(
|
||||
"写点什么。「B」「列表」在光标处插语法,图片直接选会自动上传。",
|
||||
fontSize = 15.sp,
|
||||
color = InkMuted,
|
||||
lineHeight = 24.sp,
|
||||
)
|
||||
}
|
||||
inner()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreviewBody(
|
||||
source: String,
|
||||
images: List<Attachment>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val markwon = remember { Markwon.create(context) }
|
||||
val text = remember(source) {
|
||||
// 预览里图片单独排一栏(Markwon 核心版不联网取图),正文里的图片语法换成占位说明
|
||||
source.replace(Regex("!\\[[^\\]]*\\]\\([^)]*\\)"), "〔图片〕")
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
if (images.isNotEmpty()) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
images.forEach { image ->
|
||||
AsyncImage(
|
||||
model = image.rawUrl,
|
||||
contentDescription = image.filename,
|
||||
modifier = Modifier
|
||||
.size(96.dp)
|
||||
.clip(RoundedCornerShape(10.dp)),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
if (text.isBlank()) {
|
||||
Text("还没有内容", fontSize = 14.sp, color = InkMuted)
|
||||
} else {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
android.widget.TextView(ctx).apply {
|
||||
setTextIsSelectable(true)
|
||||
textSize = 15f
|
||||
setPadding(0, 0, 0, 0)
|
||||
setLineSpacing(0f, 1.5f)
|
||||
setTextColor(android.graphics.Color.parseColor("#475569"))
|
||||
markwon.setMarkdown(this, text)
|
||||
}
|
||||
},
|
||||
update = { view -> markwon.setMarkdown(view, text) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 工具条按钮:小、方、无多余留白,横滑容得下。 */
|
||||
@Composable
|
||||
private fun ToolButton(
|
||||
label: String,
|
||||
bold: Boolean = false,
|
||||
italic: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(PaperSunken)
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
fontSize = 13.sp,
|
||||
color = InkSoft,
|
||||
fontWeight = if (bold) FontWeight.Bold else FontWeight.Medium,
|
||||
fontStyle = if (italic) FontStyle.Italic else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 把选区包起来:没选就插一对并落光标在中间,选了就把选中的包进去。 */
|
||||
private fun wrap(field: TextFieldValue, token: String): TextFieldValue {
|
||||
val start = field.selection.start.coerceIn(0, field.text.length)
|
||||
val end = field.selection.end.coerceIn(start, field.text.length)
|
||||
val picked = field.text.substring(start, end)
|
||||
val next = field.text.substring(0, start) + token + picked + token + field.text.substring(end)
|
||||
val caret = if (picked.isEmpty()) start + token.length else start + picked.length + token.length * 2
|
||||
return field.copy(text = next, selection = TextRange(caret))
|
||||
}
|
||||
|
||||
/** 行首插前缀(标题/列表/引用),一次处理选区覆盖的每一行。 */
|
||||
private fun prefixLines(field: TextFieldValue, prefix: String): TextFieldValue {
|
||||
val start = field.selection.start.coerceIn(0, field.text.length)
|
||||
val end = field.selection.end.coerceIn(start, field.text.length)
|
||||
val lineStart = field.text.lastIndexOf('\n', (start - 1).coerceAtLeast(0)).let {
|
||||
if (it < 0 || start == 0) 0 else it + 1
|
||||
}
|
||||
val before = field.text.substring(0, lineStart)
|
||||
val block = field.text.substring(lineStart, end)
|
||||
val after = field.text.substring(end)
|
||||
val nextBlock = block.split('\n').joinToString("\n") { line ->
|
||||
if (line.startsWith(prefix)) line else prefix + line
|
||||
}
|
||||
val next = before + nextBlock + after
|
||||
return field.copy(text = next, selection = TextRange(lineStart + nextBlock.length))
|
||||
}
|
||||
|
||||
private fun insert(field: TextFieldValue, snippet: String): TextFieldValue {
|
||||
val start = field.selection.start.coerceIn(0, field.text.length)
|
||||
val end = field.selection.end.coerceIn(start, field.text.length)
|
||||
val next = field.text.substring(0, start) + snippet + field.text.substring(end)
|
||||
return field.copy(text = next, selection = TextRange(start + snippet.length))
|
||||
}
|
||||
|
||||
private fun displayName(context: 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()
|
||||
@@ -66,6 +66,10 @@ fun TaskDetailSheet(
|
||||
itemId: String,
|
||||
onClose: () -> Unit,
|
||||
onChanged: () -> Unit,
|
||||
/** 进富文本编辑器改正文。标题与状态留在抽屉里,只有正文要更大地方。 */
|
||||
onEdit: (ItemDetail) -> Unit = {},
|
||||
/** 编辑器保存回来后变一次,抽屉据此重新拉数据——否则还显示保存前的旧正文。 */
|
||||
refreshKey: Int = 0,
|
||||
) {
|
||||
val sheet = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -91,7 +95,7 @@ fun TaskDetailSheet(
|
||||
runCatching { api.me() }.onSuccess { meId = it.user.id }
|
||||
}
|
||||
|
||||
LaunchedEffect(itemId) { reload() }
|
||||
LaunchedEffect(itemId, refreshKey) { reload() }
|
||||
|
||||
fun fail(e: Throwable) {
|
||||
val code = (e as? ApiException)?.code ?: 0
|
||||
@@ -125,6 +129,8 @@ fun TaskDetailSheet(
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
if (d.kind == "ticket") SoftPill("工单", Clay, ClaySoft)
|
||||
Spacer(Modifier.weight(1f))
|
||||
GhostButton("编辑正文", onClick = { onEdit(d) })
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
|
||||
@@ -42,16 +42,24 @@ import com.dadao.task.data.Item
|
||||
import com.dadao.task.data.Workspace
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// 「全部」放第一个,与 Web 任务页保持一致;默认选中的视图在下面单独定,不靠顺序。
|
||||
private val views = listOf(
|
||||
"all" to "全部",
|
||||
"today" to "今天",
|
||||
"active" to "进行中",
|
||||
"inbox" to "收件箱",
|
||||
"done" to "已完成",
|
||||
"all" to "全部",
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TaskListScreen(api: Api, workspace: Workspace) {
|
||||
fun TaskListScreen(
|
||||
api: Api,
|
||||
workspace: Workspace,
|
||||
onEdit: (EditRequest) -> Unit = {},
|
||||
/** 编辑器保存后要重新打开的那条。父层持这个状态,因为编辑器打开时本页会被整体卸载。 */
|
||||
reopenItem: String? = null,
|
||||
onReopened: () -> Unit = {},
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var view by remember { mutableStateOf("active") }
|
||||
var mode by remember { mutableStateOf("list") }
|
||||
@@ -60,6 +68,7 @@ fun TaskListScreen(api: Api, workspace: Workspace) {
|
||||
var draft by remember { mutableStateOf("") }
|
||||
var query by remember { mutableStateOf("") }
|
||||
var openId by remember { mutableStateOf<String?>(null) }
|
||||
var detailRefresh by remember { mutableStateOf(0) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
suspend fun reload() {
|
||||
@@ -75,6 +84,15 @@ fun TaskListScreen(api: Api, workspace: Workspace) {
|
||||
|
||||
LaunchedEffect(view, query) { reload() }
|
||||
|
||||
// 从编辑器保存回来:本页是新建出来的,先刷数据再把那一条详情重新打开。
|
||||
LaunchedEffect(reopenItem) {
|
||||
val target = reopenItem ?: return@LaunchedEffect
|
||||
reload()
|
||||
detailRefresh += 1
|
||||
openId = target
|
||||
onReopened()
|
||||
}
|
||||
|
||||
fun fail(e: Throwable) {
|
||||
val code = (e as? ApiException)?.code ?: 0
|
||||
error = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" }
|
||||
@@ -193,6 +211,12 @@ fun TaskListScreen(api: Api, workspace: Workspace) {
|
||||
itemId = id,
|
||||
onClose = { openId = null },
|
||||
onChanged = { scope.launch { reload() } },
|
||||
// 抽屉是独立窗口,关掉才能让编辑器显示在上层;保存回来后由父层重新打开。
|
||||
onEdit = {
|
||||
openId = null
|
||||
onEdit(EditRequest(it.id, it.title, it.bodyMd, it.version))
|
||||
},
|
||||
refreshKey = detailRefresh,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -276,7 +300,7 @@ private fun TaskRow(item: Item, onOpen: () -> Unit, onToggle: () -> Unit) {
|
||||
Text(
|
||||
item.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (item.isDone) InkMuted else Ink,
|
||||
textDecoration = if (item.isDone) TextDecoration.LineThrough else TextDecoration.None,
|
||||
)
|
||||
|
||||
@@ -13,22 +13,22 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* 与 Web 端 tailwind.config.js 同一套色板:暖白纸面 + 墨松绿主色 + 陶土橙强调。
|
||||
* 与 Web 端 tailwind.config.js 同一套色板:冷白纸面 + 靛蓝主色(#4F46E5)+ 陶土橙强调。
|
||||
* 两端看同一张截图时,颜色应该能对上,所以这里的值不要单独调。
|
||||
*/
|
||||
val Paper = Color(0xFFFBF9F6)
|
||||
val PaperSunken = Color(0xFFF3F0EA)
|
||||
val Line = Color(0xFFE4E0D8)
|
||||
val LineStrong = Color(0xFFD3CEC2)
|
||||
val Paper = Color(0xFFFAFAFC)
|
||||
val PaperSunken = Color(0xFFF4F5F9)
|
||||
val Line = Color(0xFFE3E7EF)
|
||||
val LineStrong = Color(0xFFD5DAE4)
|
||||
|
||||
val Ink = Color(0xFF1F2A28)
|
||||
val InkSoft = Color(0xFF4A5854)
|
||||
val InkMuted = Color(0xFF7C8985)
|
||||
val Ink = Color(0xFF1E293B)
|
||||
val InkSoft = Color(0xFF475569)
|
||||
val InkMuted = Color(0xFF64748B)
|
||||
|
||||
val Primary = Color(0xFF2A5A50)
|
||||
val PrimaryDeep = Color(0xFF1E443C)
|
||||
val PrimarySoft = Color(0xFFE3EDE8)
|
||||
val PrimaryTint = Color(0xFFD2E3DB)
|
||||
val Primary = Color(0xFF4F46E5)
|
||||
val PrimaryDeep = Color(0xFF4338CA)
|
||||
val PrimarySoft = Color(0xFFE0E7FF)
|
||||
val PrimaryTint = Color(0xFFC7D2FE)
|
||||
|
||||
val Clay = Color(0xFFC2703D)
|
||||
val ClaySoft = Color(0xFFFBEADF)
|
||||
@@ -39,8 +39,8 @@ val Wheat = Color(0xFFA97C25)
|
||||
val WheatSoft = Color(0xFFF8EED7)
|
||||
val Rust = Color(0xFFB04A32)
|
||||
val RustSoft = Color(0xFFFBE5DF)
|
||||
val Sky = Color(0xFF3E6F91)
|
||||
val SkySoft = Color(0xFFE4EEF5)
|
||||
val Sky = Color(0xFF16697C)
|
||||
val SkySoft = Color(0xFFE6F4F6)
|
||||
|
||||
private val colors = lightColorScheme(
|
||||
primary = Primary,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.dadao.task.ui
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -31,6 +33,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.dadao.task.data.Api
|
||||
import com.dadao.task.data.ApiException
|
||||
import com.dadao.task.data.Member
|
||||
import com.dadao.task.data.Ticket
|
||||
import com.dadao.task.data.Workspace
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -42,7 +45,12 @@ private val ticketRoles = listOf(
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TicketsScreen(api: Api, workspace: Workspace) {
|
||||
fun TicketsScreen(
|
||||
api: Api,
|
||||
workspace: Workspace,
|
||||
focusId: String? = null,
|
||||
onFocusHandled: () -> Unit = {},
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var role by remember { mutableStateOf("assigned") }
|
||||
var tickets by remember { mutableStateOf<List<Ticket>>(emptyList()) }
|
||||
@@ -52,6 +60,16 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
|
||||
var note by remember { mutableStateOf("") }
|
||||
var resolution by remember { mutableStateOf("") }
|
||||
|
||||
// 手工开单:不经过任务,人得自己选
|
||||
var members by remember { mutableStateOf<List<Member>>(emptyList()) }
|
||||
var creating by remember { mutableStateOf(false) }
|
||||
var newTitle by remember { mutableStateOf("") }
|
||||
var newAssignee by remember { mutableStateOf("") }
|
||||
var newPriority by remember { mutableStateOf(1) }
|
||||
|
||||
// 动作按钮按"我是谁"决定显隐:需求人不去接收自己的单,办理人也不该自验
|
||||
var meId by remember { mutableStateOf("") }
|
||||
|
||||
suspend fun reload() {
|
||||
loading = true
|
||||
runCatching { api.tickets(workspace.id, role) }
|
||||
@@ -65,6 +83,18 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
|
||||
|
||||
LaunchedEffect(role) { reload() }
|
||||
|
||||
// 从通知点进来:切到"全部"才看得见那张单,然后清掉待处理标记
|
||||
LaunchedEffect(focusId) {
|
||||
if (focusId == null) return@LaunchedEffect
|
||||
role = "all"
|
||||
onFocusHandled()
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching { api.members(workspace.id) }.onSuccess { members = it }
|
||||
runCatching { api.me() }.onSuccess { meId = it.user.id }
|
||||
}
|
||||
|
||||
fun fail(e: Throwable) {
|
||||
val code = (e as? ApiException)?.code ?: 0
|
||||
message = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" }
|
||||
@@ -95,10 +125,20 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
ticketRoles.forEach { (key, label) ->
|
||||
SurfaceChip(text = label, selected = role == key, onClick = { role = key })
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
GhostButton("新建", onClick = {
|
||||
newTitle = ""
|
||||
newAssignee = ""
|
||||
newPriority = 1
|
||||
creating = true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,7 +164,7 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
|
||||
Text(
|
||||
ticket.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(
|
||||
@@ -140,21 +180,23 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
val isAssignee = ticket.ticket.assigneeId == meId
|
||||
val isRequester = ticket.ticket.requesterId == meId
|
||||
val isAdmin = workspace.role == "owner" || workspace.role == "admin"
|
||||
val canWork = isAssignee || isAdmin
|
||||
val canVerify = isRequester || isAdmin
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
when (ticket.status) {
|
||||
"open" -> PrimaryButton("接收", onClick = { act(ticket, "accept") })
|
||||
"accepted" -> PrimaryButton("开始办理", onClick = { act(ticket, "start") })
|
||||
"in_progress" -> PrimaryButton("提交验收", onClick = {
|
||||
scope.launch {
|
||||
runCatching { api.ticketAction(workspace.id, ticket.id, "submit") }
|
||||
.onSuccess {
|
||||
message = null
|
||||
reload()
|
||||
}
|
||||
.onFailure { fail(it) }
|
||||
}
|
||||
})
|
||||
"pending_verify" -> {
|
||||
"open" -> if (canWork) {
|
||||
PrimaryButton("接收", onClick = { act(ticket, "accept") })
|
||||
}
|
||||
"accepted" -> if (canWork) {
|
||||
PrimaryButton("开始办理", onClick = { act(ticket, "start") })
|
||||
}
|
||||
"in_progress" -> if (canWork) {
|
||||
PrimaryButton("提交验收", onClick = { act(ticket, "submit") })
|
||||
}
|
||||
"pending_verify" -> if (canVerify) {
|
||||
PrimaryButton("通过验收", onClick = {
|
||||
verifyTarget = ticket
|
||||
note = ""
|
||||
@@ -174,6 +216,17 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
|
||||
})
|
||||
}
|
||||
}
|
||||
// 谁都不能替别人取消,但需求人/办理人/管理员可以中止
|
||||
if (ticket.status != "done" && ticket.status != "canceled" && (isRequester || isAssignee || isAdmin)) {
|
||||
GhostButton("取消", onClick = { act(ticket, "cancel") })
|
||||
}
|
||||
if (!canWork && !canVerify && ticket.status != "done" && ticket.status != "canceled") {
|
||||
Text(
|
||||
if (isRequester) "等对方处理" else "这条不是派给你的",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,6 +234,89 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
|
||||
item { Spacer(Modifier.height(20.dp)) }
|
||||
}
|
||||
|
||||
if (creating) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { creating = false },
|
||||
title = { Text("新建工单", style = MaterialTheme.typography.titleMedium) },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"不经过任务也能开单。写清楚要办什么,指定一个人,对方会收到通知。",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted,
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = newTitle,
|
||||
onValueChange = { newTitle = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("要办什么,例如:核对 9 月发票", color = InkMuted) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text("交给谁", style = MaterialTheme.typography.labelMedium, color = InkMuted)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
) {
|
||||
members.forEach { m ->
|
||||
SurfaceChip(
|
||||
text = m.nickname.ifBlank { m.phone.takeLast(4) },
|
||||
selected = newAssignee == m.userId,
|
||||
onClick = { newAssignee = m.userId },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text("优先级", style = MaterialTheme.typography.labelMedium, color = InkMuted)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
listOf(0 to "无", 1 to "低", 2 to "中", 3 to "高").forEach { (v, label) ->
|
||||
SurfaceChip(
|
||||
text = label,
|
||||
selected = newPriority == v,
|
||||
onClick = { newPriority = v },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = newTitle.isNotBlank() && newAssignee.isNotBlank(),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
api.createTicket(
|
||||
workspace.id,
|
||||
newTitle.trim(),
|
||||
newAssignee,
|
||||
priority = newPriority,
|
||||
)
|
||||
}
|
||||
.onSuccess {
|
||||
creating = false
|
||||
message = null
|
||||
role = "requested"
|
||||
}
|
||||
.onFailure { fail(it) }
|
||||
}
|
||||
},
|
||||
) { Text("开单并通知对方") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { creating = false }) { Text("取消") } },
|
||||
containerColor = Paper,
|
||||
)
|
||||
}
|
||||
|
||||
val target = verifyTarget
|
||||
if (target != null) {
|
||||
AlertDialog(
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.dadao.task.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
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.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.dadao.task.data.AppRelease
|
||||
import com.dadao.task.data.AppUpdater
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
/** 更新对话框当前停在哪一步。 */
|
||||
private enum class UpdatePhase { Offer, Downloading, Downloaded, Denied, Failed }
|
||||
|
||||
/**
|
||||
* 应用内升级的对话窗口。
|
||||
*
|
||||
* 只有一条主按钮,因为这一步没得选:点了就下载、下完就交给系统安装器。
|
||||
* 「允许安装未知应用」是系统开关,界面只能把用户送到设置页,不能替他点。
|
||||
*/
|
||||
@Composable
|
||||
fun UpdateDialog(
|
||||
release: AppRelease,
|
||||
currentVersionName: String,
|
||||
updater: AppUpdater,
|
||||
onLater: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var phase by remember { mutableStateOf(UpdatePhase.Offer) }
|
||||
var progress by remember { mutableStateOf(0f) }
|
||||
var detail by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var file by remember { mutableStateOf<File?>(null) }
|
||||
|
||||
fun handToInstaller(target: File) {
|
||||
if (updater.install(target)) onLater() else phase = UpdatePhase.Denied
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
// 下载过程中不给关:半截的包留在磁盘上比重新下一个更麻烦
|
||||
onDismissRequest = { if (phase != UpdatePhase.Downloading) onLater() },
|
||||
title = { Text("有新版 ${release.versionName}", style = MaterialTheme.typography.titleMedium) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
"当前 ${currentVersionName} · 新包 ${formatSize(release.size)}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = InkMuted,
|
||||
)
|
||||
if (release.notes.isNotBlank()) {
|
||||
Text(release.notes, style = MaterialTheme.typography.bodySmall, color = InkSoft)
|
||||
}
|
||||
when (phase) {
|
||||
UpdatePhase.Downloading -> {
|
||||
LinearProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
"下载中 ${(progress * 100).toInt()}% $detail",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = InkMuted,
|
||||
)
|
||||
}
|
||||
|
||||
UpdatePhase.Denied -> Text(
|
||||
"安装包已经下好了,但系统还关着「允许安装未知应用」。去打开这个开关再回来点安装。",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Clay,
|
||||
)
|
||||
|
||||
UpdatePhase.Failed -> Text(
|
||||
error ?: "更新失败了,稍后再试",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Clay,
|
||||
)
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
when (phase) {
|
||||
UpdatePhase.Denied -> PrimaryButton(
|
||||
text = "去开启安装权限",
|
||||
onClick = {
|
||||
// 用户可能是刚从系统设置回来:权限开了就直接装,没开才把人送过去
|
||||
val downloaded = file
|
||||
if (downloaded != null && updater.canInstall()) {
|
||||
handToInstaller(downloaded)
|
||||
} else {
|
||||
updater.openInstallPermission()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
UpdatePhase.Downloaded -> PrimaryButton(
|
||||
text = "安装",
|
||||
onClick = { file?.let { handToInstaller(it) } },
|
||||
)
|
||||
|
||||
UpdatePhase.Downloading -> PrimaryButton("下载中…", onClick = {}, enabled = false)
|
||||
|
||||
else -> PrimaryButton(
|
||||
text = if (phase == UpdatePhase.Failed) "重新下载" else "下载并安装",
|
||||
onClick = {
|
||||
scope.launch {
|
||||
phase = UpdatePhase.Downloading
|
||||
progress = 0f
|
||||
runCatching {
|
||||
val downloaded = updater.download(release) { done, total ->
|
||||
progress = if (total > 0) (done.toFloat() / total).coerceIn(0f, 1f) else 0f
|
||||
detail = "${formatSize(done)} / ${formatSize(total)}"
|
||||
}
|
||||
file = downloaded
|
||||
phase = UpdatePhase.Downloaded
|
||||
handToInstaller(downloaded)
|
||||
}.onFailure {
|
||||
error = it.message ?: "更新失败了,稍后再试"
|
||||
phase = UpdatePhase.Failed
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { if (phase != UpdatePhase.Downloading) onLater() }) {
|
||||
Text("以后再说", color = InkMuted)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** 把字节数说成人话:文件小就 KB,大就 MB。 */
|
||||
private fun formatSize(bytes: Long): String {
|
||||
if (bytes <= 0) return "未知大小"
|
||||
if (bytes < 1024 * 1024) return "${(bytes / 1024)} KB"
|
||||
return String.format(Locale.US, "%.1f MB", bytes / 1024.0 / 1024.0)
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -96,27 +97,46 @@ fun SoftPill(text: String, fg: Color = InkSoft, bg: Color = PaperSunken, modifie
|
||||
}
|
||||
}
|
||||
|
||||
/** 卡片:白底、细描边、几乎不可见的投影。整个 App 用它承载所有内容。 */
|
||||
/**
|
||||
* 卡片:白底、细描边、几乎不可见的投影。整个 App 用它承载所有内容。
|
||||
*
|
||||
* [accent] 是卡片顶部的一条 3dp 色带——知识卡片用它显示所属分类的颜色,
|
||||
* 扫一眼列表就能按颜色找回"这堆是哪个分类的"。用 clip 保证色带两端跟着圆角走。
|
||||
*/
|
||||
@Composable
|
||||
fun SoftCard(
|
||||
modifier: Modifier = Modifier,
|
||||
padding: Dp = 14.dp,
|
||||
accent: Color? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
val base = modifier
|
||||
.fillMaxWidth()
|
||||
.border(1.dp, Line, RoundedCornerShape(14.dp))
|
||||
.background(Color.White, RoundedCornerShape(14.dp))
|
||||
.clip(shape)
|
||||
.border(1.dp, Line, shape)
|
||||
.background(Color.White, shape)
|
||||
Surface(
|
||||
modifier = if (onClick != null) base.clickable { onClick() } else base,
|
||||
color = Color.White,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
shape = shape,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(padding), content = content)
|
||||
Column {
|
||||
if (accent != null) {
|
||||
Box(Modifier.fillMaxWidth().height(3.dp).background(accent))
|
||||
}
|
||||
Column(modifier = Modifier.padding(padding), content = content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 分类色的小圆点:侧栏、筛选 chip、卡片 meta 里到处都用它。 */
|
||||
@Composable
|
||||
fun CategoryDot(color: Color, size: Dp = 8.dp, modifier: Modifier = Modifier) {
|
||||
Box(modifier.size(size).background(color, CircleShape))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 自适应图标前景:与 Web Logo 同一套比例(108 画布,安全区 66 居中;靛蓝底上的白线与淡紫点) -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:pathData="M36.3,42.6 L71.7,42.6"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="5.06"
|
||||
android:strokeLineCap="round" />
|
||||
<path
|
||||
android:pathData="M36.3,54 L59.6,54"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="5.06"
|
||||
android:strokeLineCap="round" />
|
||||
<path
|
||||
android:pathData="M36.3,65.4 L50.8,65.4"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="5.06"
|
||||
android:strokeLineCap="round" />
|
||||
<path
|
||||
android:fillColor="#C7D2FE"
|
||||
android:pathData="M71.7,65.4 m-7.6,0 a7.6,7.6 0 1,0 15.2,0 a7.6,7.6 0 1,0 -15.2,0" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 与 Web 端 Logo(web/src/components/icons.tsx 的 <Logo/>)同一个靛蓝 -->
|
||||
<color name="ic_launcher_background">#4F46E5</color>
|
||||
</resources>
|
||||
@@ -2,7 +2,7 @@
|
||||
<resources>
|
||||
<!-- Compose 自己管配色,这里只提供无 ActionBar 的容器主题 -->
|
||||
<style name="Theme.DadaoTask" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:statusBarColor">#3F5A68</item>
|
||||
<item name="android:windowBackground">#F7F8F8</item>
|
||||
<item name="android:statusBarColor">#4F46E5</item>
|
||||
<item name="android:windowBackground">#FAFAFC</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- FileProvider 的可见范围:只暴露应用自己的 files/updates,安装包就在那儿 -->
|
||||
<paths>
|
||||
<external-files-path name="updates" path="updates/" />
|
||||
<files-path name="internal_updates" path="updates/" />
|
||||
</paths>
|
||||
@@ -1,11 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
只对本机开发地址放行明文 HTTP。生产域名必须走 HTTPS,不放行任何 cleartext。
|
||||
只给内网/本机地址放行明文 HTTP:10.0.2.2 是模拟器里的宿主机,
|
||||
192.168.8.188 是当前的内网生产入口(还没上 HTTPS)。
|
||||
正式域名必须走 HTTPS——上线时把 192.168.8.188 这条删掉。
|
||||
-->
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">10.0.2.2</domain>
|
||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||
<domain includeSubdomains="false">localhost</domain>
|
||||
<domain includeSubdomains="false">192.168.8.188</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
|
||||
@@ -24,14 +24,20 @@ JWT_SECRET=dev-only-change-me
|
||||
ACCESS_TOKEN_MINUTES=30
|
||||
REFRESH_TOKEN_DAYS=14
|
||||
|
||||
# 生产环境禁止 SMS_PROVIDER=dev(否则任意验证码可登录),启动即失败
|
||||
# 生产环境禁止 SMS_PROVIDER=dev(否则任意验证码可登录),启动即失败;
|
||||
# 内网试用确需固定验证码时显式设置 INTERNAL_TEST_LOGIN=true(启动会打 WARNING)
|
||||
SMS_PROVIDER=dev
|
||||
SMS_DEV_CODE=123456
|
||||
INTERNAL_TEST_LOGIN=false
|
||||
# 同一手机号两次发码的最小间隔(秒),测试里置 0 可关闭
|
||||
SMS_THROTTLE_SECONDS=60
|
||||
|
||||
CORS_ORIGINS=http://localhost:5180,http://127.0.0.1:5180
|
||||
|
||||
# 生产单端口部署:后端直接托管前端构建产物(同源,可留空 CORS)
|
||||
# 留空则只提供 API;部署方式见 2.Design/DEPLOY.md
|
||||
# WEB_DIST_DIR=/home/openclaw/app/dadaotask/web/dist
|
||||
|
||||
# ── 知识库向量库(独立的 PG + pgvector,M3 起用)──────────────────────
|
||||
# 单独实例,不动 fnsvr-01:那台没有 pgvector,且向量检索吃内存,不该和业务库挤。
|
||||
# 起法见 deploy/docker-compose.vector.yml。不配则 M3 的语义检索能力直接不可用。
|
||||
|
||||
@@ -12,13 +12,13 @@ import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from app.roles import APP_ROLE, OWNER_ROLE
|
||||
|
||||
revision: str = "0001"
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
APP_ROLE = "dadao_app"
|
||||
|
||||
# 说明:asyncpg 走"预备语句"执行,**一条 execute 里只能有一条 SQL**,
|
||||
# 多条语句会报 `cannot insert multiple commands into a prepared statement`。
|
||||
# 所以这里按语句拆成元组,逐条执行。
|
||||
@@ -76,7 +76,7 @@ POLICY_STATEMENTS: tuple[str, ...] = (
|
||||
f"workspace, app_user, member, invitation, audit_log TO {APP_ROLE};"
|
||||
),
|
||||
(
|
||||
"ALTER DEFAULT PRIVILEGES FOR ROLE dadao_owner IN SCHEMA public "
|
||||
f"ALTER DEFAULT PRIVILEGES FOR ROLE {OWNER_ROLE} IN SCHEMA public "
|
||||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO {APP_ROLE};"
|
||||
),
|
||||
)
|
||||
|
||||
@@ -15,13 +15,13 @@ import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision: str = "0002"
|
||||
down_revision: str | None = "0001"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
APP_ROLE = "dadao_app"
|
||||
|
||||
TENANT_TABLES_0002: tuple[str, ...] = ("item", "tag", "item_tag", "comment", "activity")
|
||||
|
||||
_TIMESTAMP_COLUMNS = (
|
||||
|
||||
@@ -19,13 +19,13 @@ import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision: str = "0003"
|
||||
down_revision: str | None = "0002"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
APP_ROLE = "dadao_app"
|
||||
|
||||
TENANT_TABLES_0003: tuple[str, ...] = ("ticket_ext", "item_link", "notification")
|
||||
|
||||
TASK_STATUSES = ("inbox", "todo", "doing", "done", "blocked", "canceled")
|
||||
|
||||
@@ -21,13 +21,13 @@ import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision: str = "0004"
|
||||
down_revision: str | None = "0003"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
APP_ROLE = "dadao_app"
|
||||
|
||||
TENANT_TABLES_0004: tuple[str, ...] = ("knowledge_doc",)
|
||||
|
||||
TASK_STATUSES = ("inbox", "todo", "doing", "done", "blocked", "canceled")
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""附件表(任务 / 工单 / 知识共用)
|
||||
|
||||
Revision ID: 0005
|
||||
Revises: 0004
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID as PgUUID
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision = "0005"
|
||||
down_revision = "0004"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"attachment",
|
||||
sa.Column("id", PgUUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"workspace_id",
|
||||
PgUUID(as_uuid=True),
|
||||
sa.ForeignKey("workspace.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"item_id",
|
||||
PgUUID(as_uuid=True),
|
||||
sa.ForeignKey("item.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"uploader_id",
|
||||
PgUUID(as_uuid=True),
|
||||
sa.ForeignKey("app_user.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("filename", sa.String(255), nullable=False),
|
||||
sa.Column("content_type", sa.String(120), nullable=False),
|
||||
sa.Column("size_bytes", sa.BigInteger(), nullable=False),
|
||||
sa.Column("sha256", sa.String(64), nullable=False),
|
||||
sa.Column("storage_key", sa.String(255), nullable=False, unique=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.CheckConstraint("size_bytes >= 0", name="ck_attachment_size"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_attachment_ws_item", "attachment", ["workspace_id", "item_id", "created_at"]
|
||||
)
|
||||
|
||||
for statement in (
|
||||
"ALTER TABLE attachment ENABLE ROW LEVEL SECURITY",
|
||||
"CREATE POLICY attachment_isolation ON attachment "
|
||||
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
|
||||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON attachment TO {APP_ROLE}",
|
||||
):
|
||||
op.execute(statement)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS attachment_isolation ON attachment")
|
||||
op.drop_index("ix_attachment_ws_item", table_name="attachment")
|
||||
op.drop_table("attachment")
|
||||
@@ -0,0 +1,104 @@
|
||||
"""知识切片与检索(M3)
|
||||
|
||||
Revision ID: 0006
|
||||
Revises: 0005
|
||||
Create Date: 2026-10-29
|
||||
|
||||
建 `chunk` 表:知识正文按段切开,每片一行,带向量数组。
|
||||
|
||||
两个刻意的取舍:
|
||||
|
||||
1. **向量存 `real[]`,不依赖 pgvector**。本机拿不到带 pgvector 的实例(Docker 起不来,
|
||||
fnsvr-01 明确不动),但检索链路不能一直停在纸面上。`real[]` + 一条 SQL 余弦函数
|
||||
在小数据量下够用;将来换上 pgvector 只需改列类型、加 HNSW 索引,应用层接口不变。
|
||||
2. **检索范围只能是 `published`**。草稿是"还没被人认可的话",进检索等于让 AI 把
|
||||
猜测当结论引用出去——这条过滤写在查询里,不靠调用方自觉。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision: str = "0006"
|
||||
down_revision: str = "0005"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
# 余弦相似度。写成 SQL 函数而不是在 Python 里算,是为了让过滤与排序留在数据库里做——
|
||||
# 把整表向量捞回应用算相似度,数据一多就会拖垮 API。
|
||||
COSINE_SQL = """
|
||||
CREATE OR REPLACE FUNCTION app_cosine(a real[], b real[]) RETURNS double precision
|
||||
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE AS $fn$
|
||||
DECLARE
|
||||
n int;
|
||||
dot double precision := 0;
|
||||
na double precision := 0;
|
||||
nb double precision := 0;
|
||||
BEGIN
|
||||
n := LEAST(array_length(a, 1), array_length(b, 1));
|
||||
IF n IS NULL THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
FOR i IN 1..n LOOP
|
||||
dot := dot + a[i] * b[i];
|
||||
na := na + a[i] * a[i];
|
||||
nb := nb + b[i] * b[i];
|
||||
END LOOP;
|
||||
IF na = 0 OR nb = 0 THEN
|
||||
RETURN 0;
|
||||
END IF;
|
||||
RETURN dot / sqrt(na * nb);
|
||||
END
|
||||
$fn$;
|
||||
"""
|
||||
|
||||
CREATE_SQL = """
|
||||
CREATE TABLE chunk (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL,
|
||||
item_id uuid NOT NULL REFERENCES item(id) ON DELETE CASCADE,
|
||||
idx integer NOT NULL,
|
||||
heading varchar(200),
|
||||
content text NOT NULL,
|
||||
char_count integer NOT NULL DEFAULT 0,
|
||||
embedding real[],
|
||||
embed_model varchar(64),
|
||||
version integer NOT NULL DEFAULT 1,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_chunk_item_idx UNIQUE (item_id, idx)
|
||||
)
|
||||
"""
|
||||
|
||||
# asyncpg 的执行器不接受多语句,一条 `execute` 只能带一句 SQL(0001 踩过同样的坑)
|
||||
INDEX_STATEMENTS = (
|
||||
"CREATE INDEX ix_chunk_ws_item ON chunk (workspace_id, item_id)",
|
||||
"COMMENT ON TABLE chunk IS '知识正文切片,检索的最小单位'",
|
||||
)
|
||||
|
||||
POLICY_SQL = (
|
||||
"ALTER TABLE chunk ENABLE ROW LEVEL SECURITY",
|
||||
"CREATE POLICY chunk_isolation ON chunk "
|
||||
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
|
||||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON chunk TO {APP_ROLE}",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(COSINE_SQL)
|
||||
op.execute(CREATE_SQL)
|
||||
for statement in INDEX_STATEMENTS:
|
||||
op.execute(statement)
|
||||
for statement in POLICY_SQL:
|
||||
op.execute(statement)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS chunk_isolation ON chunk")
|
||||
op.execute("DROP TABLE IF EXISTS chunk")
|
||||
# 函数被别的东西引用时先别删;本轮只有切片在用,留一条注释说明为什么还留着。
|
||||
# op.execute("DROP FUNCTION IF EXISTS app_cosine(real[], real[])")
|
||||
@@ -0,0 +1,81 @@
|
||||
"""知识分类(M3 补充)
|
||||
|
||||
Revision ID: 0007
|
||||
Revises: 0006
|
||||
Create Date: 2026-10-30
|
||||
|
||||
知识库的基本形态是"备忘录",不是"任务":一条知识要有标题、正文、标签,
|
||||
还要能归类。这里补上分类树(一级大类 / 二级 / 三级,深不过三)。
|
||||
|
||||
两个取舍:
|
||||
|
||||
1. **分类独立成表,不塞进 item**。分类是工作区级的字典,一条知识挂一个分类;
|
||||
塞进 item 会让"分类"变成每条知识自己的一份副本,改个名字要改几百行。
|
||||
2. **删分类不删知识**。外键是 `ON DELETE SET NULL`:知识退回"未分类",
|
||||
分类只是入口,不是知识的容器。真要连内容一起删,那是删知识,得单独确认。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision: str = "0007"
|
||||
down_revision: str = "0006"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
CREATE_SQL = """
|
||||
CREATE TABLE knowledge_category (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
parent_id uuid REFERENCES knowledge_category(id) ON DELETE CASCADE,
|
||||
name varchar(40) NOT NULL,
|
||||
depth integer NOT NULL DEFAULT 1,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
version integer NOT NULL DEFAULT 1,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT ck_kcat_depth CHECK (depth BETWEEN 1 AND 3),
|
||||
CONSTRAINT ck_kcat_name CHECK (length(btrim(name)) > 0)
|
||||
)
|
||||
"""
|
||||
|
||||
INDEX_STATEMENTS = (
|
||||
"CREATE INDEX ix_kcat_ws_parent ON knowledge_category (workspace_id, parent_id)",
|
||||
# 同一父级下不许重名。根分类的 parent_id 是 NULL,而 PG 里 NULL 互不相等,
|
||||
# 所以根要单独一条 `WHERE parent_id IS NULL` 的部分索引,否则可以建出两个同名大类。
|
||||
"CREATE UNIQUE INDEX uq_kcat_ws_parent_name ON knowledge_category "
|
||||
"(workspace_id, parent_id, lower(name)) WHERE parent_id IS NOT NULL",
|
||||
"CREATE UNIQUE INDEX uq_kcat_ws_root_name ON knowledge_category "
|
||||
"(workspace_id, lower(name)) WHERE parent_id IS NULL",
|
||||
"COMMENT ON TABLE knowledge_category IS '知识分类树,深不过三'",
|
||||
"ALTER TABLE knowledge_doc ADD COLUMN category_id uuid "
|
||||
"REFERENCES knowledge_category(id) ON DELETE SET NULL",
|
||||
"CREATE INDEX ix_knowledge_ws_category ON knowledge_doc (workspace_id, category_id)",
|
||||
)
|
||||
|
||||
POLICY_SQL = (
|
||||
"ALTER TABLE knowledge_category ENABLE ROW LEVEL SECURITY",
|
||||
"CREATE POLICY knowledge_category_isolation ON knowledge_category "
|
||||
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
|
||||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON knowledge_category TO {APP_ROLE}",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(CREATE_SQL)
|
||||
for statement in INDEX_STATEMENTS:
|
||||
op.execute(statement)
|
||||
for statement in POLICY_SQL:
|
||||
op.execute(statement)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_knowledge_ws_category")
|
||||
op.execute("ALTER TABLE knowledge_doc DROP COLUMN IF EXISTS category_id")
|
||||
op.execute("DROP POLICY IF EXISTS knowledge_category_isolation ON knowledge_category")
|
||||
op.execute("DROP TABLE IF EXISTS knowledge_category")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""登录密码(M1 账号功能)
|
||||
|
||||
Revision ID: 0008
|
||||
Revises: 0007
|
||||
Create Date: 2026-10-31
|
||||
|
||||
M0 只有手机号 + 验证码登录。验证码登录在换手机、短信通道欠费、
|
||||
或者只想快一点的时候都不够用,所以给账号加一条**可选**的密码登录路径。
|
||||
|
||||
两个取舍:
|
||||
|
||||
1. **密码可空**。老用户不去设置就一直走验证码,不会被强制改流程;
|
||||
登录页两种方式并存,谁设过谁自己知道。
|
||||
2. **只存 scrypt 串**(见 `app/security.py`),不存明文也不存 md5。
|
||||
密码本身永远不回给客户端,接口只返回 `has_password` 这个布尔。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0008"
|
||||
down_revision: str = "0007"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE app_user
|
||||
ADD COLUMN IF NOT EXISTS password_hash VARCHAR(255)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE app_user DROP COLUMN IF EXISTS password_hash")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""知识分类配色(M3 补充)
|
||||
|
||||
Revision ID: 0009
|
||||
Revises: 0008
|
||||
Create Date: 2026-11-02
|
||||
|
||||
分类从"只能分级"变成"还能上色"。一列而已,但解决了卡片墙上的一个真问题:
|
||||
三十条知识平铺在一起,光看标题要逐条读才知道哪条属于部署、哪条属于业务。
|
||||
分类色让"归属"在扫视时就成立,不用读字。
|
||||
|
||||
三个取舍:
|
||||
|
||||
1. **颜色存分类上,不存知识上**。同一类的颜色必须一致,否则卡片墙会花成
|
||||
一盘散沙;存在分类上,改一次全类生效。
|
||||
2. **允许为空**。老数据(0007 建的分类)没有颜色,强行补一个默认值等于替用户
|
||||
做决定;为空时前端按分类 id 稳定地挑一个调色板色,看起来仍是"每类一色"。
|
||||
3. **CHECK 约束卡格式**。只接受 `#RRGGBB`,大写。前端传 `rgb(...)` 或带 alpha
|
||||
的值会被挡在数据库外——渲染层不会因为一个脏值悄悄丢样式。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0009"
|
||||
down_revision: str = "0008"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
UPGRADE_SQL = (
|
||||
"ALTER TABLE knowledge_category ADD COLUMN IF NOT EXISTS color varchar(7)",
|
||||
"ALTER TABLE knowledge_category DROP CONSTRAINT IF EXISTS ck_kcat_color",
|
||||
"ALTER TABLE knowledge_category ADD CONSTRAINT ck_kcat_color "
|
||||
"CHECK (color IS NULL OR color ~ '^#[0-9A-F]{6}$')",
|
||||
"COMMENT ON COLUMN knowledge_category.color IS '分类色 #RRGGBB,卡片上边线用它'",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for statement in UPGRADE_SQL:
|
||||
op.execute(statement)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE knowledge_category DROP CONSTRAINT IF EXISTS ck_kcat_color")
|
||||
op.execute("ALTER TABLE knowledge_category DROP COLUMN IF EXISTS color")
|
||||
@@ -0,0 +1,67 @@
|
||||
"""邮箱登录 + 平台设置(M1 账号功能)
|
||||
|
||||
Revision ID: 0010
|
||||
Revises: 0009
|
||||
Create Date: 2026-11-02
|
||||
|
||||
两件事:
|
||||
|
||||
1. `app_user.email`:邮箱当账号名,配合密码登录。唯一性用 `lower(email)` 的部分
|
||||
唯一索引——邮箱大小写不敏感,`Steven@x.com` 和 `steven@x.com` 是同一个人;
|
||||
允许为空(老用户先不绑)。
|
||||
|
||||
2. `system_setting`:平台级键值表,第一项就是 SMTP。**这张表刻意不挂 workspace_id、
|
||||
也不开 RLS**:发信账号是整台服务共用的,属于平台而不是某个租户,开 RLS 反而要
|
||||
编一个"当前租户"出来。写权限在 API 层用平台管理员白名单把关(deps.platform_admin),
|
||||
应用角色只有表的读写权,没有建表/改结构的权。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision: str = "0010"
|
||||
down_revision: str = "0009"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
|
||||
CREATE_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS system_setting (
|
||||
key VARCHAR(64) PRIMARY KEY,
|
||||
value JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_by UUID REFERENCES app_user(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
version INTEGER NOT NULL DEFAULT 1
|
||||
)
|
||||
"""
|
||||
|
||||
INDEX_STATEMENTS = (
|
||||
"ALTER TABLE app_user ADD COLUMN IF NOT EXISTS email VARCHAR(255)",
|
||||
"CREATE UNIQUE INDEX uq_app_user_email ON app_user (lower(email)) WHERE email IS NOT NULL",
|
||||
"COMMENT ON TABLE system_setting IS '平台级设置(SMTP 等),不挂租户、无 RLS'",
|
||||
)
|
||||
|
||||
GRANT_SQL = (
|
||||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON system_setting TO {APP_ROLE}",
|
||||
f"GRANT SELECT, UPDATE ON app_user TO {APP_ROLE}",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(CREATE_SQL)
|
||||
for statement in INDEX_STATEMENTS:
|
||||
op.execute(statement)
|
||||
for statement in GRANT_SQL:
|
||||
op.execute(statement)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS uq_app_user_email")
|
||||
op.execute("ALTER TABLE app_user DROP COLUMN IF EXISTS email")
|
||||
op.execute("DROP TABLE IF EXISTS system_setting")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""app_user 支持邮箱注册:放开 phone 非空、记录邮箱已验证时间
|
||||
|
||||
手机验证码登录关掉以后,新用户是「邮箱 + 验证码」注册进来的,此时还没有手机号;
|
||||
`app_user.phone` 原来 NOT NULL,不放开就没法插这类账号。
|
||||
|
||||
`email_verified_at` 用来区分"填了个邮箱字符串"和"这个地址确实验证过":
|
||||
注册流程发码成功后写入;账号页手工绑邮箱时同一套流程。
|
||||
|
||||
Revision ID: 0011
|
||||
Revises: 0010
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision = "0011"
|
||||
down_revision = "0010"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 邮箱注册的账号没有手机号:老账号都有值,放开非空不影响它们
|
||||
op.execute("ALTER TABLE app_user ALTER COLUMN phone DROP NOT NULL")
|
||||
op.execute("ALTER TABLE app_user ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ")
|
||||
op.execute(
|
||||
"COMMENT ON COLUMN app_user.email_verified_at IS "
|
||||
"'邮箱验证通过的时间;为空表示还没验证过这个地址'"
|
||||
)
|
||||
# 本表全局(不挂租户),只补列不新增表,权限照旧:应用角色要能读写这些列
|
||||
op.execute(f"GRANT SELECT, UPDATE ON app_user TO {APP_ROLE}")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 回滚前先把没有手机号的账号补上占位值,否则加回 NOT NULL 会失败
|
||||
op.execute("UPDATE app_user SET phone = 'redeem-' || replace(id::text, '-', '') WHERE phone IS NULL")
|
||||
op.execute("ALTER TABLE app_user DROP COLUMN IF EXISTS email_verified_at")
|
||||
op.execute("ALTER TABLE app_user ALTER COLUMN phone SET NOT NULL")
|
||||
@@ -0,0 +1,108 @@
|
||||
"""微信(clawbot)接入通道
|
||||
|
||||
Revision ID: 0012
|
||||
Revises: 0011
|
||||
Create Date: 2026-11-05
|
||||
|
||||
两张表:
|
||||
|
||||
- `bot_channel`:一个工作区可以挂多个机器人通道,令牌只存 sha256。
|
||||
- `bot_message`:进来的每条消息留痕,附带它变成了哪条任务/知识。
|
||||
|
||||
`bot_channel` 的 RLS 多放了一条口子:`token_hash = app_bot_token_hash()`。
|
||||
原因很实在——**收到 webhook 时我们还不知道它是哪个工作区的**,而候选令牌
|
||||
必须读过表才知道。没有这条口子,就只能要么关掉 RLS(不行),要么把令牌
|
||||
放一张不挂租户的表里(等于把钥匙串放在门外)。现在的做法是:
|
||||
请求先把 `app.bot_token_hash` 设成本次令牌的哈希,策略只允许命中那一行,
|
||||
其余行照旧看不见。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision: str = "0012"
|
||||
down_revision: str = "0011"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
CREATE_STATEMENTS = (
|
||||
"""
|
||||
CREATE TABLE bot_channel (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
kind varchar(32) NOT NULL DEFAULT 'wechat_clawbot',
|
||||
name varchar(64) NOT NULL DEFAULT '',
|
||||
token_hash varchar(64) NOT NULL,
|
||||
status varchar(16) NOT NULL DEFAULT 'active',
|
||||
created_by uuid REFERENCES app_user(id) ON DELETE SET NULL,
|
||||
last_seen_at timestamptz,
|
||||
last_error text,
|
||||
version integer NOT NULL DEFAULT 1,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE bot_message (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
channel_id uuid NOT NULL REFERENCES bot_channel(id) ON DELETE CASCADE,
|
||||
external_id varchar(128),
|
||||
sender varchar(128),
|
||||
text text NOT NULL DEFAULT '',
|
||||
action varchar(24) NOT NULL DEFAULT 'ignored',
|
||||
item_id uuid REFERENCES item(id) ON DELETE SET NULL,
|
||||
reply text NOT NULL DEFAULT '',
|
||||
raw jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
version integer NOT NULL DEFAULT 1,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
)
|
||||
|
||||
INDEX_STATEMENTS = (
|
||||
"CREATE INDEX ix_bot_channel_ws ON bot_channel (workspace_id)",
|
||||
"CREATE UNIQUE INDEX uq_bot_channel_token ON bot_channel (token_hash)",
|
||||
"CREATE INDEX ix_bot_message_channel ON bot_message (channel_id, created_at)",
|
||||
"CREATE UNIQUE INDEX uq_bot_message_external ON bot_message (channel_id, external_id) "
|
||||
"WHERE external_id IS NOT NULL",
|
||||
)
|
||||
|
||||
POLICY_SQL = (
|
||||
"ALTER TABLE bot_channel ENABLE ROW LEVEL SECURITY",
|
||||
"CREATE POLICY bot_channel_isolation ON bot_channel "
|
||||
"USING (workspace_id = app_ws_id() OR token_hash = current_setting('app.bot_token_hash', true)) "
|
||||
"WITH CHECK (workspace_id = app_ws_id())",
|
||||
"ALTER TABLE bot_message ENABLE ROW LEVEL SECURITY",
|
||||
"CREATE POLICY bot_message_isolation ON bot_message "
|
||||
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
|
||||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON bot_channel TO {APP_ROLE}",
|
||||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON bot_message TO {APP_ROLE}",
|
||||
)
|
||||
|
||||
COMMENT_SQL = (
|
||||
"COMMENT ON TABLE bot_channel IS '外部接入通道(微信 clawbot 等),令牌只存 sha256'",
|
||||
"COMMENT ON TABLE bot_message IS '外部接入收到并处理过的消息,用于支线排查与去重'",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for statement in CREATE_STATEMENTS:
|
||||
op.execute(statement)
|
||||
for statement in INDEX_STATEMENTS:
|
||||
op.execute(statement)
|
||||
for statement in POLICY_SQL:
|
||||
op.execute(statement)
|
||||
for statement in COMMENT_SQL:
|
||||
op.execute(statement)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS bot_message")
|
||||
op.execute("DROP TABLE IF EXISTS bot_channel")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""微信 clawbot 改成"扫码登录 + 长轮询"
|
||||
|
||||
Revision ID: 0013
|
||||
Revises: 0012
|
||||
Create Date: 2026-11-05
|
||||
|
||||
上一版(0012)做成了"clawbot 往我们这 POST webhook"。真实协议是腾讯 iLink Bot:
|
||||
我们先扫码登录拿 `bot_token`,再由我们长轮询 `getupdates` 拉消息(见
|
||||
`app/services/wechat_ilink.py` 文件头的出处说明)。所以这张表要补登录态与游标:
|
||||
|
||||
- `bot_token_enc`:加密后的登录态(密钥由 JWT_SECRET 派生),换 secret 就要重新扫码;
|
||||
- `ilink_user_id`:扫码的微信号,界面上显示"谁扫的";
|
||||
- `base_url`:服务端可能下发新基址;
|
||||
- `sync_buf`:长轮询游标,丢了会重复收消息;
|
||||
- `context_token`:回复必须带对方上一条消息的 context_token。
|
||||
|
||||
`bot_channel.status` 增加 `pending`(等扫码)与 `expired`(登录态失效)两个取值;
|
||||
`token_hash` 是上一版遗留列,改成可空、不再使用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision: str = "0013"
|
||||
down_revision: str = "0012"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
STATEMENTS = (
|
||||
"ALTER TABLE bot_channel ADD COLUMN IF NOT EXISTS bot_token_enc text",
|
||||
"ALTER TABLE bot_channel ADD COLUMN IF NOT EXISTS ilink_user_id varchar(64)",
|
||||
"ALTER TABLE bot_channel ADD COLUMN IF NOT EXISTS base_url text",
|
||||
"ALTER TABLE bot_channel ADD COLUMN IF NOT EXISTS sync_buf text",
|
||||
"ALTER TABLE bot_channel ADD COLUMN IF NOT EXISTS context_token text",
|
||||
"ALTER TABLE bot_channel ALTER COLUMN token_hash DROP NOT NULL",
|
||||
"ALTER TABLE bot_channel ALTER COLUMN status SET DEFAULT 'pending'",
|
||||
"COMMENT ON COLUMN bot_channel.bot_token_enc IS 'iLink 登录态,Fernet 加密(密钥由 JWT_SECRET 派生)'",
|
||||
"COMMENT ON COLUMN bot_channel.sync_buf IS 'getupdates 游标,丢了会重复收消息'",
|
||||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON bot_channel TO {APP_ROLE}",
|
||||
)
|
||||
|
||||
DOWN_STATEMENTS = (
|
||||
"ALTER TABLE bot_channel DROP COLUMN IF EXISTS context_token",
|
||||
"ALTER TABLE bot_channel DROP COLUMN IF EXISTS sync_buf",
|
||||
"ALTER TABLE bot_channel DROP COLUMN IF EXISTS base_url",
|
||||
"ALTER TABLE bot_channel DROP COLUMN IF EXISTS ilink_user_id",
|
||||
"ALTER TABLE bot_channel DROP COLUMN IF EXISTS bot_token_enc",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for statement in STATEMENTS:
|
||||
op.execute(statement)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for statement in DOWN_STATEMENTS:
|
||||
op.execute(statement)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""轮询任务读通道需要一条 RLS 口子
|
||||
|
||||
Revision ID: 0014
|
||||
Revises: 0013
|
||||
Create Date: 2026-11-05
|
||||
|
||||
后台轮询是"从 channel_id 开始"的:它还没有工作区上下文,而 `bot_channel` 的策略要求
|
||||
`workspace_id = app_ws_id()`,直接 SELECT 会一行都看不到(先有鸡还是先有蛋)。
|
||||
|
||||
和 0012 那条 `token_hash = app_bot_token_hash()` 同理,这里再放一条**只按主键命中自己**
|
||||
的口子:任务在读任何东西之前先把 `app.bot_channel` 设成本次要处理的通道 id,
|
||||
策略允许它看见那一行,其余行照旧不可见。读到 workspace_id 之后立刻 `bind_tenant`,
|
||||
后面所有查询都回到正常的租户上下文里。
|
||||
|
||||
两条策略是 permissive 关系(PG 会 OR 起来),所以原来的隔离策略不用改。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision: str = "0014"
|
||||
down_revision: str = "0013"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
CREATE_POLICY = """
|
||||
CREATE POLICY bot_channel_worker ON bot_channel
|
||||
FOR ALL
|
||||
USING (id = current_setting('app.bot_channel', true)::uuid)
|
||||
WITH CHECK (id = current_setting('app.bot_channel', true)::uuid)
|
||||
"""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS bot_channel_worker ON bot_channel")
|
||||
op.execute(CREATE_POLICY)
|
||||
op.execute(f"GRANT SELECT, INSERT, UPDATE, DELETE ON bot_channel TO {APP_ROLE}")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS bot_channel_worker ON bot_channel")
|
||||
@@ -0,0 +1,56 @@
|
||||
"""bot_channel 的轮询口子要容忍"空字符串"的 GUC
|
||||
|
||||
`set_config(..., true)` 提交后会把变量还原成上一次的值;连接池里这条连接如果本来没有
|
||||
会话级值,还原出来的是空串 `''` 而不是 NULL。`0014` 的策略直接
|
||||
`current_setting('app.bot_channel', true)::uuid`,空串一 cast 就
|
||||
`InvalidTextRepresentationError: invalid input syntax for type uuid: ""`,
|
||||
于是同一连接上下一个请求(比如扫码确认)500。
|
||||
|
||||
这里把策略重建成和 `app_ws_id()` 一样的写法:先 `nullif(..., '')` 再 cast。
|
||||
|
||||
Revision ID: 0015
|
||||
Revises: 0014
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision = "0015"
|
||||
down_revision = "0014"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
STATEMENTS: tuple[str, ...] = (
|
||||
"DROP POLICY IF EXISTS bot_channel_worker ON bot_channel",
|
||||
"""
|
||||
CREATE POLICY bot_channel_worker ON bot_channel
|
||||
FOR ALL
|
||||
USING (
|
||||
id = nullif(current_setting('app.bot_channel', true), '')::uuid
|
||||
)
|
||||
WITH CHECK (
|
||||
id = nullif(current_setting('app.bot_channel', true), '')::uuid
|
||||
)
|
||||
""",
|
||||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON bot_channel TO {APP_ROLE}",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for statement in STATEMENTS:
|
||||
op.execute(statement)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS bot_channel_worker ON bot_channel")
|
||||
op.execute(
|
||||
"""
|
||||
CREATE POLICY bot_channel_worker ON bot_channel
|
||||
FOR ALL
|
||||
USING (id = current_setting('app.bot_channel', true)::uuid)
|
||||
WITH CHECK (id = current_setting('app.bot_channel', true)::uuid)
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""启动时列出要接管的通道,也需要一条 RLS 口子
|
||||
|
||||
Revision ID: 0016
|
||||
Revises: 0015
|
||||
Create Date: 2026-11-05
|
||||
|
||||
`BotManager.start_all()` 要在进程启动时问一句"现在有哪些通道已登录、该接着收消息",
|
||||
但 `bot_channel` 的隔离策略要求 `workspace_id = app_ws_id()`,启动那一刻**没有工作区**,
|
||||
结果一行都查不到——表现是"重启之后机器人静默不收消息,页面上还显示已连接"。
|
||||
|
||||
0014 那条口子是"知道 id、只看自己那一行",这里补的是"允许列出还没开始干的活":
|
||||
只有当次事务显式设了 `app.bot_list = 'on'` 时才放行,而且只开 **SELECT**,
|
||||
防止有人顺手拿它改别人的通道。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision: str = "0016"
|
||||
down_revision: str = "0015"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
CREATE_POLICY = """
|
||||
CREATE POLICY bot_channel_startup ON bot_channel
|
||||
FOR SELECT
|
||||
USING (current_setting('app.bot_list', true) = 'on')
|
||||
"""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS bot_channel_startup ON bot_channel")
|
||||
op.execute(CREATE_POLICY)
|
||||
op.execute(f"GRANT SELECT ON bot_channel TO {APP_ROLE}")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS bot_channel_startup ON bot_channel")
|
||||
@@ -0,0 +1,93 @@
|
||||
"""安卓 OTA:最新版本清单 + APK 下载。
|
||||
|
||||
为什么不用一张表:一个安装包就是一条"发布记录",而 APK 是构建产物、不能进仓库。
|
||||
所以发布目录里放「latest.json + 同名 apk」,换版本只换文件,运维不需要动数据库。
|
||||
清单由 `backend/scripts/publish_apk.py` 生成(算好大小与 sha256),本模块只读。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from ..config import get_settings
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..logging_setup import get_logger
|
||||
from ..schemas import ok
|
||||
|
||||
router = APIRouter(prefix="/app", tags=["版本升级"])
|
||||
settings = get_settings()
|
||||
logger = get_logger("app.release")
|
||||
|
||||
MANIFEST_NAME = "latest.json"
|
||||
|
||||
|
||||
def apk_dir() -> Path:
|
||||
"""发布目录:优先用配置里的 APK_DIR,默认在 backend/static/apk(部署时保留、不入库)。"""
|
||||
if settings.apk_dir:
|
||||
return Path(settings.apk_dir)
|
||||
return Path(__file__).resolve().parents[2] / "static" / "apk"
|
||||
|
||||
|
||||
def _manifest() -> dict[str, Any]:
|
||||
path = apk_dir() / MANIFEST_NAME
|
||||
if not path.is_file():
|
||||
raise AppError(ErrorCode.APP_RELEASE_NOT_FOUND, "还没有发布过安装包")
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc: # noqa: BLE001 - 清单坏了也要说人话
|
||||
logger.exception("读取 OTA 清单失败")
|
||||
raise AppError(ErrorCode.APP_RELEASE_NOT_FOUND, "安装包清单损坏,请重新发布") from exc
|
||||
if not isinstance(data, dict) or not data.get("file"):
|
||||
raise AppError(ErrorCode.APP_RELEASE_NOT_FOUND, "安装包清单缺少文件名")
|
||||
return data
|
||||
|
||||
|
||||
def _payload(data: dict[str, Any], base: str, current: int | None) -> dict[str, Any]:
|
||||
version_code = int(data.get("version_code") or 0)
|
||||
return {
|
||||
"platform": data.get("platform", "android"),
|
||||
"version_code": version_code,
|
||||
"version_name": data.get("version_name", ""),
|
||||
"notes": data.get("notes", ""),
|
||||
"file": data["file"],
|
||||
"size": int(data.get("size") or 0),
|
||||
"sha256": data.get("sha256", ""),
|
||||
"min_sdk": int(data.get("min_sdk") or 26),
|
||||
"published_at": data.get("published_at"),
|
||||
# 客户端拿相对路径拼自己的 API 前缀,换域名不用改包
|
||||
"download_url": f"{base}/app/release/download/{data['file']}",
|
||||
# 带了当前版本号就顺手算好要不要升级,客户端不用再比一次
|
||||
"has_update": current is not None and version_code > current,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/release/latest", summary="安卓最新版本")
|
||||
async def latest_release(version_code: int | None = None) -> dict[str, Any]:
|
||||
"""公开接口:装完 App 之前就要能查,不该要登录。
|
||||
|
||||
`version_code` 传客户端当前版本(`BuildConfig.VERSION_CODE`),返回里的
|
||||
`has_update` 直接告诉它要不要提示升级。
|
||||
"""
|
||||
data = _manifest()
|
||||
return ok(_payload(data, settings.api_prefix, version_code))
|
||||
|
||||
|
||||
@router.api_route("/release/download/{name}", methods=["GET", "HEAD"], summary="下载安装包")
|
||||
async def download_release(name: str) -> FileResponse:
|
||||
# 只认清单里那一个文件:不做目录列举,也不接受 ../ 之类
|
||||
data = _manifest()
|
||||
if name != data["file"]:
|
||||
raise AppError(ErrorCode.APP_RELEASE_NOT_FOUND, "没有这个安装包")
|
||||
path = apk_dir() / name
|
||||
if not path.is_file():
|
||||
raise AppError(ErrorCode.APP_RELEASE_NOT_FOUND, "安装包文件不见了,请重新发布")
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="application/vnd.android.package-archive",
|
||||
filename=name,
|
||||
)
|
||||
@@ -0,0 +1,307 @@
|
||||
"""附件接口:上传 / 列表 / 下载 / 删除。
|
||||
|
||||
三条约束(都会有用例守):
|
||||
1. 只认「本工作区的条目」。拿着别的租户的 item_id 上传,返回 5001 而不是 403——
|
||||
对外不该暴露那条记录是否存在。
|
||||
2. 大小上限在**读流时**卡住,不能先落盘再判断(否则等于给了别人一个写满磁盘的入口)。
|
||||
3. 磁盘上的名字由服务端生成,用户文件名只进库和 Content-Disposition。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, File, Query, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import select
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from app.audit import record as audit_record
|
||||
from app.config import get_settings
|
||||
from app.db import bind_tenant
|
||||
from app.deps import MemberDep, SessionDep
|
||||
from app.errors import AppError, ErrorCode
|
||||
from app.models import Member
|
||||
from app.models_attachment import Attachment
|
||||
from app.models_task import Item
|
||||
from app.schemas import ok
|
||||
from app.security import decode_token
|
||||
from app.services import signing, storage
|
||||
|
||||
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["attachments"])
|
||||
|
||||
ALLOWED_KINDS = ("task", "ticket", "knowledge")
|
||||
|
||||
|
||||
def _attachment_out(row: Attachment, viewer_id=None, role=None) -> dict:
|
||||
settings = get_settings()
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"item_id": str(row.item_id),
|
||||
"filename": row.filename,
|
||||
"content_type": row.content_type,
|
||||
"size": row.size_bytes,
|
||||
"sha256": row.sha256,
|
||||
"uploader_id": str(row.uploader_id),
|
||||
"created_at": row.created_at.isoformat(),
|
||||
# 图片直链:自带短期签名,浏览器 <img> 直接能用,不需要把登录令牌塞进 URL
|
||||
"raw_url": (
|
||||
f"{settings.api_prefix}/workspaces/{row.workspace_id}"
|
||||
f"/attachments/{row.id}/raw?{signing.sign(row.workspace_id, row.id)}"
|
||||
),
|
||||
"can_delete": row.uploader_id == viewer_id or role in ("owner", "admin"),
|
||||
}
|
||||
|
||||
|
||||
async def _load_item(session, ws_id, item_id) -> Item:
|
||||
item = await session.scalar(
|
||||
select(Item).where(
|
||||
Item.id == item_id,
|
||||
Item.workspace_id == ws_id,
|
||||
Item.deleted_at.is_(None),
|
||||
Item.kind.in_(ALLOWED_KINDS),
|
||||
)
|
||||
)
|
||||
if item is None:
|
||||
raise AppError(ErrorCode.ITEM_NOT_FOUND, "事项不存在")
|
||||
return item
|
||||
|
||||
|
||||
def _safe_filename(name: str | None, fallback: str = "未命名文件") -> str:
|
||||
"""只用于展示与下载响应头:去掉路径分隔符与控制字符,长度截断。"""
|
||||
raw = (name or "").strip().replace("\\", "/").split("/")[-1]
|
||||
cleaned = "".join(ch for ch in raw if ch.isprintable() and ch not in '\r\n"')
|
||||
cleaned = cleaned.strip() or fallback
|
||||
if len(cleaned) > 200:
|
||||
stem, dot, ext = cleaned.rpartition(".")
|
||||
cleaned = (stem[:180] + dot + ext[-16:]) if dot and ext else cleaned[:200]
|
||||
return cleaned
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/attachments", status_code=201)
|
||||
async def upload_attachment(
|
||||
ctx: MemberDep,
|
||||
session: SessionDep,
|
||||
item_id: str,
|
||||
file: Annotated[UploadFile, File()],
|
||||
):
|
||||
item = await _load_item(session, ctx.ws_id, item_id)
|
||||
limit = get_settings().max_upload_mb * 1024 * 1024
|
||||
|
||||
# 先看声明的大小(可选,客户端不一定给),真正的限额在读流时兜底
|
||||
if file.size is not None and file.size > limit:
|
||||
raise AppError(
|
||||
ErrorCode.ATTACHMENT_TOO_LARGE,
|
||||
f"文件超过 {get_settings().max_upload_mb}MB 上限",
|
||||
)
|
||||
|
||||
try:
|
||||
stored = await run_in_threadpool(
|
||||
storage.save, file.file, workspace_id=ctx.ws_id, item_id=item.id
|
||||
)
|
||||
if stored.size > limit:
|
||||
await run_in_threadpool(storage.remove, stored.key)
|
||||
raise AppError(
|
||||
ErrorCode.ATTACHMENT_TOO_LARGE,
|
||||
f"文件超过 {get_settings().max_upload_mb}MB 上限",
|
||||
)
|
||||
|
||||
row = Attachment(
|
||||
workspace_id=ctx.ws_id,
|
||||
item_id=item.id,
|
||||
uploader_id=ctx.user.id,
|
||||
filename=_safe_filename(file.filename),
|
||||
content_type=(file.content_type or "application/octet-stream")[:120],
|
||||
size_bytes=stored.size,
|
||||
sha256=stored.sha256,
|
||||
storage_key=stored.key,
|
||||
)
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
await audit_record(
|
||||
session,
|
||||
workspace_id=ctx.ws_id,
|
||||
actor_user_id=ctx.user.id,
|
||||
action="attachment_uploaded",
|
||||
target_type="item",
|
||||
target_id=str(item.id),
|
||||
payload={"attachment_id": str(row.id), "filename": row.filename},
|
||||
)
|
||||
await session.commit()
|
||||
finally:
|
||||
await file.close()
|
||||
|
||||
return ok(_attachment_out(row, ctx.user.id, ctx.role))
|
||||
|
||||
|
||||
@router.get("/items/{item_id}/attachments")
|
||||
async def list_attachments(ctx: MemberDep, session: SessionDep, item_id: str):
|
||||
item = await _load_item(session, ctx.ws_id, item_id)
|
||||
rows = (
|
||||
await session.scalars(
|
||||
select(Attachment)
|
||||
.where(Attachment.workspace_id == ctx.ws_id, Attachment.item_id == item.id)
|
||||
.order_by(Attachment.created_at.asc())
|
||||
)
|
||||
).all()
|
||||
return ok({"items": [_attachment_out(row, ctx.user.id, ctx.role) for row in rows]})
|
||||
|
||||
|
||||
@router.get("/attachments/{attachment_id}/download")
|
||||
async def download_attachment(ctx: MemberDep, session: SessionDep, attachment_id: str):
|
||||
row = await session.scalar(
|
||||
select(Attachment).where(
|
||||
Attachment.id == attachment_id, Attachment.workspace_id == ctx.ws_id
|
||||
)
|
||||
)
|
||||
if row is None:
|
||||
raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件不存在")
|
||||
|
||||
path = storage.path_of(row.storage_key)
|
||||
if not path.exists():
|
||||
# 库里有记录、盘上没文件:说明备份漏了 blob,明确报错而不是返回空文件
|
||||
raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件文件已丢失,请联系管理员")
|
||||
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type=row.content_type,
|
||||
headers={
|
||||
# 中文文件名必须按 RFC 5987 编码,否则浏览器拿到的是乱码
|
||||
"Content-Disposition": "attachment; filename*=UTF-8''"
|
||||
+ urllib.parse.quote(row.filename),
|
||||
# 附件是不可信内容:禁止浏览器嗅探类型,否则 .txt 里的 HTML 会被执行
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/attachments/{attachment_id}/raw")
|
||||
async def raw_attachment(
|
||||
ws_id: uuid.UUID,
|
||||
session: SessionDep,
|
||||
attachment_id: uuid.UUID,
|
||||
exp: Annotated[int | None, Query(description="签名过期时间(unix 秒)")] = None,
|
||||
sig: Annotated[str | None, Query(description="短期签名,见 services/signing.py")] = None,
|
||||
token: Annotated[str | None, Query(description="兼容旧链接用的 access token")] = None,
|
||||
):
|
||||
"""给 Markdown 里的图片用:浏览器 `<img>` 无法附加 Authorization 头。
|
||||
|
||||
优先认 `?exp=&sig=` 的短期签名(由附件列表接口签发,只为这一张图、只活 30 分钟);
|
||||
老链接里的 `?token=` 仍能用,但那是过渡期兼容,前端已不再生成。
|
||||
"""
|
||||
if exp is not None or sig is not None:
|
||||
if not signing.verify(ws_id, attachment_id, exp, sig):
|
||||
raise AppError(ErrorCode.ATTACHMENT_FORBIDDEN, "图片链接已失效,请刷新页面")
|
||||
# 这个接口不走 MemberDep,RLS 不会自动绑租户,得手动绑一次
|
||||
await bind_tenant(session, ws_id=ws_id, user_id=None)
|
||||
else:
|
||||
if not token:
|
||||
raise AppError(ErrorCode.UNAUTHORIZED, "缺少访问凭据")
|
||||
try:
|
||||
user_id = decode_token(token, "access")
|
||||
except Exception: # noqa: BLE001 —— 任何解码失败都按未认证处理
|
||||
raise AppError(ErrorCode.UNAUTHORIZED, "登录已过期,请重新登录") from None
|
||||
|
||||
await bind_tenant(session, ws_id=ws_id, user_id=user_id)
|
||||
|
||||
member = await session.scalar(
|
||||
select(Member.id).where(
|
||||
Member.workspace_id == ws_id,
|
||||
Member.user_id == user_id,
|
||||
Member.status == "active",
|
||||
)
|
||||
)
|
||||
if member is None:
|
||||
# 不是成员就当作不存在:不暴露"这里有个附件但你看不到"
|
||||
raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件不存在")
|
||||
|
||||
row = await session.scalar(
|
||||
select(Attachment).where(Attachment.id == attachment_id, Attachment.workspace_id == ws_id)
|
||||
)
|
||||
if row is None:
|
||||
raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件不存在")
|
||||
|
||||
path = storage.path_of(row.storage_key)
|
||||
if not path.exists():
|
||||
raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件文件已丢失,请联系管理员")
|
||||
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type=row.content_type,
|
||||
headers={
|
||||
"Content-Disposition": "inline",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
# 附件内容不变(key 由内容哈希决定),可以放心长缓存;换了文件就是换了 id
|
||||
"Cache-Control": "private, max-age=86400",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/attachments/{attachment_id}")
|
||||
async def delete_attachment(
|
||||
ctx: MemberDep, session: SessionDep, attachment_id: str, confirm: bool = Query(False)
|
||||
):
|
||||
row = await session.scalar(
|
||||
select(Attachment).where(
|
||||
Attachment.id == attachment_id, Attachment.workspace_id == ctx.ws_id
|
||||
)
|
||||
)
|
||||
if row is None:
|
||||
raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件不存在")
|
||||
|
||||
is_uploader = row.uploader_id == ctx.user.id
|
||||
if not is_uploader and ctx.role not in ("owner", "admin"):
|
||||
raise AppError(ErrorCode.ATTACHMENT_FORBIDDEN, "只有上传者或管理员能删除附件")
|
||||
if not confirm:
|
||||
# 删除不可逆,要求显式确认,避免误点
|
||||
raise AppError(
|
||||
ErrorCode.CONFIRM_REQUIRED, "删除附件需要二次确认", details={"confirm": True}
|
||||
)
|
||||
|
||||
await run_in_threadpool(storage.remove, row.storage_key)
|
||||
await session.delete(row)
|
||||
await audit_record(
|
||||
session,
|
||||
workspace_id=ctx.ws_id,
|
||||
actor_user_id=ctx.user.id,
|
||||
action="attachment_deleted",
|
||||
target_type="item",
|
||||
target_id=str(row.item_id),
|
||||
payload={"attachment_id": str(row.id), "filename": row.filename},
|
||||
)
|
||||
await session.commit()
|
||||
return ok({"deleted": True})
|
||||
|
||||
|
||||
async def list_for_item(session, ws_id, item_id) -> list[dict]:
|
||||
"""给其他接口内联用(列表/详情里带上附件摘要)。"""
|
||||
rows = (
|
||||
await session.scalars(
|
||||
select(Attachment)
|
||||
.where(Attachment.workspace_id == ws_id, Attachment.item_id == item_id)
|
||||
.order_by(Attachment.created_at.asc())
|
||||
)
|
||||
).all()
|
||||
return [_attachment_out(row) for row in rows]
|
||||
|
||||
|
||||
async def count_for_items(session, ws_id, item_ids: list) -> dict[str, int]:
|
||||
"""列表页只显示"几个附件",避免每条都拉全量。"""
|
||||
if not item_ids:
|
||||
return {}
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Attachment.item_id).where(
|
||||
Attachment.workspace_id == ws_id, Attachment.item_id.in_(item_ids)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
counts: dict[str, int] = {}
|
||||
for (item_id,) in rows:
|
||||
counts[str(item_id)] = counts.get(str(item_id), 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
__all__ = ["router", "list_for_item", "count_for_items"]
|
||||
@@ -1,32 +1,98 @@
|
||||
"""认证:手机号 + 验证码。M0 不做密码、不做第三方登录。"""
|
||||
"""认证:手机号 / 邮箱 + 密码,邮箱验证码注册,手机验证码可选。
|
||||
|
||||
当前**默认关掉短信验证码**(`SMS_LOGIN_ENABLED=false`):内测阶段的固定验证码
|
||||
等于"知道手机号就能进任意账号"。关掉后 `/auth/sms/send` 与 `/auth/login` 直接
|
||||
回 2017,登录走「手机号 或 邮箱 + 密码」。登录页先问 `/auth/methods`,只显示
|
||||
服务端真开着的登录方式,免得点进去才报错。
|
||||
|
||||
新用户走「邮箱 + 验证码」注册(`POST /auth/email/code` → `POST /auth/register`):
|
||||
短信一关,就没有别的注册入口了。注册出来的账号没有手机号,`app_user.phone` 因此可空。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config import get_settings
|
||||
from ..deps import CurrentUser, SessionDep
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models import AppUser, Member, Workspace
|
||||
from ..schemas import ok
|
||||
from ..security import create_token_pair, decode_token
|
||||
from ..stores import get_store, sms_code_key, sms_throttle_key
|
||||
from ..security import (
|
||||
create_token_pair,
|
||||
decode_token,
|
||||
hash_password,
|
||||
password_problem,
|
||||
verify_password,
|
||||
)
|
||||
from ..services import mail
|
||||
from ..stores import (
|
||||
email_code_key,
|
||||
email_throttle_key,
|
||||
email_tries_key,
|
||||
get_store,
|
||||
sms_code_key,
|
||||
sms_throttle_key,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
settings = get_settings()
|
||||
|
||||
PHONE_PATTERN = r"^1[3-9]\d{9}$"
|
||||
# 邮箱只做形状校验:真正的可达性靠发信验证,正则再严也证明不了地址存在
|
||||
EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]{1,190}\.[A-Za-z]{2,}$")
|
||||
|
||||
# 密码登录连错这么多次就先锁一会儿:暴力猜密码的成本必须比猜验证码高
|
||||
PASSWORD_FAIL_LIMIT = 10
|
||||
PASSWORD_FAIL_TTL = 900
|
||||
|
||||
|
||||
def mask_phone(phone: str) -> str:
|
||||
def mask_phone(phone: str | None) -> str:
|
||||
if not phone:
|
||||
return ""
|
||||
return f"{phone[:3]}****{phone[-4:]}" if len(phone) == 11 else "***"
|
||||
|
||||
|
||||
def normalize_email(email: str) -> str:
|
||||
"""邮箱大小写不敏感:统一小写存,登录时也按小写比。"""
|
||||
return email.strip().lower()
|
||||
|
||||
|
||||
def _user_out(user: AppUser) -> dict:
|
||||
"""对外只给脱敏手机号;密码本身一个字节都不出去,只告诉客户端「设没设」。
|
||||
|
||||
邮箱是用户自己的账号名,给全值(账号页要显示、要能改)。
|
||||
"""
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"phone": mask_phone(user.phone),
|
||||
"email": user.email,
|
||||
"email_verified": user.email_verified_at is not None,
|
||||
"nickname": user.nickname,
|
||||
"avatar_url": user.avatar_url,
|
||||
"has_password": bool(user.password_hash),
|
||||
"platform_admin": bool(user.phone) and user.phone in settings.platform_admin_phones,
|
||||
"created_at": user.created_at,
|
||||
}
|
||||
|
||||
|
||||
def _tokens_out(tokens) -> dict:
|
||||
return {
|
||||
"access_token": tokens.access_token,
|
||||
"refresh_token": tokens.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": tokens.expires_in,
|
||||
}
|
||||
|
||||
|
||||
class SendCodeIn(BaseModel):
|
||||
phone: str = Field(pattern=PHONE_PATTERN, examples=["13800138000"])
|
||||
|
||||
@@ -36,12 +102,57 @@ class LoginIn(BaseModel):
|
||||
code: str = Field(min_length=4, max_length=8)
|
||||
|
||||
|
||||
class PasswordLoginIn(BaseModel):
|
||||
"""手机号或邮箱 + 密码。两个都给也行,手机号优先(老客户端只发 phone)。"""
|
||||
|
||||
phone: str | None = Field(default=None, pattern=PHONE_PATTERN)
|
||||
email: str | None = Field(default=None, max_length=255)
|
||||
password: str = Field(min_length=1, max_length=64)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _one_account(self) -> PasswordLoginIn:
|
||||
if not self.phone and not (self.email and self.email.strip()):
|
||||
raise ValueError("手机号或邮箱填一个")
|
||||
return self
|
||||
|
||||
|
||||
class ProfilePatch(BaseModel):
|
||||
nickname: str | None = Field(default=None, min_length=1, max_length=32)
|
||||
# 传空串表示解绑;不传表示不动
|
||||
email: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class PasswordIn(BaseModel):
|
||||
"""old_password 只在「已经设过密码」时必填;第一次设密码不需要。"""
|
||||
|
||||
password: str = Field(min_length=1, max_length=64)
|
||||
old_password: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class RefreshIn(BaseModel):
|
||||
refresh_token: str = Field(min_length=10)
|
||||
|
||||
|
||||
@router.get("/methods", summary="这台服务现在开放哪些登录方式")
|
||||
async def methods() -> dict:
|
||||
"""登录页据此渲染,别让用户点进去才发现这条路关着。"""
|
||||
return ok(
|
||||
{
|
||||
"password": True,
|
||||
"email_password": True,
|
||||
"sms_code": settings.sms_login_enabled,
|
||||
"email_register": settings.email_register_enabled,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sms/send", summary="发送登录验证码")
|
||||
async def send_code(body: SendCodeIn) -> dict:
|
||||
if not settings.sms_login_enabled:
|
||||
raise AppError(
|
||||
ErrorCode.LOGIN_METHOD_DISABLED,
|
||||
"短信验证码登录暂未开放,请用手机号或邮箱 + 密码登录",
|
||||
)
|
||||
store = get_store()
|
||||
if settings.sms_throttle_seconds > 0:
|
||||
hits = await store.incr(sms_throttle_key(body.phone), settings.sms_throttle_seconds)
|
||||
@@ -64,6 +175,11 @@ async def send_code(body: SendCodeIn) -> dict:
|
||||
|
||||
@router.post("/login", summary="验证码登录(不存在则自动注册)")
|
||||
async def login(body: LoginIn, session: SessionDep) -> dict:
|
||||
if not settings.sms_login_enabled:
|
||||
raise AppError(
|
||||
ErrorCode.LOGIN_METHOD_DISABLED,
|
||||
"短信验证码登录暂未开放,请用手机号或邮箱 + 密码登录",
|
||||
)
|
||||
store = get_store()
|
||||
stored = await store.get(sms_code_key(body.phone))
|
||||
if stored is None:
|
||||
@@ -81,40 +197,68 @@ async def login(body: LoginIn, session: SessionDep) -> dict:
|
||||
await session.flush()
|
||||
|
||||
tokens = create_token_pair(user.id)
|
||||
return ok(
|
||||
{
|
||||
"user": {
|
||||
"id": str(user.id),
|
||||
"phone": mask_phone(user.phone),
|
||||
"nickname": user.nickname,
|
||||
"created_at": user.created_at,
|
||||
},
|
||||
"tokens": {
|
||||
"access_token": tokens.access_token,
|
||||
"refresh_token": tokens.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": tokens.expires_in,
|
||||
},
|
||||
}
|
||||
)
|
||||
return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)})
|
||||
|
||||
|
||||
@router.post("/login/password", summary="手机号 / 邮箱 + 密码登录")
|
||||
async def login_password(body: PasswordLoginIn, session: SessionDep) -> dict:
|
||||
"""手机号或邮箱 + 密码登录。
|
||||
|
||||
三个细节:
|
||||
- **不区分"没这个人"和"密码错"**,同一个错误码,别让登录接口变成账号探测工具。
|
||||
- 连错 PASSWORD_FAIL_LIMIT 次锁 PASSWORD_FAIL_TTL 秒;成功即清零,正常用户不会感知。
|
||||
失败计数按"填的那个账号"记,换手机号/邮箱轮着试也照样累计。
|
||||
- 邮箱大小写不敏感(存的时候已经统一小写,这里再 lower 一次兜住历史数据)。
|
||||
"""
|
||||
store = get_store()
|
||||
account_key = body.phone or normalize_email(body.email or "")
|
||||
fail_key = f"pwd_fail:{account_key}"
|
||||
if int(await store.get(fail_key) or 0) >= PASSWORD_FAIL_LIMIT:
|
||||
raise AppError(
|
||||
ErrorCode.LOGIN_TOO_MANY,
|
||||
f"密码错误次数过多,请 {PASSWORD_FAIL_TTL // 60} 分钟后再试",
|
||||
)
|
||||
|
||||
if body.phone:
|
||||
stmt = select(AppUser).where(AppUser.phone == body.phone)
|
||||
hint = "手机号或密码不对"
|
||||
else:
|
||||
stmt = select(AppUser).where(func.lower(AppUser.email) == account_key)
|
||||
hint = "邮箱或密码不对"
|
||||
|
||||
user = (await session.execute(stmt)).scalar_one_or_none()
|
||||
if user is None or not verify_password(body.password, user.password_hash):
|
||||
await store.incr(fail_key, PASSWORD_FAIL_TTL)
|
||||
raise AppError(ErrorCode.PASSWORD_INVALID, hint)
|
||||
|
||||
if user.status != "active":
|
||||
raise AppError(ErrorCode.ACCOUNT_DISABLED, "账号已停用")
|
||||
|
||||
await store.delete(fail_key)
|
||||
tokens = create_token_pair(user.id)
|
||||
return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)})
|
||||
|
||||
|
||||
@router.post("/refresh", summary="刷新访问令牌")
|
||||
async def refresh(body: RefreshIn) -> dict:
|
||||
async def refresh(body: RefreshIn, session: SessionDep) -> dict:
|
||||
user_id = decode_token(body.refresh_token, "refresh")
|
||||
# 换令牌时也看一眼账号状态:停用的账号不该靠 refresh 一直续下去
|
||||
user = await session.get(AppUser, user_id)
|
||||
if user is None:
|
||||
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
|
||||
if user.status != "active":
|
||||
raise AppError(ErrorCode.ACCOUNT_DISABLED, "账号已停用,请联系管理员")
|
||||
tokens = create_token_pair(user_id)
|
||||
return ok(
|
||||
{
|
||||
"access_token": tokens.access_token,
|
||||
"refresh_token": tokens.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": tokens.expires_in,
|
||||
}
|
||||
)
|
||||
# 这里的形状保持扁平(不带 tokens 信封),老客户端与安卓端都按这个取
|
||||
return ok(_tokens_out(tokens))
|
||||
|
||||
|
||||
@router.get("/me", summary="当前用户与所属工作区")
|
||||
async def me(user: CurrentUser, session: SessionDep) -> dict:
|
||||
row = await session.get(AppUser, user.id)
|
||||
if row is None:
|
||||
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
|
||||
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Workspace, Member.role)
|
||||
@@ -126,11 +270,7 @@ async def me(user: CurrentUser, session: SessionDep) -> dict:
|
||||
|
||||
return ok(
|
||||
{
|
||||
"user": {
|
||||
"id": str(user.id),
|
||||
"phone": mask_phone(user.phone),
|
||||
"nickname": user.nickname,
|
||||
},
|
||||
"user": _user_out(row),
|
||||
"workspaces": [
|
||||
{
|
||||
"id": str(ws.id),
|
||||
@@ -144,3 +284,186 @@ async def me(user: CurrentUser, session: SessionDep) -> dict:
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/me", summary="改自己的昵称与邮箱")
|
||||
async def update_me(body: ProfilePatch, user: CurrentUser, session: SessionDep) -> dict:
|
||||
row = await session.get(AppUser, user.id)
|
||||
if row is None:
|
||||
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
|
||||
|
||||
if body.nickname is not None:
|
||||
nickname = body.nickname.strip()
|
||||
if not nickname:
|
||||
raise AppError(ErrorCode.VALIDATION_FAILED, "昵称不能为空")
|
||||
row.nickname = nickname
|
||||
|
||||
if body.email is not None:
|
||||
raw = body.email.strip()
|
||||
if not raw:
|
||||
# 解绑:邮箱只是"多一条登录方式",不是必填项
|
||||
row.email = None
|
||||
else:
|
||||
email = normalize_email(raw)
|
||||
if not EMAIL_RE.match(email):
|
||||
raise AppError(ErrorCode.EMAIL_INVALID, "邮箱格式不对")
|
||||
taken = (
|
||||
await session.execute(
|
||||
select(AppUser.id).where(
|
||||
func.lower(AppUser.email) == email, AppUser.id != user.id
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if taken is not None:
|
||||
raise AppError(ErrorCode.EMAIL_TAKEN, "这个邮箱已经绑在别的账号上了")
|
||||
row.email = email
|
||||
|
||||
await session.flush()
|
||||
return ok({"user": _user_out(row)})
|
||||
|
||||
|
||||
@router.post("/password", summary="设置或修改登录密码")
|
||||
async def set_password(body: PasswordIn, user: CurrentUser, session: SessionDep) -> dict:
|
||||
"""已经设过密码的,必须先给旧密码——否则拿到一个没锁屏的浏览器就能把密码换掉。"""
|
||||
row = await session.get(AppUser, user.id)
|
||||
if row is None:
|
||||
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
|
||||
|
||||
if row.password_hash:
|
||||
if not body.old_password:
|
||||
raise AppError(ErrorCode.PASSWORD_INVALID, "请输入当前密码")
|
||||
if not verify_password(body.old_password, row.password_hash):
|
||||
raise AppError(ErrorCode.PASSWORD_INVALID, "当前密码不对")
|
||||
if hmac.compare_digest(body.old_password, body.password):
|
||||
raise AppError(ErrorCode.PASSWORD_WEAK, "新密码不能和当前密码一样")
|
||||
|
||||
problem = password_problem(body.password)
|
||||
if problem:
|
||||
raise AppError(ErrorCode.PASSWORD_WEAK, problem)
|
||||
|
||||
row.password_hash = hash_password(body.password)
|
||||
await session.flush()
|
||||
return ok({"has_password": True})
|
||||
|
||||
|
||||
# ── 邮箱注册 ────────────────────────────────────────────
|
||||
class EmailCodeIn(BaseModel):
|
||||
email: str = Field(max_length=255, examples=["someone@example.com"])
|
||||
|
||||
|
||||
class RegisterIn(BaseModel):
|
||||
"""邮箱 + 验证码 + 密码,一步把账号建出来。
|
||||
|
||||
昵称不填就取邮箱 @ 前面那段,别让新用户第一眼看到一个空名字。
|
||||
"""
|
||||
|
||||
email: str = Field(max_length=255)
|
||||
code: str = Field(min_length=4, max_length=8)
|
||||
password: str = Field(min_length=1, max_length=64)
|
||||
nickname: str | None = Field(default=None, max_length=32)
|
||||
|
||||
|
||||
async def _email_taken(session: AsyncSession, email: str) -> bool:
|
||||
stmt = select(AppUser.id).where(func.lower(AppUser.email) == email)
|
||||
return (await session.execute(stmt)).first() is not None
|
||||
|
||||
|
||||
@router.post("/email/code", summary="发送邮箱注册验证码")
|
||||
async def send_email_code(body: EmailCodeIn, session: SessionDep) -> dict:
|
||||
"""发码前先查邮箱是否已被占用:让用户填完密码才发现"这个邮箱注册过了"最气人。"""
|
||||
if not settings.email_register_enabled:
|
||||
raise AppError(
|
||||
ErrorCode.LOGIN_METHOD_DISABLED,
|
||||
"邮箱注册暂未开放,请联系管理员开通账号",
|
||||
)
|
||||
|
||||
email = normalize_email(body.email)
|
||||
if not EMAIL_RE.match(email):
|
||||
raise AppError(ErrorCode.EMAIL_INVALID, "邮箱格式不对")
|
||||
if await _email_taken(session, email):
|
||||
raise AppError(ErrorCode.EMAIL_TAKEN, "这个邮箱已经注册过了,直接登录即可")
|
||||
|
||||
store = get_store()
|
||||
if settings.email_code_throttle_seconds > 0:
|
||||
hits = await store.incr(email_throttle_key(email), settings.email_code_throttle_seconds)
|
||||
if hits > 1:
|
||||
raise AppError(
|
||||
ErrorCode.EMAIL_CODE_TOO_FREQUENT,
|
||||
f"发送过于频繁,请 {settings.email_code_throttle_seconds} 秒后重试",
|
||||
)
|
||||
|
||||
code = f"{secrets.randbelow(1_000_000):06d}"
|
||||
await store.set(email_code_key(email), code, settings.email_code_ttl_seconds)
|
||||
await store.delete(email_tries_key(email))
|
||||
|
||||
minutes = max(1, settings.email_code_ttl_seconds // 60)
|
||||
if settings.email_code_dev_echo:
|
||||
# 本机联调:不发真信,直接把码回给调用方(生产打开会被 validate_runtime 拒掉)
|
||||
return ok(
|
||||
{
|
||||
"sent": True,
|
||||
"expires_in": settings.email_code_ttl_seconds,
|
||||
"dev_code": code,
|
||||
}
|
||||
)
|
||||
|
||||
await mail.send_verify_code(session, to=email, code=code, minutes=minutes)
|
||||
return ok({"sent": True, "expires_in": settings.email_code_ttl_seconds})
|
||||
|
||||
|
||||
@router.post("/register", summary="邮箱注册(验证码 + 密码)")
|
||||
async def register(body: RegisterIn, session: SessionDep) -> dict:
|
||||
"""邮箱注册:验码 → 建账号 → 直接发令牌,注册完就是登录状态。
|
||||
|
||||
账号没有手机号(`phone` 为空),所以邮件是唯一联系方式——这也是为什么注册必须
|
||||
真验一次码,而不是"填个邮箱就算"。
|
||||
"""
|
||||
if not settings.email_register_enabled:
|
||||
raise AppError(
|
||||
ErrorCode.LOGIN_METHOD_DISABLED,
|
||||
"邮箱注册暂未开放,请联系管理员开通账号",
|
||||
)
|
||||
|
||||
email = normalize_email(body.email)
|
||||
if not EMAIL_RE.match(email):
|
||||
raise AppError(ErrorCode.EMAIL_INVALID, "邮箱格式不对")
|
||||
|
||||
store = get_store()
|
||||
stored = await store.get(email_code_key(email))
|
||||
if not stored:
|
||||
raise AppError(ErrorCode.EMAIL_CODE_INVALID, "验证码已过期,请重新获取")
|
||||
|
||||
tries = await store.incr(email_tries_key(email), settings.email_code_ttl_seconds)
|
||||
if tries > settings.email_code_max_attempts:
|
||||
await store.delete(email_code_key(email))
|
||||
raise AppError(ErrorCode.EMAIL_CODE_INVALID, "验证码错太多次,请重新获取")
|
||||
|
||||
if not hmac.compare_digest(stored, body.code.strip()):
|
||||
raise AppError(ErrorCode.EMAIL_CODE_INVALID, "验证码不对")
|
||||
|
||||
problem = password_problem(body.password)
|
||||
if problem:
|
||||
raise AppError(ErrorCode.PASSWORD_WEAK, problem)
|
||||
|
||||
# 拿到验证码之后这段时间里,别人可能已经用同一个邮箱注册完了
|
||||
if await _email_taken(session, email):
|
||||
raise AppError(ErrorCode.EMAIL_TAKEN, "这个邮箱已经注册过了,直接登录即可")
|
||||
|
||||
nickname = (body.nickname or "").strip() or email.split("@", 1)[0][:32]
|
||||
user = AppUser(
|
||||
email=email,
|
||||
phone=None,
|
||||
nickname=nickname,
|
||||
password_hash=hash_password(body.password),
|
||||
email_verified_at=datetime.now(timezone.utc),
|
||||
status="active",
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
|
||||
await store.delete(email_code_key(email))
|
||||
await store.delete(email_tries_key(email))
|
||||
await store.delete(email_throttle_key(email))
|
||||
|
||||
tokens = create_token_pair(user.id)
|
||||
return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)})
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""外部接入:微信 clawbot 的扫码登录与状态。
|
||||
|
||||
真实协议是腾讯 iLink Bot(出处见 `app/services/wechat_ilink.py` 文件头):
|
||||
**我们**去取二维码、等人扫、拿到登录态后长轮询拉消息。所以这里的接口是三件事:
|
||||
|
||||
1. `POST /workspaces/{ws}/integrations/wechat/qr` —— 取一张二维码,页面显示给人扫;
|
||||
2. `GET /workspaces/{ws}/integrations/wechat/qr/{id}` —— 问"扫了吗",扫成功就存登录态并开始轮询;
|
||||
3. `DELETE /workspaces/{ws}/integrations/{channel_id}` —— 断开:登录态作废、轮询停止。
|
||||
|
||||
二维码 id 放 Redis 而不是内存:问状态的那一下可能落到另一个进程,
|
||||
存内存就会"扫完了但没人知道"。key 带工作区,两个工作区互不踩。
|
||||
|
||||
上一版这里做的是 webhook 接收端(`POST /integrations/clawbot/{token}`),
|
||||
对 clawbot 的理解是错的,接口与令牌一并删掉了(迁移 0013 的注释记了原因)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import qrcode
|
||||
import qrcode.image.svg
|
||||
from fastapi import APIRouter, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..db import bind_tenant
|
||||
from ..deps import AdminDep, SessionDep
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models_integrations import BotChannel, BotMessage
|
||||
from ..schemas import ok
|
||||
from ..services import wechat_ilink
|
||||
from ..services.wechat_bot import lease_held, manager
|
||||
from ..stores import get_store
|
||||
|
||||
admin_router = APIRouter(prefix="/workspaces", tags=["外部接入"])
|
||||
|
||||
# iLink 的二维码大约 3 分钟过期,我们按 5 分钟缓存 id,过期就重新取。
|
||||
QR_TTL_SECONDS = 300
|
||||
|
||||
|
||||
class ChannelPatch(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
def _qr_key(ws_id: uuid.UUID) -> str:
|
||||
return f"wechat:qr:{ws_id}"
|
||||
|
||||
|
||||
def render_qr_svg(payload: str) -> str:
|
||||
"""把二维码内容画成 data URL:服务端画,前端不用再引一个二维码库。"""
|
||||
image = qrcode.make(payload, image_factory=qrcode.image.svg.SvgPathImage, box_size=10)
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer)
|
||||
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
|
||||
return f"data:image/svg+xml;base64,{encoded}"
|
||||
|
||||
|
||||
async def _channel_out(channel: BotChannel) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(channel.id),
|
||||
"kind": channel.kind,
|
||||
"name": channel.name,
|
||||
"status": channel.status,
|
||||
"connected": bool(channel.bot_token_enc) and channel.status == "active",
|
||||
# 数据库级租约:谁在真收消息所有进程都认,页面不会跟着进程抖动。
|
||||
"polling": await lease_held(channel.id),
|
||||
"ilink_user_id": channel.ilink_user_id,
|
||||
"last_seen_at": channel.last_seen_at,
|
||||
"last_error": channel.last_error,
|
||||
"created_at": channel.created_at,
|
||||
}
|
||||
|
||||
|
||||
async def _wechat_channel(session: SessionDep, ws_id: uuid.UUID) -> BotChannel | None:
|
||||
rows = await session.execute(
|
||||
select(BotChannel)
|
||||
.where(BotChannel.workspace_id == ws_id, BotChannel.kind == "wechat_clawbot")
|
||||
.order_by(BotChannel.created_at.desc())
|
||||
)
|
||||
return rows.scalars().first()
|
||||
|
||||
|
||||
@admin_router.get("/{ws_id}/integrations")
|
||||
async def list_integrations(ws_id: uuid.UUID, ctx: AdminDep, session: SessionDep):
|
||||
rows = await session.execute(
|
||||
select(BotChannel)
|
||||
.where(BotChannel.workspace_id == ws_id)
|
||||
.order_by(BotChannel.created_at.desc())
|
||||
)
|
||||
return ok({"items": [await _channel_out(c) for c in rows.scalars().all()]})
|
||||
|
||||
|
||||
@admin_router.post("/{ws_id}/integrations/wechat/qr")
|
||||
async def begin_wechat_login(ws_id: uuid.UUID, ctx: AdminDep, session: SessionDep):
|
||||
"""取一张二维码。已经登录过的通道会被重置成 pending(换号 = 重新扫)。"""
|
||||
try:
|
||||
code = await wechat_ilink.get_qrcode()
|
||||
except wechat_ilink.WechatError as exc:
|
||||
raise AppError(ErrorCode.BOT_TOKEN_INVALID, f"取二维码失败:{exc}") from exc
|
||||
|
||||
channel = await _wechat_channel(session, ws_id)
|
||||
if channel is None:
|
||||
channel = BotChannel(
|
||||
workspace_id=ws_id,
|
||||
kind="wechat_clawbot",
|
||||
name="微信",
|
||||
status="pending",
|
||||
created_by=ctx.user.id,
|
||||
)
|
||||
session.add(channel)
|
||||
if channel.bot_token_enc:
|
||||
await manager.stop(channel.id)
|
||||
channel.bot_token_enc = None
|
||||
channel.sync_buf = None
|
||||
channel.context_token = None
|
||||
channel.status = "pending"
|
||||
channel.last_error = None
|
||||
await session.flush()
|
||||
|
||||
await get_store().set(_qr_key(ws_id), code.qrcode_id, QR_TTL_SECONDS)
|
||||
await session.commit()
|
||||
|
||||
return ok(
|
||||
{
|
||||
"channel_id": str(channel.id),
|
||||
"qrcode_id": code.qrcode_id,
|
||||
"payload": code.payload,
|
||||
"image": render_qr_svg(code.payload),
|
||||
"expires_in": QR_TTL_SECONDS,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@admin_router.get("/{ws_id}/integrations/wechat/qr/{qrcode_id}")
|
||||
async def poll_wechat_login(ws_id: uuid.UUID, qrcode_id: str, ctx: AdminDep, session: SessionDep):
|
||||
"""问扫码结果。只有 confirmed 才落登录态并开轮询,其余状态原样回给页面。"""
|
||||
cached = await get_store().get(_qr_key(ws_id))
|
||||
if cached != qrcode_id:
|
||||
# 页面拿着旧二维码在问:直接说"过期",让它重新取一张。
|
||||
return ok({"status": "expired"})
|
||||
|
||||
try:
|
||||
status = await wechat_ilink.qrcode_status(qrcode_id)
|
||||
except wechat_ilink.WechatError as exc:
|
||||
return ok({"status": "error", "message": str(exc)})
|
||||
|
||||
if status.status != "confirmed" or not status.bot_token:
|
||||
return ok({"status": status.status})
|
||||
|
||||
channel = await _wechat_channel(session, ws_id)
|
||||
if channel is None:
|
||||
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "通道不见了,请重新取二维码")
|
||||
|
||||
await bind_tenant(session, ws_id, ctx.user.id)
|
||||
channel.bot_token_enc = wechat_ilink.encrypt_token(status.bot_token)
|
||||
channel.ilink_user_id = status.ilink_user_id
|
||||
channel.base_url = status.base_url
|
||||
channel.status = "active"
|
||||
channel.sync_buf = None
|
||||
channel.context_token = None
|
||||
channel.last_error = None
|
||||
channel.created_by = channel.created_by or ctx.user.id
|
||||
await session.commit()
|
||||
await get_store().delete(_qr_key(ws_id))
|
||||
|
||||
await manager.start(channel.id)
|
||||
return ok({"status": "confirmed", "channel": await _channel_out(channel)})
|
||||
|
||||
|
||||
@admin_router.patch("/{ws_id}/integrations/{channel_id}")
|
||||
async def update_integration(
|
||||
ws_id: uuid.UUID,
|
||||
channel_id: uuid.UUID,
|
||||
ctx: AdminDep,
|
||||
session: SessionDep,
|
||||
body: ChannelPatch,
|
||||
):
|
||||
channel = await session.get(BotChannel, channel_id)
|
||||
if channel is None or channel.workspace_id != ws_id:
|
||||
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "接入通道不存在")
|
||||
wanted = body.status.strip()
|
||||
if wanted not in ("active", "disabled"):
|
||||
raise AppError(ErrorCode.VALIDATION_FAILED, "status 只能是 active 或 disabled")
|
||||
if wanted == "active" and not channel.bot_token_enc:
|
||||
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "这个通道还没扫码登录")
|
||||
channel.status = wanted
|
||||
await session.commit()
|
||||
if wanted == "active":
|
||||
await manager.start(channel.id)
|
||||
else:
|
||||
await manager.stop(channel.id)
|
||||
return ok({"channel": await _channel_out(channel)})
|
||||
|
||||
|
||||
@admin_router.delete("/{ws_id}/integrations/{channel_id}")
|
||||
async def disconnect_integration(
|
||||
ws_id: uuid.UUID, channel_id: uuid.UUID, ctx: AdminDep, session: SessionDep
|
||||
):
|
||||
"""断开:停轮询、清登录态。留痕(bot_message)不删,翻旧账用得上。"""
|
||||
channel = await session.get(BotChannel, channel_id)
|
||||
if channel is None or channel.workspace_id != ws_id:
|
||||
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "接入通道不存在")
|
||||
await manager.stop(channel.id)
|
||||
channel.bot_token_enc = None
|
||||
channel.sync_buf = None
|
||||
channel.context_token = None
|
||||
channel.status = "pending"
|
||||
channel.last_error = None
|
||||
await session.commit()
|
||||
return ok({"channel": await _channel_out(channel)})
|
||||
|
||||
|
||||
@admin_router.get("/{ws_id}/integrations/{channel_id}/messages")
|
||||
async def list_messages(
|
||||
ws_id: uuid.UUID,
|
||||
channel_id: uuid.UUID,
|
||||
ctx: AdminDep,
|
||||
session: SessionDep,
|
||||
limit: int = Query(default=5, ge=1, le=100),
|
||||
):
|
||||
rows = await session.execute(
|
||||
select(BotMessage)
|
||||
.where(BotMessage.channel_id == channel_id, BotMessage.workspace_id == ws_id)
|
||||
.order_by(BotMessage.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return ok(
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": str(m.id),
|
||||
"action": m.action,
|
||||
"sender": m.sender,
|
||||
"text": m.text,
|
||||
"reply": m.reply,
|
||||
"item_id": str(m.item_id) if m.item_id else None,
|
||||
"created_at": m.created_at,
|
||||
}
|
||||
for m in rows.scalars().all()
|
||||
]
|
||||
}
|
||||
)
|
||||
@@ -27,10 +27,13 @@ from ..domain.task import (
|
||||
view_statuses,
|
||||
)
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models import Member
|
||||
from ..models_task import Activity, Comment, Item, ItemTag, Tag
|
||||
from ..models_ticket import TicketExt
|
||||
from ..schemas import Pagination, encode_cursor, ok
|
||||
from ..serializers import item_out, tag_out, tags_of
|
||||
from ..services.notify import TASK_STATUS_CN, notify
|
||||
from .attachments import count_for_items, list_for_item
|
||||
|
||||
router = APIRouter(prefix="/workspaces/{ws_id}/items", tags=["任务"])
|
||||
tag_router = APIRouter(prefix="/workspaces/{ws_id}/tags", tags=["标签"])
|
||||
@@ -97,6 +100,25 @@ async def _load_item(
|
||||
return item
|
||||
|
||||
|
||||
async def _ensure_member(session: AsyncSession, ws_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
||||
"""被指派的人必须是这个工作区的在册成员。
|
||||
|
||||
不校验的话可以指派给任意 uuid:任务从此挂在"不存在的人"名下,
|
||||
既不会出现在任何人的列表里,也不会有人收到通知。
|
||||
"""
|
||||
row = (
|
||||
await session.execute(
|
||||
select(Member.id).where(
|
||||
Member.workspace_id == ws_id,
|
||||
Member.user_id == user_id,
|
||||
Member.status == "active",
|
||||
)
|
||||
)
|
||||
).first()
|
||||
if row is None:
|
||||
raise AppError(ErrorCode.BAD_REQUEST, "被指派的人不在这个工作区里")
|
||||
|
||||
|
||||
async def _tags_of(
|
||||
session: AsyncSession, ws_id: uuid.UUID, item_ids: list[uuid.UUID]
|
||||
) -> dict[uuid.UUID, list[Tag]]:
|
||||
@@ -294,9 +316,13 @@ async def list_items(
|
||||
.all()
|
||||
)
|
||||
tags = await _tags_of(session, ctx.ws_id, [r.id for r in rows])
|
||||
counts = await count_for_items(session, ctx.ws_id, [r.id for r in rows])
|
||||
return ok(
|
||||
{
|
||||
"items": [_item_out(r, tags.get(r.id)) for r in rows],
|
||||
"items": [
|
||||
dict(_item_out(r, tags.get(r.id)), attachment_count=counts.get(str(r.id), 0))
|
||||
for r in rows
|
||||
],
|
||||
"next_cursor": None,
|
||||
"has_more": False,
|
||||
}
|
||||
@@ -325,9 +351,13 @@ async def list_items(
|
||||
encode_cursor(getattr(rows[-1], sort_attr), rows[-1].id) if has_more and rows else None
|
||||
)
|
||||
tags = await _tags_of(session, ctx.ws_id, [r.id for r in rows])
|
||||
counts = await count_for_items(session, ctx.ws_id, [r.id for r in rows])
|
||||
return ok(
|
||||
{
|
||||
"items": [_item_out(r, tags.get(r.id)) for r in rows],
|
||||
"items": [
|
||||
dict(_item_out(r, tags.get(r.id)), attachment_count=counts.get(str(r.id), 0))
|
||||
for r in rows
|
||||
],
|
||||
"next_cursor": next_cursor,
|
||||
"has_more": has_more,
|
||||
}
|
||||
@@ -405,6 +435,8 @@ async def get_item(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> d
|
||||
.all()
|
||||
)
|
||||
data = _item_out(item, tags.get(item.id))
|
||||
data["attachments"] = await list_for_item(session, ctx.ws_id, item.id)
|
||||
data["attachment_count"] = len(data["attachments"])
|
||||
data["activities"] = [
|
||||
{
|
||||
"id": str(a.id),
|
||||
@@ -441,6 +473,9 @@ async def create_item(body: CreateItemIn, ctx: MemberDep, session: SessionDep) -
|
||||
fields: dict[str, Any] = {"status": body.status, "completed_at": None}
|
||||
apply_status(fields, body.status, now=datetime.now(timezone.utc))
|
||||
|
||||
if body.assignee_id is not None:
|
||||
await _ensure_member(session, ctx.ws_id, body.assignee_id)
|
||||
|
||||
item = Item(
|
||||
id=uuid.uuid4(),
|
||||
workspace_id=ctx.ws_id,
|
||||
@@ -459,6 +494,17 @@ async def create_item(body: CreateItemIn, ctx: MemberDep, session: SessionDep) -
|
||||
if body.tag_names:
|
||||
await _set_tags(session, ctx.ws_id, item, body.tag_names)
|
||||
await _log(session, item, ctx.user.id, "created", {"status": item.status})
|
||||
if item.assignee_id is not None:
|
||||
await notify(
|
||||
session,
|
||||
ws_id=ctx.ws_id,
|
||||
user_ids=(item.assignee_id,),
|
||||
kind="task_assigned",
|
||||
item_id=item.id,
|
||||
title=f"有人把「{item.title}」交给你了",
|
||||
body=f"{ctx.user.nickname or '同事'} 指派给你,注意截止时间",
|
||||
actor_id=ctx.user.id,
|
||||
)
|
||||
|
||||
tags = await _tags_of(session, ctx.ws_id, [item.id])
|
||||
return ok(_item_out(item, tags.get(item.id)), message="已创建")
|
||||
@@ -565,6 +611,7 @@ async def update_item(
|
||||
values["assignee_id"] = None
|
||||
changes["assignee_id"] = None
|
||||
elif body.assignee_id is not None and body.assignee_id != item.assignee_id:
|
||||
await _ensure_member(session, ctx.ws_id, body.assignee_id)
|
||||
values["assignee_id"] = body.assignee_id
|
||||
changes["assignee_id"] = str(body.assignee_id)
|
||||
if body.clear_due_at:
|
||||
@@ -594,6 +641,18 @@ async def update_item(
|
||||
)
|
||||
item.version += 1
|
||||
|
||||
if values.get("assignee_id") is not None:
|
||||
await notify(
|
||||
session,
|
||||
ws_id=ctx.ws_id,
|
||||
user_ids=(values["assignee_id"],),
|
||||
kind="task_assigned",
|
||||
item_id=item.id,
|
||||
title=f"「{item.title}」转给你了",
|
||||
body=f"{ctx.user.nickname or '同事'} 把这件事交给你,原来的办理人已经卸下",
|
||||
actor_id=ctx.user.id,
|
||||
)
|
||||
|
||||
tags = await _tags_of(session, ctx.ws_id, [item.id])
|
||||
return ok(_item_out(item, tags.get(item.id)), message="已保存")
|
||||
|
||||
@@ -623,6 +682,20 @@ async def change_status(
|
||||
payload={"from": previous, "to": body.status},
|
||||
)
|
||||
tags = await _tags_of(session, ctx.ws_id, [item.id])
|
||||
await notify(
|
||||
session,
|
||||
ws_id=ctx.ws_id,
|
||||
user_ids=(item.creator_id, item.assignee_id),
|
||||
kind="task_status_changed",
|
||||
item_id=item.id,
|
||||
title=f"「{item.title}」变成{TASK_STATUS_CN.get(body.status, body.status)}了",
|
||||
body=(
|
||||
f"{ctx.user.nickname or '同事'} 把它从 "
|
||||
f"{TASK_STATUS_CN.get(previous, previous)} 改成了 "
|
||||
f"{TASK_STATUS_CN.get(body.status, body.status)}"
|
||||
),
|
||||
actor_id=ctx.user.id,
|
||||
)
|
||||
return ok(_item_out(item, tags.get(item.id)), message="状态已更新")
|
||||
|
||||
|
||||
@@ -660,6 +733,16 @@ async def add_comment(
|
||||
session.add(comment)
|
||||
await session.flush()
|
||||
await _log(session, item, ctx.user.id, "commented", {"comment_id": str(comment.id)})
|
||||
await notify(
|
||||
session,
|
||||
ws_id=ctx.ws_id,
|
||||
user_ids=(item.creator_id, item.assignee_id),
|
||||
kind="item_commented",
|
||||
item_id=item.id,
|
||||
title=f"「{item.title}」有新讨论",
|
||||
body=f"{ctx.user.nickname or '同事'}:{body.body_md[:120]}",
|
||||
actor_id=ctx.user.id,
|
||||
)
|
||||
return ok(
|
||||
{
|
||||
"id": str(comment.id),
|
||||
|
||||