Compare commits
3
Commits
e25a1a4e31
...
3d02fe845a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d02fe845a | ||
|
|
6e33e1024d | ||
|
|
c4868f04cf |
+2
-1
@@ -19,6 +19,8 @@ htmlcov/
|
|||||||
node_modules/
|
node_modules/
|
||||||
web/dist/
|
web/dist/
|
||||||
web/.vite/
|
web/.vite/
|
||||||
|
*.tsbuildinfo
|
||||||
|
web/e2e/artifacts/
|
||||||
|
|
||||||
# 安卓
|
# 安卓
|
||||||
android/.gradle/
|
android/.gradle/
|
||||||
@@ -38,4 +40,3 @@ tmprfx*
|
|||||||
|
|
||||||
# 本机开发用的便携 PostgreSQL / 临时目录(不入库)
|
# 本机开发用的便携 PostgreSQL / 临时目录(不入库)
|
||||||
.local/
|
.local/
|
||||||
|
|
||||||
|
|||||||
+32
-3
@@ -173,18 +173,47 @@ canceled→ todo (恢复)
|
|||||||
**前端可以据此直接渲染"下一步能点什么",不用自己维护一份规则。**
|
**前端可以据此直接渲染"下一步能点什么",不用自己维护一份规则。**
|
||||||
目标状态与当前相同:直接返回,版本号不变。
|
目标状态与当前相同:直接返回,版本号不变。
|
||||||
|
|
||||||
### 3.5 `DELETE /items/{item_id}?version=3`
|
### 3.5 `POST /items/batch-status`(看板多选拖拽)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "done",
|
||||||
|
"items": [{ "id": "…", "version": 3 }, { "id": "…", "version": 7 }] }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "updated": [{ "id": "…", "version": 4, "unchanged": false }],
|
||||||
|
"failed": [{ "id": "…", "code": 4009, "message": "…", "current_version": 8 }] }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **不是全成功或全回滚**:拖 10 条里有 1 条被同事改过,其余 9 条照样生效。
|
||||||
|
全回滚会让用户白干一次,而且更难解释"为什么什么都没变"。
|
||||||
|
- 单次最多 50 条;`failed[].code` 只会是 `4009`(版本冲突)、`5002`(状态不允许)、`5001`(不存在)。
|
||||||
|
- 已经是目标状态的条目返回 `unchanged: true`,计入 `updated` 但不产生事件。
|
||||||
|
|
||||||
|
### 3.6 `GET /items/board`(看板一屏数据)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "columns": { "todo": [...], "doing": [...], "blocked": [...], "done": [...] },
|
||||||
|
"order": ["todo", "doing", "blocked", "done"],
|
||||||
|
"labels": { "todo": "待办", "doing": "进行中", "blocked": "被阻塞", "done": "已完成" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
- 每列默认 30 条(`per_column` 1–100),按"截止早的在前、无截止靠后",再按更新时间。
|
||||||
|
- **收件箱不在看板上**:没归类的东西不该占着"正在做的事"的位置。
|
||||||
|
- 一次请求拿全,避免拖拽时四列数据不同步。
|
||||||
|
|
||||||
|
### 3.7 `DELETE /items/{item_id}?version=3`
|
||||||
|
|
||||||
软删(写 `deleted_at`),只在 `view=deleted` / `include_deleted=true` 时可见。
|
软删(写 `deleted_at`),只在 `view=deleted` / `include_deleted=true` 时可见。
|
||||||
|
|
||||||
### 3.6 评论
|
### 3.8 评论
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/items/{item_id}/comments` | 时间正序,游标分页 |
|
| GET | `/items/{item_id}/comments` | 时间正序,游标分页 |
|
||||||
| POST | `/items/{item_id}/comments` | `{body_md}` |
|
| POST | `/items/{item_id}/comments` | `{body_md}` |
|
||||||
|
|
||||||
### 3.7 标签 `/workspaces/{ws_id}/tags`
|
### 3.9 标签 `/workspaces/{ws_id}/tags`
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
+48
-2
@@ -78,7 +78,53 @@ ruff check . && ruff format --check .
|
|||||||
|
|
||||||
隔离测试(`tests/test_tenant_isolation.py`)是本仓库最重要的一组用例,改动任何租户表都必须重跑它。
|
隔离测试(`tests/test_tenant_isolation.py`)是本仓库最重要的一组用例,改动任何租户表都必须重跑它。
|
||||||
|
|
||||||
## 7. 常见坑
|
### 真浏览器 e2e
|
||||||
|
|
||||||
|
需要后端(8100)与前端(5180)都在跑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
node e2e/smoke.mjs # 登录→建任务→详情保存→看板拖拽→非法流转被拒,7 项断言
|
||||||
|
```
|
||||||
|
|
||||||
|
用本机缓存的 Chromium(`playwright-core`,不下载浏览器),截图落在 `web/e2e/artifacts/`。
|
||||||
|
|
||||||
|
### 1000 条下的首屏耗时
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && python scripts/bench_views.py # 连开发库,会自己造 1000 条
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 知识库向量库(独立实例)
|
||||||
|
|
||||||
|
知识库的语义检索需要一个带 **pgvector** 的 PostgreSQL,它**不是**业务库:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd deploy
|
||||||
|
docker-compose -f docker-compose.vector.yml up -d # 127.0.0.1:5434,库名 dadao_knowledge
|
||||||
|
```
|
||||||
|
|
||||||
|
然后在 `backend/.env` 里打开:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VECTOR_DATABASE_URL=postgresql+asyncpg://dadao_app:dadao_dev_pw@127.0.0.1:5434/dadao_knowledge
|
||||||
|
```
|
||||||
|
|
||||||
|
三条设计约束(别绕):
|
||||||
|
|
||||||
|
1. **不碰 fnsvr-01**。那台 PG 17 没有 pgvector,装扩展要动生产机;向量检索吃内存,
|
||||||
|
和任务库挤一起互相拖累。
|
||||||
|
2. **不配就是没有**。`VECTOR_DATABASE_URL` 为空时,M3 的语义检索直接报"未配置",
|
||||||
|
不许悄悄退化成"全表扫描 + 假装是语义匹配"——那种降级没人能发现。
|
||||||
|
3. **向量表同样开 RLS**。知识库串租户比任务串租户严重得多:等于把别人的文档喂给你的模型。
|
||||||
|
|
||||||
|
`EMBEDDING_DIM` 一旦定下来改起来很痛(要重算全部切片),默认 `1024`(bge-m3)。
|
||||||
|
换模型 = 重建 `chunk` 表 + 重新向量化,写进 M3 的迁移说明。
|
||||||
|
|
||||||
|
> 当前这台机器 Docker 起不来(见下),所以向量库还没真正跑起来,
|
||||||
|
> 上面是配置与脚本,M3 开工前必须先把它跑通。
|
||||||
|
|
||||||
|
## 8. 常见坑
|
||||||
|
|
||||||
| 现象 | 原因 | 处理 |
|
| 现象 | 原因 | 处理 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -127,7 +173,7 @@ ruff check . && ruff format --check .
|
|||||||
- **安装类命令一律写日志文件再 tail**,不要用管道截断输出,否则看不到进度也不知道卡在哪。
|
- **安装类命令一律写日志文件再 tail**,不要用管道截断输出,否则看不到进度也不知道卡在哪。
|
||||||
- `npm install` 要加 `--ignore-scripts`:沙箱不允许 npm 的 lifecycle 脚本派生进程(`EPERM spawn`)。
|
- `npm install` 要加 `--ignore-scripts`:沙箱不允许 npm 的 lifecycle 脚本派生进程(`EPERM spawn`)。
|
||||||
|
|
||||||
## 8. 提交前自检清单
|
## 9. 提交前自检清单
|
||||||
|
|
||||||
1. `pytest` 全绿,`ruff` 无告警。
|
1. `pytest` 全绿,`ruff` 无告警。
|
||||||
2. 新增租户表时:`workspace_id NOT NULL` + RLS 策略 + 跨租户测试,三者齐全。
|
2. 新增租户表时:`workspace_id NOT NULL` + RLS 策略 + 跨租户测试,三者齐全。
|
||||||
|
|||||||
+30
-19
@@ -128,8 +128,9 @@
|
|||||||
| 外网 TLS | 被拦:`schannel SEC_E_NO_CREDENTIALS`、`基础连接已经关闭` | 走 HTTP 镜像:PyPI 用 `http://mirrors.aliyun.com/pypi/simple/`,npm 用 `http://mirrors.huaweicloud.com/repository/npm/` |
|
| 外网 TLS | 被拦:`schannel SEC_E_NO_CREDENTIALS`、`基础连接已经关闭` | 走 HTTP 镜像:PyPI 用 `http://mirrors.aliyun.com/pypi/simple/`,npm 用 `http://mirrors.huaweicloud.com/repository/npm/` |
|
||||||
| pip 缓存 | 装包时写缓存会**卡死**(无输出、CPU 空转) | 必须加 `--no-cache-dir`;装包命令输出要重定向到文件再 tail,不要管道截断 |
|
| pip 缓存 | 装包时写缓存会**卡死**(无输出、CPU 空转) | 必须加 `--no-cache-dir`;装包命令输出要重定向到文件再 tail,不要管道截断 |
|
||||||
| `pg_ctl` | `could not create restricted token: error code 87` | 直接跑 `postgres.exe`,且必须放在**后台作业**里(父进程退出会连带杀死) |
|
| `pg_ctl` | `could not create restricted token: error code 87` | 直接跑 `postgres.exe`,且必须放在**后台作业**里(父进程退出会连带杀死) |
|
||||||
| fnsvr-01 (192.168.8.160) | 只开放 8418(Gitea) / 3306(MySQL);5432/5433/22 全关 | 该机没有可用的 PostgreSQL 对外端口;SSH 也不通 |
|
| fnsvr-01 (192.168.8.160) | 只开放 8418(Gitea) / 3306(MySQL) / **8432(PG 17.6,dbadmin/superuser)** | 用途定死:只有 `dadaotask` 与 `dadaotask_test` 两个库,**不再往上装扩展**;能力:无 pgvector,只有 pg_trgm/pgcrypto |
|
||||||
| Android | 本机无 Android SDK、无 gradle-wrapper.jar | 交给 Android Studio 打开 `android/` 自动补全 |
|
| Android | **无 JDK、无 Gradle**(`java -version`/`gradle -v` 都不存在),SDK 平台目录在但缺构建链 | 装 JDK 17 + Gradle 8.x 后 `./gradlew assembleDebug`;当前安卓代码未被编译器验证过 |
|
||||||
|
| 向量库 | Docker 起不来 → `deploy/docker-compose.vector.yml` 写了但没跑过 | 等 Docker 可用(或换一台机器)再 `up -d`,端口 5434 |
|
||||||
|
|
||||||
便携 PG 起法(本机专用):
|
便携 PG 起法(本机专用):
|
||||||
|
|
||||||
@@ -164,39 +165,49 @@
|
|||||||
- [x] 增删改查(`backend/app/api/items.py`)、`POST /{id}/status`、软删(带 version)
|
- [x] 增删改查(`backend/app/api/items.py`)、`POST /{id}/status`、软删(带 version)
|
||||||
- [x] 视图:`inbox|active|today|week|done|canceled|all|deleted`;`today/week` 按 `Asia/Shanghai` 切日界(`settings.default_timezone`),不按 UTC
|
- [x] 视图:`inbox|active|today|week|done|canceled|all|deleted`;`today/week` 按 `Asia/Shanghai` 切日界(`settings.default_timezone`),不按 UTC
|
||||||
- [x] 过滤:`status`(可重复)、`assignee=me|<uuid>`、`tag`、`q`(标题+正文 ILIKE,实测中文 `周报` 命中)
|
- [x] 过滤:`status`(可重复)、`assignee=me|<uuid>`、`tag`、`q`(标题+正文 ILIKE,实测中文 `周报` 命中)
|
||||||
- [ ] **批量状态变更**未做(M1 先做单条,批量等看板拖拽一起上)
|
- [x] **批量状态变更**(`POST /items/batch-status`,逐条返回成败,不做全回滚)+ 看板数据 `GET /items/board`
|
||||||
- [~] 搜索返回高亮片段:未做,只返回命中行。理由:高亮要在客户端按 query 二次切词,纯展示逻辑,等 Web 有真实使用反馈再补
|
- [~] 搜索返回高亮片段:未做,只返回命中行。理由:高亮要在客户端按 query 二次切词,纯展示逻辑,等 Web 有真实使用反馈再补
|
||||||
- [x] 分页:`(sort_key DESC, id DESC)` keyset 游标;`sort=due` 不分页(NULLS LAST 会把 NULL 行切碎,只有今日/逾期短列表用)
|
- [x] 分页:`(sort_key DESC, id DESC)` keyset 游标;`sort=due` 不分页(NULLS LAST 会把 NULL 行切碎,只有今日/逾期短列表用)
|
||||||
|
|
||||||
### 1.4 Web(3d)
|
### 1.4 Web(3d)
|
||||||
- [x] 视图 tab(今天/进行中/收件箱/已完成/全部)+ 搜索框 + 回车快速新建(`web/src/pages/Tasks.tsx`)
|
- [x] 视图 tab(今天/进行中/收件箱/已完成/全部)+ 搜索框 + 回车快速新建(`web/src/pages/Tasks.tsx`)
|
||||||
- [x] 勾选完成/重开、`收下`(inbox→todo)、`开工`(todo→doing)、`阻塞`
|
- [x] 勾选完成/重开、`收下`(inbox→todo)、`开工`(todo→doing)、`阻塞`
|
||||||
- [x] 点标题展开:正文、标签、最近 8 条事件流;`409` 时提示"刚被别人改过"并重载
|
- [x] **看板拖拽**(`web/src/pages/Board.tsx`):HTML5 拖放,四列,非法流转当场拦下并说明原因;勾选后可批量移动
|
||||||
- [x] 加载更多(游标),空态文案("今天没有到期的事,挺好。")
|
- [x] **详情侧栏可编辑**(`web/src/components/TaskDetail.tsx`):标题/优先级/截止时间/标签/备注/评论,全部落库;409 时自动刷新为最新内容
|
||||||
- [x] `npm run build` 通过:38 modules,`183.26 kB │ gzip 60.08 kB`
|
- [x] 快速录入语法:`#标签` `!高/!中/!低/!1..!3`(`web/src/lib/parse.ts`,语法从标题里剥离)
|
||||||
- [ ] 看板拖拽(列 = 状态)未做
|
- [x] 键盘流:`j/k` 选择、`Enter` 完成、`e` 编辑、`Esc` 关闭
|
||||||
- [ ] 详情侧栏(优先级/截止日期选择器/评论输入/子任务)未做——当前详情只读
|
- [x] `npm run build` 通过:41 modules,`195.20 kB │ gzip 63.73 kB`
|
||||||
- [ ] 全局快速录入 `#标签 !优先级 @人` 语法未做
|
- [~] `@人` 指派:未做。理由:重名的人比标签多,悄悄指错负责人比不支持更糟;等 M2 有成员选择器再做
|
||||||
- [ ] 键盘流 `j/k`、`Enter`、`e` 未做
|
- [~] 子任务(一层):未做,排到 M2 与工单的 `item_link` 一起做——两者本质是同一种"事项之间的边"
|
||||||
|
|
||||||
### 1.5 安卓(1.5d)
|
### 1.5 安卓(1.5d)
|
||||||
- [ ] 任务列表 + 详情 + 勾选完成(`android/` 目前只有 M0 的登录/工作区壳)
|
- [x] 任务列表 + 详情弹窗 + 勾选完成 + 快速新建(`android/.../ui/TaskListScreen.kt`)
|
||||||
|
- [x] 视图切换(今天/进行中/收件箱/已完成)、乐观锁冲突提示与自动刷新
|
||||||
|
- [ ] **未编译**:本机没有 JDK、没有 Gradle(`java -version` / `gradle -v` 均不存在),
|
||||||
|
Android SDK 平台目录存在但缺构建工具链。代码按 Compose 常规写法写,**尚未经过编译器验证**。
|
||||||
|
补 JDK 17 + Gradle 后第一件事就是 `./gradlew assembleDebug`,那时的报错才算数。
|
||||||
|
- [ ] 下拉刷新、只读缓存(Room)未做
|
||||||
|
|
||||||
### 1.6 通知与提醒(0.8d)
|
### 1.6 通知与提醒(0.8d)
|
||||||
- [ ] 站内通知表与未读角标(表在 M2 随工单一起建)
|
- [ ] 站内通知表与未读角标 —— **主动推迟到 M2**:通知的绝大多数触发源是工单(被指派、被催、验收),
|
||||||
- [ ] 每日摘要邮件
|
M1 只有自己的任务,做出来也没人收到东西。表随 M2 的工单一起建,避免建两次。
|
||||||
|
- [ ] 每日摘要邮件(同上推迟)
|
||||||
|
|
||||||
### 1.7 测试与验收(0.7d)
|
### 1.7 测试与验收(0.7d)
|
||||||
- [x] `backend/tests/test_items.py` 10 个用例:默认收件箱、状态机 happy path 与非法流转、版本冲突、跨租户 3003 且他人列表为空、搜索+标签、`today` 时区边界、软删、评论与事件流、游标翻页不重复
|
- [x] `backend/tests/test_items.py` 13 个用例:默认收件箱、状态机 happy path 与非法流转、版本冲突、
|
||||||
- [x] 全量 `pytest -q` → **43 passed**;`ruff check` + `ruff format --check` 全 0
|
跨租户 3003 且他人列表为空、搜索+标签、`today` 时区边界、软删、评论与事件流、游标翻页、
|
||||||
- [ ] Playwright e2e 未做
|
批量改状态的部分成功、批量非法流转、看板分列
|
||||||
- [ ] 性能口径(1000 条下首屏 < 300ms)未实测
|
- [x] 全量 `pytest -q` → **46 passed**;`ruff check` + `ruff format --check` 全 0
|
||||||
|
- [x] **Playwright 真浏览器 e2e**(`web/e2e/smoke.mjs`,7 项断言全绿):登录 → 建工作区 → 快速录入解析
|
||||||
|
`#标签`/`!优先级` → 详情抽屉保存并回写列表 → 看板拖拽改状态 → 非法拖拽被拦 → 控制台无报错
|
||||||
|
- [x] 性能:1000 条任务下 P50 —— 今天 43.1ms / 进行中 42.4ms / 收件箱 42.9ms / 全部 38.4ms /
|
||||||
|
搜索 45.0ms / 看板 55.4ms(含本机网络与鉴权中间件,是**下限**)
|
||||||
|
|
||||||
### M1 完成定义(DOD)
|
### M1 完成定义(DOD)
|
||||||
- [ ] 连续 3 天所有待办都记在里面,没有回到微信/备忘录 —— 只有真实使用能证伪,代码侧无法替代
|
- [ ] 连续 3 天所有待办都记在里面,没有回到微信/备忘录 —— 只有真实使用能证伪,代码侧无法替代
|
||||||
- [ ] 冷启动到"建完第一个任务"< 90 秒(新账号实测)
|
- [ ] 冷启动到"建完第一个任务"< 90 秒(新账号实测)
|
||||||
- [ ] 1000 条任务下四视图首屏 < 300ms
|
- [x] 1000 条任务下四视图首屏 < 300ms —— 实测 P50 38–55ms,最慢的看板 55.4ms
|
||||||
- [x] 跨租户测试、越权测试全绿(`test_tenant_isolation.py` + `test_items.py::test_cross_tenant_access_denied`)
|
- [x] 跨租户测试、越权测试全绿(`test_tenant_isolation.py` + `test_items.py` 的跨租户与批量用例)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,11 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 手机号验证码登录、刷新令牌、幂等请求 | ✅ 已实现并有测试 |
|
| 手机号验证码登录、刷新令牌、幂等请求 | ✅ 已实现并有测试 |
|
||||||
| 工作区 / 成员 / 邀请码加入,跨租户隔离(RLS) | ✅ 同上 |
|
| 工作区 / 成员 / 邀请码加入,跨租户隔离(RLS) | ✅ 同上 |
|
||||||
| 任务:收件箱→待办→进行中→完成、截止时间、标签、评论、事件流、乐观锁 | ✅ 后端 + Web 已通,43 个测试全绿 |
|
| 任务:收件箱→待办→进行中→完成、截止时间、标签、评论、事件流、乐观锁 | ✅ 后端 + Web 已通,46 个测试全绿 |
|
||||||
| Web 构建 | ✅ `npm run build` 通过 |
|
| Web 列表 / 看板拖拽 / 详情侧栏编辑 / 键盘流 / 快速录入语法 | ✅ e2e 7 项断言全绿,构建通过 |
|
||||||
|
| 1000 条任务下的首屏性能 | ✅ 实测 P50 38–55ms(目标 < 300ms) |
|
||||||
|
| 安卓端任务列表与详情 | ⚠️ 代码已写,**本机无 JDK/Gradle,未编译验证** |
|
||||||
| 工单、知识库、LLM 能力 | ⬜ M2–M4 |
|
| 工单、知识库、LLM 能力 | ⬜ M2–M4 |
|
||||||
| 安卓端任务列表 | ⬜ |
|
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import com.dadao.task.data.Api
|
import com.dadao.task.data.Api
|
||||||
import com.dadao.task.data.TokenStore
|
import com.dadao.task.data.TokenStore
|
||||||
|
import com.dadao.task.data.Workspace
|
||||||
import com.dadao.task.ui.DadaoTheme
|
import com.dadao.task.ui.DadaoTheme
|
||||||
import com.dadao.task.ui.HomeScreen
|
import com.dadao.task.ui.HomeScreen
|
||||||
import com.dadao.task.ui.LoginScreen
|
import com.dadao.task.ui.LoginScreen
|
||||||
|
import com.dadao.task.ui.TaskListScreen
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
@@ -33,7 +35,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum class Screen { Splash, Login, Home }
|
private enum class Screen { Splash, Login, Home, Tasks }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AppRoot() {
|
private fun AppRoot() {
|
||||||
@@ -41,6 +43,7 @@ private fun AppRoot() {
|
|||||||
val tokens = remember { TokenStore(context) }
|
val tokens = remember { TokenStore(context) }
|
||||||
val api = remember { Api(tokens) }
|
val api = remember { Api(tokens) }
|
||||||
var screen by remember { mutableStateOf(if (tokens.isLoggedIn) Screen.Splash else Screen.Login) }
|
var screen by remember { mutableStateOf(if (tokens.isLoggedIn) Screen.Splash else Screen.Login) }
|
||||||
|
var current by remember { mutableStateOf<Workspace?>(null) }
|
||||||
|
|
||||||
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
|
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
|
||||||
LaunchedEffect(screen) {
|
LaunchedEffect(screen) {
|
||||||
@@ -64,6 +67,18 @@ private fun AppRoot() {
|
|||||||
tokens.clear()
|
tokens.clear()
|
||||||
screen = Screen.Login
|
screen = Screen.Login
|
||||||
},
|
},
|
||||||
|
onOpenWorkspace = { ws ->
|
||||||
|
current = ws
|
||||||
|
screen = Screen.Tasks
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
Screen.Tasks -> {
|
||||||
|
val ws = current
|
||||||
|
if (ws == null) {
|
||||||
|
screen = Screen.Home
|
||||||
|
} else {
|
||||||
|
TaskListScreen(api = api, workspace = ws, onBack = { screen = Screen.Home })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ private data class Envelope<T>(
|
|||||||
// ---- 与 backend/app/api/schemas.py 一一对应 ----
|
// ---- 与 backend/app/api/schemas.py 一一对应 ----
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class User(val id: String, val phone: String, @SerialName("display_name") val displayName: String)
|
data class User(
|
||||||
|
val id: String,
|
||||||
|
val phone: String,
|
||||||
|
val nickname: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Tokens(
|
data class Tokens(
|
||||||
@@ -61,10 +65,62 @@ data class MeResult(val user: User, val workspaces: List<Workspace> = emptyList(
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Member(
|
data class Member(
|
||||||
val userId: String,
|
val id: String = "",
|
||||||
@SerialName("display_name") val displayName: String,
|
@SerialName("user_id") val userId: String = "",
|
||||||
val role: String,
|
val role: String = "",
|
||||||
|
val status: String = "",
|
||||||
|
val nickname: String = "",
|
||||||
|
val phone: String = "",
|
||||||
|
@SerialName("joined_at") val joinedAt: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Tag(val id: String, val name: String, val color: String? = null)
|
||||||
|
|
||||||
|
/** 与 backend/app/api/items.py 的 `_item_out` 对齐。 */
|
||||||
|
@Serializable
|
||||||
|
data class Item(
|
||||||
|
val id: String,
|
||||||
|
val kind: String = "task",
|
||||||
|
val title: String,
|
||||||
|
@SerialName("body_md") val bodyMd: String = "",
|
||||||
val status: String,
|
val status: String,
|
||||||
|
val priority: Int = 1,
|
||||||
|
@SerialName("creator_id") val creatorId: String = "",
|
||||||
|
@SerialName("assignee_id") val assigneeId: String? = null,
|
||||||
|
@SerialName("due_at") val dueAt: String? = null,
|
||||||
|
@SerialName("completed_at") val completedAt: String? = null,
|
||||||
|
val tags: List<Tag> = emptyList(),
|
||||||
|
@SerialName("version") val version: Int = 1,
|
||||||
|
) {
|
||||||
|
val isDone: Boolean get() = status == "done"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ItemPage(
|
||||||
|
val items: List<Item> = emptyList(),
|
||||||
|
@SerialName("next_cursor") val nextCursor: String? = null,
|
||||||
|
@SerialName("has_more") val hasMore: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Activity(
|
||||||
|
val id: String,
|
||||||
|
val action: String,
|
||||||
|
@SerialName("created_at") val createdAt: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ItemDetail(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
@SerialName("body_md") val bodyMd: String = "",
|
||||||
|
val status: String,
|
||||||
|
val priority: Int = 1,
|
||||||
|
@SerialName("due_at") val dueAt: String? = null,
|
||||||
|
val tags: List<Tag> = emptyList(),
|
||||||
|
val version: Int = 1,
|
||||||
|
val activities: List<Activity> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -132,6 +188,37 @@ class Api(private val tokens: TokenStore) {
|
|||||||
auth = true,
|
auth = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
suspend fun items(workspaceId: String, view: String = "active", query: String? = null): ItemPage {
|
||||||
|
val params = buildString {
|
||||||
|
append("?view=").append(view).append("&limit=30")
|
||||||
|
if (!query.isNullOrBlank()) append("&q=").append(java.net.URLEncoder.encode(query, "UTF-8"))
|
||||||
|
}
|
||||||
|
return call(path = "/workspaces/$workspaceId/items$params", auth = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createItem(workspaceId: String, title: String, status: String = "inbox"): Item = call(
|
||||||
|
path = "/workspaces/$workspaceId/items",
|
||||||
|
body = json.encodeToString(mapOf("title" to title, "status" to status)),
|
||||||
|
auth = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 改状态必须带 version:服务端用 `UPDATE ... WHERE version = ?` 防并发覆盖。 */
|
||||||
|
suspend fun setStatus(workspaceId: String, itemId: String, status: String, version: Int): Item =
|
||||||
|
call(
|
||||||
|
path = "/workspaces/$workspaceId/items/$itemId/status",
|
||||||
|
body = json.encodeToString(mapOf("status" to status, "version" to version)),
|
||||||
|
auth = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun itemDetail(workspaceId: String, itemId: String): ItemDetail =
|
||||||
|
call(path = "/workspaces/$workspaceId/items/$itemId", auth = true)
|
||||||
|
|
||||||
|
suspend fun addComment(workspaceId: String, itemId: String, body: String): Activity = call(
|
||||||
|
path = "/workspaces/$workspaceId/items/$itemId/comments",
|
||||||
|
body = json.encodeToString(mapOf("body_md" to body)),
|
||||||
|
auth = true,
|
||||||
|
)
|
||||||
|
|
||||||
private suspend inline fun <reified T> call(
|
private suspend inline fun <reified T> call(
|
||||||
path: String,
|
path: String,
|
||||||
body: String? = null,
|
body: String? = null,
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ private val ROLE_LABEL = mapOf(
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(api: Api, onLogout: () -> Unit) {
|
fun HomeScreen(api: Api, onLogout: () -> Unit, onOpenWorkspace: (Workspace) -> Unit) {
|
||||||
val workspaces = remember { mutableStateListOf<Workspace>() }
|
val workspaces = remember { mutableStateListOf<Workspace>() }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
@@ -95,16 +95,7 @@ fun HomeScreen(api: Api, onLogout: () -> Unit) {
|
|||||||
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
items(workspaces, key = { it.id }) { ws ->
|
items(workspaces, key = { it.id }) { ws ->
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth().clickable {
|
modifier = Modifier.fillMaxWidth().clickable { onOpenWorkspace(ws) },
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
val inv = api.createInvitation(ws.id)
|
|
||||||
invitation = "${ws.name}\n邀请码 ${inv.code}\n有效期至 ${inv.expiresAt}\n\n分享链接:/join/${ws.id}/${inv.code}"
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
error = e.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = CardDefaults.cardColors(),
|
colors = CardDefaults.cardColors(),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@@ -120,11 +111,23 @@ fun HomeScreen(api: Api, onLogout: () -> Unit) {
|
|||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
ROLE_LABEL[ws.role] ?: (ws.role ?: ""),
|
Text(
|
||||||
style = MaterialTheme.typography.labelMedium,
|
ROLE_LABEL[ws.role] ?: (ws.role ?: ""),
|
||||||
color = Accent,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
)
|
color = Accent,
|
||||||
|
)
|
||||||
|
TextButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val inv = api.createInvitation(ws.id)
|
||||||
|
invitation = "${ws.name}\n邀请码 ${inv.code}\n有效期至 ${inv.expiresAt}\n\n分享链接:/join/${ws.id}/${inv.code}"
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
error = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) { Text("邀请") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package com.dadao.task.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
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.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
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.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.dadao.task.data.Api
|
||||||
|
import com.dadao.task.data.ApiException
|
||||||
|
import com.dadao.task.data.Item
|
||||||
|
import com.dadao.task.data.Workspace
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private val VIEWS = listOf(
|
||||||
|
"today" to "今天",
|
||||||
|
"active" to "进行中",
|
||||||
|
"inbox" to "收件箱",
|
||||||
|
"done" to "已完成",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val STATUS_LABEL = mapOf(
|
||||||
|
"inbox" to "收件箱",
|
||||||
|
"todo" to "待办",
|
||||||
|
"doing" to "进行中",
|
||||||
|
"done" to "已完成",
|
||||||
|
"blocked" to "被阻塞",
|
||||||
|
"canceled" to "已取消",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务列表。
|
||||||
|
*
|
||||||
|
* 勾选 = 完成/重开,都带 `version` 回传:手机端最常见的是"在地铁上点了一下,
|
||||||
|
* 旁边同事也在改",没有乐观锁就会静默覆盖对方。
|
||||||
|
* 409 时把服务端的最新内容拉回来,而不是让界面停在错误的勾选状态上。
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun TaskListScreen(
|
||||||
|
api: Api,
|
||||||
|
workspace: Workspace,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
val items = remember { mutableStateListOf<Item>() }
|
||||||
|
var view by remember { mutableStateOf("today") }
|
||||||
|
var draft by remember { mutableStateOf("") }
|
||||||
|
var loading by remember { mutableStateOf(true) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var detailId by remember { mutableStateOf<String?>(null) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
suspend fun reload() {
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
try {
|
||||||
|
val page = api.items(workspace.id, view)
|
||||||
|
items.clear()
|
||||||
|
items.addAll(page.items)
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
error = e.message
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(view) { reload() }
|
||||||
|
|
||||||
|
suspend fun toggle(item: Item) {
|
||||||
|
val target = if (item.status == "done") "todo" else "done"
|
||||||
|
try {
|
||||||
|
val updated = api.setStatus(workspace.id, item.id, target, item.version)
|
||||||
|
val index = items.indexOfFirst { it.id == updated.id }
|
||||||
|
if (index >= 0) items[index] = updated
|
||||||
|
if (target == "done" && view != "done") items.removeAll { it.id == updated.id }
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
error = if (e.code == 4009) "这条刚被别人改过,已刷新" else e.message
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun add() {
|
||||||
|
val title = draft.trim()
|
||||||
|
if (title.isEmpty()) return
|
||||||
|
try {
|
||||||
|
val created = api.createItem(workspace.id, title, if (view == "inbox") "inbox" else "todo")
|
||||||
|
draft = ""
|
||||||
|
if (view != "done") items.add(0, created)
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
error = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(workspace.name) },
|
||||||
|
navigationIcon = { TextButton(onClick = onBack) { Text("返回") } },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
VIEWS.forEach { (key, label) ->
|
||||||
|
FilterChip(
|
||||||
|
selected = view == key,
|
||||||
|
onClick = { view = key },
|
||||||
|
label = { Text(label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = draft,
|
||||||
|
onValueChange = { draft = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
placeholder = { Text("加一件要做的事,回车创建") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions = KeyboardActions(onDone = { scope.launch { add() } }),
|
||||||
|
)
|
||||||
|
|
||||||
|
error?.let {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
when {
|
||||||
|
loading -> Text("加载中…", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
items.isEmpty() -> Text(
|
||||||
|
if (view == "today") "今天没有到期的事,挺好。" else "这里还空着。",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
items(items, key = { it.id }) { item ->
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = item.isDone,
|
||||||
|
onCheckedChange = { scope.launch { toggle(item) } },
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
Modifier.weight(1f).clickable { detailId = item.id },
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
item.title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textDecoration = if (item.isDone) TextDecoration.LineThrough else null,
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
STATUS_LABEL[item.status] ?: item.status,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Accent,
|
||||||
|
)
|
||||||
|
item.dueAt?.let {
|
||||||
|
Text(
|
||||||
|
it.take(10),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (item.tags.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
item.tags.joinToString(" ") { "#${it.name}" },
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
detailId?.let { id ->
|
||||||
|
var detail by remember(id) { mutableStateOf<String?>(null) }
|
||||||
|
var comment by remember(id) { mutableStateOf("") }
|
||||||
|
LaunchedEffect(id) {
|
||||||
|
detail = try {
|
||||||
|
val d = api.itemDetail(workspace.id, id)
|
||||||
|
buildString {
|
||||||
|
appendLine(d.title)
|
||||||
|
appendLine()
|
||||||
|
appendLine("状态:${STATUS_LABEL[d.status] ?: d.status} 优先级:${d.priority}")
|
||||||
|
d.dueAt?.let { appendLine("截止:${it.take(16).replace('T', ' ')}") }
|
||||||
|
if (d.tags.isNotEmpty()) appendLine("标签:${d.tags.joinToString(" ") { "#${it.name}" }}")
|
||||||
|
if (d.bodyMd.isNotBlank()) {
|
||||||
|
appendLine()
|
||||||
|
append(d.bodyMd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { detailId = null },
|
||||||
|
title = { Text("任务详情") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(detail ?: "加载中…", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = comment,
|
||||||
|
onValueChange = { comment = it },
|
||||||
|
label = { Text("补充一句进展") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
enabled = comment.isNotBlank(),
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
api.addComment(workspace.id, id, comment.trim())
|
||||||
|
comment = ""
|
||||||
|
detailId = null
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
error = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { Text("发表") }
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = { detailId = null }) { Text("关闭") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,3 +31,10 @@ SMS_DEV_CODE=123456
|
|||||||
SMS_THROTTLE_SECONDS=60
|
SMS_THROTTLE_SECONDS=60
|
||||||
|
|
||||||
CORS_ORIGINS=http://localhost:5180,http://127.0.0.1:5180
|
CORS_ORIGINS=http://localhost:5180,http://127.0.0.1:5180
|
||||||
|
|
||||||
|
# ── 知识库向量库(独立的 PG + pgvector,M3 起用)──────────────────────
|
||||||
|
# 单独实例,不动 fnsvr-01:那台没有 pgvector,且向量检索吃内存,不该和业务库挤。
|
||||||
|
# 起法见 deploy/docker-compose.vector.yml。不配则 M3 的语义检索能力直接不可用。
|
||||||
|
# VECTOR_DATABASE_URL=postgresql+asyncpg://dadao_app:dadao_dev_pw@127.0.0.1:5434/dadao_knowledge
|
||||||
|
# EMBEDDING_DIM=1024
|
||||||
|
# EMBEDDING_MODEL=BAAI/bge-m3
|
||||||
|
|||||||
@@ -361,6 +361,60 @@ async def list_items(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/board", summary="看板:按状态分列的最近任务")
|
||||||
|
async def board(
|
||||||
|
ctx: MemberDep,
|
||||||
|
session: SessionDep,
|
||||||
|
per_column: Annotated[int, Query(ge=1, le=100)] = 30,
|
||||||
|
) -> dict:
|
||||||
|
"""一屏拖动需要的数据,一次拿全。
|
||||||
|
|
||||||
|
每列按"截止时间早的在前、没有截止时间的靠后"排,其次按更新时间。
|
||||||
|
只取 `per_column` 条:看板是"手上正在做的",不是全量列表。
|
||||||
|
"""
|
||||||
|
columns: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
statuses = ("todo", "doing", "blocked", "done")
|
||||||
|
rows = (
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Item)
|
||||||
|
.where(
|
||||||
|
Item.workspace_id == ctx.ws_id,
|
||||||
|
Item.status.in_(statuses),
|
||||||
|
Item.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
Item.due_at.asc().nulls_last(),
|
||||||
|
Item.updated_at.desc(),
|
||||||
|
Item.id.desc(),
|
||||||
|
)
|
||||||
|
.limit(per_column * len(statuses))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
by_id = {row.id: row for row in rows}
|
||||||
|
tag_map = await _tags_of(session, ctx.ws_id, list(by_id))
|
||||||
|
for status in statuses:
|
||||||
|
columns[status] = [
|
||||||
|
_item_out(row, tag_map.get(row.id)) for row in rows if row.status == status
|
||||||
|
][:per_column]
|
||||||
|
return ok(
|
||||||
|
{
|
||||||
|
"columns": columns,
|
||||||
|
"order": list(statuses),
|
||||||
|
"labels": {
|
||||||
|
"todo": "待办",
|
||||||
|
"doing": "进行中",
|
||||||
|
"blocked": "被阻塞",
|
||||||
|
"done": "已完成",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{item_id}", summary="任务详情(含最近 50 条事件)")
|
@router.get("/{item_id}", summary="任务详情(含最近 50 条事件)")
|
||||||
async def get_item(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
|
async def get_item(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
|
||||||
item = await _load_item(session, ctx.ws_id, item_id)
|
item = await _load_item(session, ctx.ws_id, item_id)
|
||||||
@@ -423,6 +477,85 @@ async def create_item(body: CreateItemIn, ctx: MemberDep, session: SessionDep) -
|
|||||||
return ok(_item_out(item, tags.get(item.id)), message="已创建")
|
return ok(_item_out(item, tags.get(item.id)), message="已创建")
|
||||||
|
|
||||||
|
|
||||||
|
class BatchStatusItemIn(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
version: int
|
||||||
|
|
||||||
|
|
||||||
|
class BatchStatusIn(BaseModel):
|
||||||
|
status: str
|
||||||
|
items: list[BatchStatusItemIn] = Field(min_length=1, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch-status", summary="批量改状态(逐条返回成败)")
|
||||||
|
async def batch_status(body: BatchStatusIn, ctx: MemberDep, session: SessionDep) -> dict:
|
||||||
|
"""看板多选拖拽用。
|
||||||
|
|
||||||
|
不做"全成功或全回滚":拖 10 条里有 1 条被同事改过,其余 9 条应当照样生效,
|
||||||
|
失败的那条单独告诉前端原因。全回滚会让用户白干一次,而且更难解释。
|
||||||
|
"""
|
||||||
|
if body.status not in ALL_STATUSES:
|
||||||
|
raise AppError(ErrorCode.BAD_REQUEST, f"未知状态:{body.status}")
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
updated: list[dict[str, Any]] = []
|
||||||
|
failed: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for entry in body.items:
|
||||||
|
try:
|
||||||
|
item = await _load_item(session, ctx.ws_id, entry.id)
|
||||||
|
except AppError as exc:
|
||||||
|
failed.append({"id": str(entry.id), "code": int(exc.code), "message": exc.message})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if item.status == body.status:
|
||||||
|
updated.append({"id": str(item.id), "version": item.version, "unchanged": True})
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
ensure_transition(item.status, body.status)
|
||||||
|
except AppError as exc:
|
||||||
|
failed.append(
|
||||||
|
{
|
||||||
|
"id": str(entry.id),
|
||||||
|
"code": int(exc.code),
|
||||||
|
"message": exc.message,
|
||||||
|
"allowed": (exc.details or {}).get("allowed"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
previous = item.status
|
||||||
|
fields: dict[str, Any] = {"status": body.status, "completed_at": None}
|
||||||
|
apply_status(fields, body.status, now=now)
|
||||||
|
try:
|
||||||
|
item = await _apply_update(
|
||||||
|
session,
|
||||||
|
item,
|
||||||
|
version=entry.version,
|
||||||
|
values={"status": fields["status"], "completed_at": fields["completed_at"]},
|
||||||
|
actor_id=ctx.user.id,
|
||||||
|
action="status_changed",
|
||||||
|
payload={"from": previous, "to": body.status, "batch": True},
|
||||||
|
)
|
||||||
|
except AppError as exc:
|
||||||
|
failed.append(
|
||||||
|
{
|
||||||
|
"id": str(entry.id),
|
||||||
|
"code": int(exc.code),
|
||||||
|
"message": exc.message,
|
||||||
|
"current_version": (exc.details or {}).get("current_version"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
updated.append({"id": str(item.id), "version": item.version, "unchanged": False})
|
||||||
|
|
||||||
|
return ok(
|
||||||
|
{"updated": updated, "failed": failed},
|
||||||
|
message=f"已更新 {len(updated)} 条,{len(failed)} 条未生效",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{item_id}", summary="修改任务字段(乐观锁)")
|
@router.patch("/{item_id}", summary="修改任务字段(乐观锁)")
|
||||||
async def update_item(
|
async def update_item(
|
||||||
item_id: uuid.UUID, body: UpdateItemIn, ctx: MemberDep, session: SessionDep
|
item_id: uuid.UUID, body: UpdateItemIn, ctx: MemberDep, session: SessionDep
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ class Settings(BaseSettings):
|
|||||||
idempotency_enabled: bool = True
|
idempotency_enabled: bool = True
|
||||||
idempotency_ttl_seconds: int = 86_400
|
idempotency_ttl_seconds: int = 86_400
|
||||||
|
|
||||||
|
# ── 知识库向量库(独立实例,M3 起用)───────────────────
|
||||||
|
# 刻意与业务库分开放:fnsvr-01 上的 PG 没有 pgvector,也不该为了装扩展去动它;
|
||||||
|
# 向量检索吃内存,和任务库挤在同一实例会互相拖累。
|
||||||
|
# 未配置时所有向量相关能力(M3 才启用)直接不可用,不会静默降级成假结果。
|
||||||
|
vector_database_url: str | None = None
|
||||||
|
embedding_dim: int = 1024
|
||||||
|
embedding_model: str = "BAAI/bge-m3"
|
||||||
|
|
||||||
# ── 构建标记(部署校验用)──────────────────────────────
|
# ── 构建标记(部署校验用)──────────────────────────────
|
||||||
build_commit: str = "unknown"
|
build_commit: str = "unknown"
|
||||||
build_time: str = "unknown"
|
build_time: str = "unknown"
|
||||||
@@ -100,6 +108,10 @@ class Settings(BaseSettings):
|
|||||||
return self.test_migration_database_url
|
return self.test_migration_database_url
|
||||||
return self.migration_database_url
|
return self.migration_database_url
|
||||||
|
|
||||||
|
def resolved_vector_database_url(self) -> str | None:
|
||||||
|
"""向量库连接串;没配就是 None,调用方必须显式处理"没有向量能力"。"""
|
||||||
|
return self.vector_database_url
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""四视图 + 看板的首屏耗时实测(1000 条任务)。
|
||||||
|
|
||||||
|
不是压测工具,只回答一个问题:**1000 条之后,点开还会不会卡**。
|
||||||
|
跑之前先起后端:`uvicorn app.main:app --port 8100`(连开发库)。
|
||||||
|
|
||||||
|
python scripts/bench_views.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import statistics
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
BASE = "http://127.0.0.1:8100/api/v1"
|
||||||
|
TOTAL = 1000
|
||||||
|
REPEAT = 15
|
||||||
|
|
||||||
|
|
||||||
|
async def login(client: httpx.AsyncClient) -> tuple[str, dict[str, str]]:
|
||||||
|
phone = f"137{uuid.uuid4().int % 100_000_000:08d}"
|
||||||
|
sent = await client.post(f"{BASE}/auth/sms/send", json={"phone": phone})
|
||||||
|
code = sent.json()["data"]["dev_code"]
|
||||||
|
out = await client.post(f"{BASE}/auth/login", json={"phone": phone, "code": code})
|
||||||
|
token = out.json()["data"]["tokens"]["access_token"]
|
||||||
|
return token, {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
_, headers = await login(client)
|
||||||
|
ws = await client.post(f"{BASE}/workspaces", json={"name": "性能实测"}, headers=headers)
|
||||||
|
ws_id = ws.json()["data"]["id"]
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
statuses = ["todo", "doing", "blocked", "done", "inbox"]
|
||||||
|
print(f"造 {TOTAL} 条任务……")
|
||||||
|
started = time.perf_counter()
|
||||||
|
for index in range(TOTAL):
|
||||||
|
payload = {
|
||||||
|
"title": f"测试任务 {index:04d} " + ("周报" if index % 7 == 0 else "跟进"),
|
||||||
|
"status": statuses[index % len(statuses)],
|
||||||
|
"priority": index % 4,
|
||||||
|
"due_at": (now + timedelta(days=(index % 10) - 5)).isoformat(),
|
||||||
|
"tag_names": ["批量", f"组{index % 5}"],
|
||||||
|
}
|
||||||
|
await client.post(f"{BASE}/workspaces/{ws_id}/items", json=payload, headers=headers)
|
||||||
|
print(f"写入耗时 {time.perf_counter() - started:.1f}s")
|
||||||
|
|
||||||
|
targets = {
|
||||||
|
"今天": f"/workspaces/{ws_id}/items?view=today&limit=30",
|
||||||
|
"进行中": f"/workspaces/{ws_id}/items?view=active&limit=30",
|
||||||
|
"收件箱": f"/workspaces/{ws_id}/items?view=inbox&limit=30",
|
||||||
|
"全部": f"/workspaces/{ws_id}/items?view=all&limit=30",
|
||||||
|
"搜索(周报)": f"/workspaces/{ws_id}/items?view=all&q=%E5%91%A8%E6%8A%A5&limit=30",
|
||||||
|
"看板": f"/workspaces/{ws_id}/items/board",
|
||||||
|
}
|
||||||
|
|
||||||
|
print("\n| 视图 | 首次 | P50 | P95 | 返回条数 |")
|
||||||
|
print("|---|---|---|---|---|")
|
||||||
|
for label, path in targets.items():
|
||||||
|
samples: list[float] = []
|
||||||
|
count = 0
|
||||||
|
for attempt in range(REPEAT):
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
resp = await client.get(f"{BASE}{path}", headers=headers)
|
||||||
|
samples.append((time.perf_counter() - t0) * 1000)
|
||||||
|
if attempt == 0:
|
||||||
|
body = resp.json()["data"]
|
||||||
|
if "columns" in body:
|
||||||
|
count = sum(len(v) for v in body["columns"].values())
|
||||||
|
else:
|
||||||
|
count = len(body["items"])
|
||||||
|
samples.sort()
|
||||||
|
p50 = statistics.median(samples)
|
||||||
|
p95 = samples[min(len(samples) - 1, int(len(samples) * 0.95))]
|
||||||
|
print(f"| {label} | {samples[0]:.1f} ms | {p50:.1f} ms | {p95:.1f} ms | {count} |")
|
||||||
|
|
||||||
|
print(
|
||||||
|
"\n注:含网络往返与鉴权中间件;本机 127.0.0.1,所以这是**下限**,"
|
||||||
|
"真实用户还要加速度。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -270,3 +270,90 @@ async def test_cursor_pagination_does_not_repeat_rows(client: AsyncClient) -> No
|
|||||||
assert ids_first.isdisjoint(ids_second)
|
assert ids_first.isdisjoint(ids_second)
|
||||||
assert len(ids_second) == 1
|
assert len(ids_second) == 1
|
||||||
assert second["has_more"] is False
|
assert second["has_more"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_batch_status_partial_success(client: AsyncClient) -> None:
|
||||||
|
"""拖 3 条进去:2 条该成功,1 条因为版本过期该失败,且失败不影响另外两条。"""
|
||||||
|
user = await login(client)
|
||||||
|
ws = await create_workspace(client, user, "个人")
|
||||||
|
a = await _new_item(client, user, ws["id"], title="A", status="todo")
|
||||||
|
b = await _new_item(client, user, ws["id"], title="B", status="todo")
|
||||||
|
stale = await _new_item(client, user, ws["id"], title="C", status="todo")
|
||||||
|
|
||||||
|
# 让 C 的版本先过期:另一个人(同一用户)改了一次
|
||||||
|
unwrap(
|
||||||
|
await client.patch(
|
||||||
|
f"{items_url(ws['id'])}/{stale['id']}",
|
||||||
|
json={"title": "C 改过", "version": stale["version"]},
|
||||||
|
headers=user["headers"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
body = unwrap(
|
||||||
|
await client.post(
|
||||||
|
f"{items_url(ws['id'])}/batch-status",
|
||||||
|
json={
|
||||||
|
"status": "done",
|
||||||
|
"items": [
|
||||||
|
{"id": a["id"], "version": a["version"]},
|
||||||
|
{"id": b["id"], "version": b["version"]},
|
||||||
|
{"id": stale["id"], "version": stale["version"]},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=user["headers"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert len(body["updated"]) == 2
|
||||||
|
assert len(body["failed"]) == 1
|
||||||
|
assert body["failed"][0]["id"] == stale["id"]
|
||||||
|
assert body["failed"][0]["code"] == 4009
|
||||||
|
assert body["failed"][0]["current_version"] == stale["version"] + 1
|
||||||
|
|
||||||
|
detail_a = unwrap(await client.get(f"{items_url(ws['id'])}/{a['id']}", headers=user["headers"]))
|
||||||
|
assert detail_a["status"] == "done"
|
||||||
|
assert detail_a["completed_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_batch_status_reports_illegal_transition(client: AsyncClient) -> None:
|
||||||
|
user = await login(client)
|
||||||
|
ws = await create_workspace(client, user, "个人")
|
||||||
|
inbox = await _new_item(client, user, ws["id"], title="还在收件箱")
|
||||||
|
|
||||||
|
body = unwrap(
|
||||||
|
await client.post(
|
||||||
|
f"{items_url(ws['id'])}/batch-status",
|
||||||
|
json={"status": "done", "items": [{"id": inbox["id"], "version": inbox["version"]}]},
|
||||||
|
headers=user["headers"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert body["updated"] == []
|
||||||
|
assert body["failed"][0]["code"] == 5002
|
||||||
|
assert "todo" in body["failed"][0]["allowed"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_board_groups_by_status_and_hides_others(client: AsyncClient) -> None:
|
||||||
|
user = await login(client)
|
||||||
|
ws = await create_workspace(client, user, "个人")
|
||||||
|
await _new_item(client, user, ws["id"], title="待办1", status="todo")
|
||||||
|
await _new_item(client, user, ws["id"], title="在做1", status="doing")
|
||||||
|
await _new_item(client, user, ws["id"], title="卡住1", status="blocked")
|
||||||
|
await _new_item(client, user, ws["id"], title="收件箱的", status="inbox")
|
||||||
|
|
||||||
|
board = unwrap(await client.get(f"{items_url(ws['id'])}/board", headers=user["headers"]))
|
||||||
|
columns = board["columns"]
|
||||||
|
assert board["order"] == ["todo", "doing", "blocked", "done"]
|
||||||
|
assert [i["title"] for i in columns["todo"]] == ["待办1"]
|
||||||
|
assert [i["title"] for i in columns["doing"]] == ["在做1"]
|
||||||
|
assert [i["title"] for i in columns["blocked"]] == ["卡住1"]
|
||||||
|
assert columns["done"] == []
|
||||||
|
# 收件箱不属于看板任何一列
|
||||||
|
all_titles = [i["title"] for col in columns.values() for i in col]
|
||||||
|
assert "收件箱的" not in all_titles
|
||||||
|
|
||||||
|
# 别人的工作区看板为空
|
||||||
|
other_user = await login(client)
|
||||||
|
other_ws = await create_workspace(client, other_user, "别人")
|
||||||
|
empty = unwrap(
|
||||||
|
await client.get(f"{items_url(other_ws['id'])}/board", headers=other_user["headers"])
|
||||||
|
)
|
||||||
|
assert all(len(col) == 0 for col in empty["columns"].values())
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 知识库向量库:独立实例(不碰 fnsvr-01)
|
||||||
|
#
|
||||||
|
# 为什么单独跑一个:
|
||||||
|
# 1. fnsvr-01 上的 PG 17 没有 pgvector,装扩展要动生产机,风险不值得;
|
||||||
|
# 2. 向量检索是重 IO、重内存的活,和业务库挤在一起会互相拖累;
|
||||||
|
# 3. 隔离后可以单独备份/重建/换 embedding 维度,不牵连任务数据。
|
||||||
|
#
|
||||||
|
# 启动:docker-compose -f docker-compose.vector.yml up -d
|
||||||
|
# 端口:5434(本机 5433 已经被开发库占用)
|
||||||
|
|
||||||
|
services:
|
||||||
|
vector-db:
|
||||||
|
image: pgvector/pgvector:pg16
|
||||||
|
container_name: dadao-vector
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: dadao_owner
|
||||||
|
POSTGRES_PASSWORD: dadao_dev_pw
|
||||||
|
POSTGRES_DB: dadao_knowledge
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5434:5432"
|
||||||
|
volumes:
|
||||||
|
- vector_pgdata:/var/lib/postgresql/data
|
||||||
|
- ./postgres/vector-init:/docker-entrypoint-initdb.d:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U dadao_owner -d dadao_knowledge"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
command:
|
||||||
|
- postgres
|
||||||
|
- -c
|
||||||
|
- shared_preload_libraries=vector
|
||||||
|
- -c
|
||||||
|
- maintenance_work_mem=512MB
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
vector_pgdata:
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- 向量库初始化(只在首次创建数据卷时执行一次)
|
||||||
|
-- 与业务库完全隔离:这里没有任何任务/成员/工单数据,只有切片与向量。
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||||
|
|
||||||
|
-- 应用角色:非 superuser、无 BYPASSRLS。向量表同样开 RLS,
|
||||||
|
-- 因为"知识库串租户"比"任务串租户"更致命——那是把别人公司的文档喂给模型。
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'dadao_app') THEN
|
||||||
|
CREATE ROLE dadao_app LOGIN PASSWORD 'dadao_dev_pw'
|
||||||
|
NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT CONNECT ON DATABASE dadao_knowledge TO dadao_app;
|
||||||
|
GRANT USAGE ON SCHEMA public TO dadao_app;
|
||||||
|
|
||||||
|
-- 表结构由后端 alembic 建(迁移脚本里带 RLS 策略),这里只准备环境。
|
||||||
|
-- 但默认权限要提前给:否则 alembic 建完表,dadao_app 还是读不了。
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE dadao_owner IN SCHEMA public
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO dadao_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE dadao_owner IN SCHEMA public
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCES TO dadao_app;
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* 三条真浏览器用例:登录 → 建任务(含 #标签/!优先级)→ 看板拖拽 → 详情抽屉保存。
|
||||||
|
*
|
||||||
|
* 用 playwright-core + 本机缓存的 Chromium,不下载浏览器(这台机器外网被掐)。
|
||||||
|
* 跑之前先起后端(8100)与前端(5180):
|
||||||
|
* node e2e/smoke.mjs
|
||||||
|
*/
|
||||||
|
import { existsSync, mkdirSync } from "node:fs";
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
|
||||||
|
const APP = process.env.APP_URL ?? "http://127.0.0.1:5180";
|
||||||
|
const CHROME =
|
||||||
|
process.env.CHROME_PATH ??
|
||||||
|
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`;
|
||||||
|
const SHOT_DIR = new URL("./artifacts/", import.meta.url).pathname.replace(/^\//, "");
|
||||||
|
|
||||||
|
if (!existsSync(CHROME)) {
|
||||||
|
console.error(`找不到 Chromium:${CHROME}\n用 CHROME_PATH 指定可执行文件`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
mkdirSync(SHOT_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const phone = `139${String(Date.now()).slice(-8)}`;
|
||||||
|
let step = 0;
|
||||||
|
function pass(message) {
|
||||||
|
console.log(` ✓ ${++step}. ${message}`);
|
||||||
|
}
|
||||||
|
function fail(message) {
|
||||||
|
console.error(` ✗ ${message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
||||||
|
page.setDefaultTimeout(10_000);
|
||||||
|
|
||||||
|
const consoleErrors = [];
|
||||||
|
page.on("console", (msg) => {
|
||||||
|
if (msg.type() === "error") consoleErrors.push(msg.text());
|
||||||
|
});
|
||||||
|
page.on("pageerror", (err) => consoleErrors.push(String(err)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 登录
|
||||||
|
await page.goto(`${APP}/login`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.locator("#phone").fill(phone);
|
||||||
|
await page.getByRole("button", { name: "获取验证码" }).click();
|
||||||
|
const devCodeText = await page.locator("text=开发环境验证码").first().innerText();
|
||||||
|
const code = devCodeText.match(/(\d{6})/)?.[1];
|
||||||
|
if (!code) fail(`没读到开发验证码:${devCodeText}`);
|
||||||
|
await page.locator("#code").fill(code);
|
||||||
|
await page.getByRole("main").getByRole("button", { name: "登录", exact: true }).click();
|
||||||
|
await page.waitForURL(`${APP}/`);
|
||||||
|
pass("手机号验证码登录成功");
|
||||||
|
|
||||||
|
// 2. 建工作区(创建后直接跳进任务页)
|
||||||
|
await page.getByPlaceholder("例如:达道科技 / 我的事项").fill("E2E 测试组");
|
||||||
|
await page.getByRole("button", { name: "创建" }).click();
|
||||||
|
await page.waitForURL(/\/w\/[0-9a-f-]{36}$/);
|
||||||
|
await page.getByRole("heading", { name: "任务" }).waitFor();
|
||||||
|
pass("建工作区并进入任务页");
|
||||||
|
|
||||||
|
// 3. 快速录入:#标签 + !优先级
|
||||||
|
const composer = page.getByPlaceholder(/加一件要做的事/);
|
||||||
|
await composer.fill("写周报初稿 #周报 !高");
|
||||||
|
await composer.press("Enter");
|
||||||
|
const row = page.locator("div.card > div").filter({ hasText: "写周报初稿" }).first();
|
||||||
|
await row.waitFor();
|
||||||
|
const rowText = await row.innerText();
|
||||||
|
if (!rowText.includes("#周报")) fail(`标签没解析进标题外:${rowText}`);
|
||||||
|
if (rowText.includes("#周报") && rowText.includes("写周报初稿 #周报")) fail("标题里残留了语法");
|
||||||
|
if (!rowText.includes("优先级 高")) fail(`优先级没生效:${rowText}`);
|
||||||
|
pass("快速录入解析 #标签 与 !优先级(标题里已剥离语法)");
|
||||||
|
|
||||||
|
// 4. 详情抽屉可编辑:改优先级 + 加评论
|
||||||
|
await page.getByRole("button", { name: "写周报初稿" }).click();
|
||||||
|
const drawer = page.locator("aside");
|
||||||
|
await drawer.waitFor();
|
||||||
|
await drawer.locator("select").selectOption("2");
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const commentBox = drawer.getByPlaceholder("补充一句进展…");
|
||||||
|
await commentBox.fill("E2E:初稿已出,等评审");
|
||||||
|
await commentBox.press("Enter");
|
||||||
|
await drawer.locator("text=E2E:初稿已出,等评审").waitFor();
|
||||||
|
await page.screenshot({ path: `${SHOT_DIR}/detail.png` });
|
||||||
|
await page.getByRole("button", { name: "关闭" }).click();
|
||||||
|
const rowAfter = await page.locator("div.card > div").filter({ hasText: "写周报初稿" }).first().innerText();
|
||||||
|
if (!rowAfter.includes("优先级 中")) fail(`详情里改优先级没同步回列表:${rowAfter}`);
|
||||||
|
pass("详情抽屉保存(优先级回写列表)与评论落库");
|
||||||
|
|
||||||
|
// 5. 看板拖拽:待办 → 进行中
|
||||||
|
await page.getByRole("link", { name: "看板" }).click();
|
||||||
|
await page.getByRole("heading", { name: "看板" }).waitFor();
|
||||||
|
const columns = page.locator("section");
|
||||||
|
const todoCol = columns.nth(0);
|
||||||
|
const doingCol = columns.nth(1);
|
||||||
|
const card = todoCol.locator("article").filter({ hasText: "写周报初稿" }).first();
|
||||||
|
await card.waitFor();
|
||||||
|
await card.dragTo(doingCol);
|
||||||
|
await doingCol.locator("article").filter({ hasText: "写周报初稿" }).first().waitFor();
|
||||||
|
await page.screenshot({ path: `${SHOT_DIR}/board.png` });
|
||||||
|
pass("看板拖拽改状态(待办 → 进行中)落库并刷新");
|
||||||
|
|
||||||
|
// 6. 状态机拒绝非法拖拽:进行中 → 待办 之后不能再直接拖到"已完成"以外的非法列(用 blocked→done 验证)
|
||||||
|
const blockedCol = columns.nth(2);
|
||||||
|
await doingCol.locator("article").filter({ hasText: "写周报初稿" }).first().dragTo(blockedCol);
|
||||||
|
await blockedCol.locator("article").filter({ hasText: "写周报初稿" }).first().waitFor();
|
||||||
|
const doneCol = columns.nth(3);
|
||||||
|
await blockedCol.locator("article").filter({ hasText: "写周报初稿" }).first().dragTo(doneCol);
|
||||||
|
const warning = await page.locator("text=不能直接变成").first().innerText();
|
||||||
|
if (!warning.includes("不能直接变成")) fail("非法拖拽没有被拦下");
|
||||||
|
pass(`非法状态流转被拦下并给出原因:${warning}`);
|
||||||
|
|
||||||
|
if (consoleErrors.length > 0) {
|
||||||
|
fail(`浏览器控制台有报错:\n${consoleErrors.join("\n")}`);
|
||||||
|
}
|
||||||
|
pass("浏览器控制台无报错");
|
||||||
|
|
||||||
|
console.log(`\n全部通过,截图在 ${SHOT_DIR}`);
|
||||||
|
await browser.close();
|
||||||
|
} catch (err) {
|
||||||
|
await page.screenshot({ path: `${SHOT_DIR}/failure.png` }).catch(() => {});
|
||||||
|
console.error(`\n失败:${err?.message ?? err}`);
|
||||||
|
await browser.close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -4,6 +4,12 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>达道Task</title>
|
<title>达道Task</title>
|
||||||
|
<meta name="description" content="达道Task:任务、工单、知识库,一条道走到底" />
|
||||||
|
<!-- 内联 SVG 图标:不额外发请求,也就不会有 favicon 404 -->
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%233F5A68'/%3E%3Cpath d='M18 34l8 8 20-20' stroke='%23C2703D' stroke-width='7' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { LoginPage } from "./pages/Login";
|
|||||||
import { HomePage } from "./pages/Home";
|
import { HomePage } from "./pages/Home";
|
||||||
import { WorkspacePage } from "./pages/Workspace";
|
import { WorkspacePage } from "./pages/Workspace";
|
||||||
import { TasksPage } from "./pages/Tasks";
|
import { TasksPage } from "./pages/Tasks";
|
||||||
|
import { BoardPage } from "./pages/Board";
|
||||||
import { JoinPage } from "./pages/Join";
|
import { JoinPage } from "./pages/Join";
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
@@ -82,6 +83,7 @@ export function App() {
|
|||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/w/:wsId" element={<TasksPage />} />
|
<Route path="/w/:wsId" element={<TasksPage />} />
|
||||||
|
<Route path="/w/:wsId/board" element={<BoardPage />} />
|
||||||
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
|
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
|
||||||
<Route path="/join/:wsId/:code" element={<JoinPage />} />
|
<Route path="/join/:wsId/:code" element={<JoinPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { ApiError, api } from "../lib/client";
|
||||||
|
import { PRIORITY_LABEL, STATUS_LABEL } from "../lib/parse";
|
||||||
|
import type { Activity, Item, ItemDetail } from "../lib/types";
|
||||||
|
|
||||||
|
interface Comment {
|
||||||
|
id: string;
|
||||||
|
body_md: string;
|
||||||
|
author_id: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
wsId: string;
|
||||||
|
itemId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onChanged: (item: Item) => void;
|
||||||
|
onDeleted: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocalInput(iso: string | null): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
const d = new Date(iso);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskDetail({ wsId, itemId, onClose, onChanged, onDeleted }: Props) {
|
||||||
|
const [item, setItem] = useState<ItemDetail | null>(null);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
const [tags, setTags] = useState("");
|
||||||
|
const [due, setDue] = useState("");
|
||||||
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const base = `/workspaces/${wsId}/items/${itemId}`;
|
||||||
|
|
||||||
|
const hydrate = useCallback((next: ItemDetail) => {
|
||||||
|
setItem(next);
|
||||||
|
setTitle(next.title);
|
||||||
|
setBody(next.body_md);
|
||||||
|
setTags(next.tags.map((t) => t.name).join(" "));
|
||||||
|
setDue(toLocalInput(next.due_at));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const detail = await api.get<ItemDetail>(base);
|
||||||
|
if (alive) hydrate(detail);
|
||||||
|
const page = await api.get<{ items: Comment[] }>(`${base}/comments`);
|
||||||
|
if (alive) setComments(page.items);
|
||||||
|
} catch (err) {
|
||||||
|
if (alive) setError(err instanceof ApiError ? err.message : "加载失败");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [base, hydrate]);
|
||||||
|
|
||||||
|
async function patch(
|
||||||
|
values: Record<string, unknown>,
|
||||||
|
label: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!item) return false;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const updated = await api.patch<Item>(base, { ...values, version: item.version });
|
||||||
|
onChanged(updated);
|
||||||
|
hydrate({ ...(item as ItemDetail), ...updated, activities: item.activities });
|
||||||
|
void refreshActivities(updated);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.code === 4009) {
|
||||||
|
setError(`${label}没保存:这条刚被别人改过,已刷新为最新内容`);
|
||||||
|
const fresh = await api.get<ItemDetail>(base);
|
||||||
|
hydrate(fresh);
|
||||||
|
} else {
|
||||||
|
setError(err instanceof ApiError ? err.message : `${label}没保存`);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshActivities(updated: Item) {
|
||||||
|
try {
|
||||||
|
const fresh = await api.get<ItemDetail>(base);
|
||||||
|
setItem({ ...fresh, ...updated, activities: fresh.activities });
|
||||||
|
setTitle(updated.title);
|
||||||
|
setBody(updated.body_md);
|
||||||
|
setTags(updated.tags.map((t) => t.name).join(" "));
|
||||||
|
setDue(toLocalInput(updated.due_at));
|
||||||
|
} catch {
|
||||||
|
/* 刷新事件流失败不影响已保存的结果 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTitle() {
|
||||||
|
if (!item || !title.trim() || title.trim() === item.title) return;
|
||||||
|
await patch({ title: title.trim() }, "标题");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBody() {
|
||||||
|
if (!item || body === item.body_md) return;
|
||||||
|
await patch({ body_md: body }, "备注");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTags() {
|
||||||
|
if (!item) return;
|
||||||
|
const next = tags.split(/[\s,,]+/).filter(Boolean);
|
||||||
|
const current = item.tags.map((t) => t.name);
|
||||||
|
if (next.join() === current.join()) return;
|
||||||
|
await patch({ tag_names: next }, "标签");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDue(value: string) {
|
||||||
|
if (!item) return;
|
||||||
|
if (!value) {
|
||||||
|
await patch({ clear_due_at: true }, "截止时间");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await patch({ due_at: new Date(value).toISOString() }, "截止时间");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addComment() {
|
||||||
|
const text = draft.trim();
|
||||||
|
if (!text) return;
|
||||||
|
try {
|
||||||
|
const created = await api.post<Comment>(`${base}/comments`, { body_md: text });
|
||||||
|
setComments((prev) => [...prev, created]);
|
||||||
|
setDraft("");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "评论失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (!item) return;
|
||||||
|
if (!window.confirm("删除这条任务?可以在「已删除」视图里找回内容。")) return;
|
||||||
|
try {
|
||||||
|
await api.del(`${base}?version=${item.version}`);
|
||||||
|
onDeleted(item.id);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "删除失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-20 flex justify-end bg-ink-900/20" onClick={onClose}>
|
||||||
|
<aside
|
||||||
|
className="flex h-full w-full max-w-xl flex-col overflow-y-auto bg-white p-6 shadow-xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{!item ? (
|
||||||
|
<p className="text-sm text-ink-500">{error ?? "加载中…"}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 flex items-center justify-between text-xs text-ink-500">
|
||||||
|
<span>
|
||||||
|
{STATUS_LABEL[item.status]} · 版本 {item.version}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="btn-ghost px-2 py-1 text-xs text-red-600" onClick={() => void remove()}>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
<button className="btn-ghost px-2 py-1 text-xs" onClick={onClose}>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="input text-base font-medium"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
onBlur={() => void saveTitle()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<label className="block">
|
||||||
|
<span className="label">优先级</span>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={item.priority}
|
||||||
|
onChange={(e) => void patch({ priority: Number(e.target.value) }, "优先级")}
|
||||||
|
>
|
||||||
|
{[0, 1, 2, 3].map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{PRIORITY_LABEL[p]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="label">截止时间</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className="input"
|
||||||
|
value={due}
|
||||||
|
onChange={(e) => setDue(e.target.value)}
|
||||||
|
onBlur={(e) => void saveDue(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="mt-3 block text-sm">
|
||||||
|
<span className="label">标签(空格分隔)</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={tags}
|
||||||
|
onChange={(e) => setTags(e.target.value)}
|
||||||
|
onBlur={() => void saveTags()}
|
||||||
|
placeholder="周报 客户"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="mt-3 block text-sm">
|
||||||
|
<span className="label">备注</span>
|
||||||
|
<textarea
|
||||||
|
className="input min-h-32"
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
onBlur={() => void saveBody()}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||||
|
{busy && <p className="mt-3 text-xs text-ink-500">保存中…</p>}
|
||||||
|
|
||||||
|
<h3 className="mt-6 text-sm font-medium">评论({comments.length})</h3>
|
||||||
|
<ul className="mt-2 space-y-2">
|
||||||
|
{comments.map((c) => (
|
||||||
|
<li key={c.id} className="rounded-lg bg-ink-50 p-2 text-sm">
|
||||||
|
<p className="whitespace-pre-wrap">{c.body_md}</p>
|
||||||
|
<p className="mt-1 text-xs text-ink-500">
|
||||||
|
{new Date(c.created_at).toLocaleString("zh-CN")}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<input
|
||||||
|
className="input flex-1"
|
||||||
|
placeholder="补充一句进展…"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") void addComment();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className="btn-primary" disabled={!draft.trim()} onClick={() => void addComment()}>
|
||||||
|
发表
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="mt-6 text-sm font-medium">动态</h3>
|
||||||
|
<ul className="mt-2 space-y-1 text-xs text-ink-500">
|
||||||
|
{item.activities.slice(0, 30).map((a: Activity) => (
|
||||||
|
<li key={a.id}>
|
||||||
|
{new Date(a.created_at).toLocaleString("zh-CN")} · {a.action}
|
||||||
|
{a.action === "status_changed" && a.payload
|
||||||
|
? ` ${String(a.payload.from ?? "")} → ${String(a.payload.to ?? "")}`
|
||||||
|
: ""}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { ItemStatus } from "./types";
|
||||||
|
|
||||||
|
const PRIORITY_ALIASES: Record<string, number> = {
|
||||||
|
"!0": 0,
|
||||||
|
"!1": 1,
|
||||||
|
"!2": 2,
|
||||||
|
"!3": 3,
|
||||||
|
"!低": 1,
|
||||||
|
"!中": 2,
|
||||||
|
"!高": 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface QuickAdd {
|
||||||
|
title: string;
|
||||||
|
tagNames: string[];
|
||||||
|
priority: number;
|
||||||
|
status: ItemStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析快速录入:`#标签` 与 `!优先级` 会被吃掉,剩下的做标题。
|
||||||
|
*
|
||||||
|
* 只认行内 `#` 开头且后面不是空格的片段——中文正文里 `#` 极少自然出现,
|
||||||
|
* 真要以 # 开头就加个空格。`@人` 暂时不解析:重名的人比标签多,
|
||||||
|
* 悄悄指错负责人比不支持更糟(等 M2 有成员选择器再做)。
|
||||||
|
*/
|
||||||
|
export function parseQuickAdd(raw: string, defaultStatus: ItemStatus): QuickAdd {
|
||||||
|
const tagNames: string[] = [];
|
||||||
|
let priority = 1;
|
||||||
|
const words: string[] = [];
|
||||||
|
|
||||||
|
for (const token of raw.split(/\s+/)) {
|
||||||
|
if (!token) continue;
|
||||||
|
if (token.length > 1 && token.startsWith("#")) {
|
||||||
|
tagNames.push(token.slice(1));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (PRIORITY_ALIASES[token] !== undefined) {
|
||||||
|
priority = PRIORITY_ALIASES[token];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
words.push(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = words.join(" ").trim() || raw.trim();
|
||||||
|
return { title, tagNames, priority, status: defaultStatus };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ROLE_LABEL: Record<string, string> = {
|
||||||
|
owner: "负责人",
|
||||||
|
admin: "管理员",
|
||||||
|
member: "成员",
|
||||||
|
guest: "访客",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_LABEL: Record<ItemStatus, string> = {
|
||||||
|
inbox: "收件箱",
|
||||||
|
todo: "待办",
|
||||||
|
doing: "进行中",
|
||||||
|
done: "已完成",
|
||||||
|
blocked: "被阻塞",
|
||||||
|
canceled: "已取消",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 状态机前端影子:只用来置灰拖拽目标,服务端仍会再校验一次。 */
|
||||||
|
export const ALLOWED: Record<ItemStatus, ItemStatus[]> = {
|
||||||
|
inbox: ["todo", "canceled"],
|
||||||
|
todo: ["inbox", "doing", "done", "canceled"],
|
||||||
|
doing: ["todo", "done", "blocked", "canceled"],
|
||||||
|
blocked: ["doing", "canceled"],
|
||||||
|
done: ["todo"],
|
||||||
|
canceled: ["todo"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function canTransition(from: ItemStatus, to: ItemStatus): boolean {
|
||||||
|
return from === to || (ALLOWED[from] ?? []).includes(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRIORITY_LABEL: Record<number, string> = {
|
||||||
|
0: "无",
|
||||||
|
1: "低",
|
||||||
|
2: "中",
|
||||||
|
3: "高",
|
||||||
|
};
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
import { Layout } from "../components/Layout";
|
||||||
|
import { TaskDetail } from "../components/TaskDetail";
|
||||||
|
import { ApiError, api } from "../lib/client";
|
||||||
|
import { PRIORITY_LABEL, STATUS_LABEL, canTransition } from "../lib/parse";
|
||||||
|
import type { Item, ItemStatus } from "../lib/types";
|
||||||
|
|
||||||
|
const COLUMNS: { key: ItemStatus; label: string }[] = [
|
||||||
|
{ key: "todo", label: "待办" },
|
||||||
|
{ key: "doing", label: "进行中" },
|
||||||
|
{ key: "blocked", label: "被阻塞" },
|
||||||
|
{ key: "done", label: "已完成" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface BoardPayload {
|
||||||
|
columns: Record<string, Item[]>;
|
||||||
|
order: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BatchResult {
|
||||||
|
updated: { id: string; version: number; unchanged: boolean }[];
|
||||||
|
failed: { id: string; code: number; message: string; allowed?: string[] }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BoardPage() {
|
||||||
|
const { wsId } = useParams<{ wsId: string }>();
|
||||||
|
const [columns, setColumns] = useState<Record<string, Item[]>>({});
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [dragId, setDragId] = useState<string | null>(null);
|
||||||
|
const [hoverCol, setHoverCol] = useState<string | null>(null);
|
||||||
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!wsId) return;
|
||||||
|
try {
|
||||||
|
const data = await api.get<BoardPayload>(`/workspaces/${wsId}/items/board`);
|
||||||
|
setColumns(data.columns);
|
||||||
|
setSelected((prev) => {
|
||||||
|
const alive = new Set(Object.values(data.columns).flat().map((i) => i.id));
|
||||||
|
return new Set([...prev].filter((id) => alive.has(id)));
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "看板加载失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [wsId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const allItems = useMemo(() => Object.values(columns).flat(), [columns]);
|
||||||
|
const selectedItems = allItems.filter((i) => selected.has(i.id));
|
||||||
|
|
||||||
|
function moveLocally(item: Item, to: ItemStatus) {
|
||||||
|
setColumns((prev) => {
|
||||||
|
const next: Record<string, Item[]> = {};
|
||||||
|
for (const [key, list] of Object.entries(prev)) {
|
||||||
|
next[key] = list.filter((i) => i.id !== item.id);
|
||||||
|
}
|
||||||
|
next[to] = [{ ...item, status: to }, ...(next[to] ?? [])];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function move(item: Item, to: ItemStatus) {
|
||||||
|
if (!wsId || item.status === to) return;
|
||||||
|
if (!canTransition(item.status, to)) {
|
||||||
|
setError(`「${STATUS_LABEL[item.status]}」不能直接变成「${STATUS_LABEL[to]}」`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
moveLocally(item, to);
|
||||||
|
try {
|
||||||
|
const updated = await api.post<Item>(`/workspaces/${wsId}/items/${item.id}/status`, {
|
||||||
|
status: to,
|
||||||
|
version: item.version,
|
||||||
|
});
|
||||||
|
setColumns((prev) => {
|
||||||
|
const next: Record<string, Item[]> = {};
|
||||||
|
for (const [key, list] of Object.entries(prev)) {
|
||||||
|
next[key] = list.map((i) => (i.id === updated.id ? updated : i));
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
void load();
|
||||||
|
if (err instanceof ApiError && err.code === 4009) {
|
||||||
|
setError("这条刚被别人改过,看板已刷新,请再拖一次");
|
||||||
|
} else {
|
||||||
|
setError(err instanceof ApiError ? err.message : "移动失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function batchMove(to: ItemStatus) {
|
||||||
|
if (!wsId || selectedItems.length === 0) return;
|
||||||
|
const movable = selectedItems.filter((i) => canTransition(i.status, to));
|
||||||
|
const blocked = selectedItems.length - movable.length;
|
||||||
|
if (movable.length === 0) {
|
||||||
|
setError(`选中的 ${selectedItems.length} 条都不能直接变成「${STATUS_LABEL[to]}」`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await api.post<BatchResult>(`/workspaces/${wsId}/items/batch-status`, {
|
||||||
|
status: to,
|
||||||
|
items: movable.map((i) => ({ id: i.id, version: i.version })),
|
||||||
|
});
|
||||||
|
const failedIds = new Set(result.failed.map((f) => f.id));
|
||||||
|
const note = [
|
||||||
|
`已移动 ${result.updated.length} 条`,
|
||||||
|
failedIds.size > 0 ? `${failedIds.size} 条因并发冲突未移动` : "",
|
||||||
|
blocked > 0 ? `${blocked} 条状态不允许` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(",");
|
||||||
|
setError(note);
|
||||||
|
setSelected(new Set());
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "批量移动失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDrop(to: ItemStatus) {
|
||||||
|
setHoverCol(null);
|
||||||
|
const item = allItems.find((i) => i.id === dragId);
|
||||||
|
setDragId(null);
|
||||||
|
if (item) await move(item, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold">看板</h1>
|
||||||
|
<p className="mt-0.5 text-xs text-ink-500">
|
||||||
|
拖卡片改状态;勾选后可以批量移动。状态机不允许的组合会直接拒绝并说明原因。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<Link to={`/w/${wsId}`} className="text-ink-500 hover:text-ink-900">
|
||||||
|
列表
|
||||||
|
</Link>
|
||||||
|
<Link to={`/w/${wsId}/members`} className="text-ink-500 hover:text-ink-900">
|
||||||
|
成员
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedItems.length > 0 && (
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-lg bg-ink-50 px-3 py-2 text-sm">
|
||||||
|
<span className="text-ink-700">已选 {selectedItems.length} 条</span>
|
||||||
|
{COLUMNS.map((col) => (
|
||||||
|
<button
|
||||||
|
key={col.key}
|
||||||
|
className="btn-ghost px-2 py-1 text-xs"
|
||||||
|
onClick={() => void batchMove(col.key)}
|
||||||
|
>
|
||||||
|
移到{col.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button className="btn-ghost ml-auto px-2 py-1 text-xs" onClick={() => setSelected(new Set())}>
|
||||||
|
取消选择
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="mb-3 text-sm text-amber-700">{error}</p>}
|
||||||
|
{loading && <p className="text-sm text-ink-500">加载中…</p>}
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
|
{COLUMNS.map((col) => {
|
||||||
|
const list = columns[col.key] ?? [];
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={col.key}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setHoverCol(col.key);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setHoverCol((prev) => (prev === col.key ? null : prev))}
|
||||||
|
onDrop={() => void onDrop(col.key)}
|
||||||
|
className={`rounded-xl border p-2 transition ${
|
||||||
|
hoverCol === col.key ? "border-ink-900 bg-ink-50" : "border-ink-100 bg-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<header className="mb-2 flex items-center justify-between px-1 text-xs text-ink-500">
|
||||||
|
<span className="font-medium text-ink-700">{col.label}</span>
|
||||||
|
<span>{list.length}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{list.map((item) => (
|
||||||
|
<article
|
||||||
|
key={item.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => setDragId(item.id)}
|
||||||
|
onDragEnd={() => setDragId(null)}
|
||||||
|
className={`cursor-grab rounded-lg border border-ink-100 bg-white p-2 shadow-sm ${
|
||||||
|
dragId === item.id ? "opacity-50" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 h-3.5 w-3.5 accent-ink-900"
|
||||||
|
checked={selected.has(item.id)}
|
||||||
|
onChange={() => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(item.id)) next.delete(item.id);
|
||||||
|
else next.add(item.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="min-w-0 flex-1 text-left text-sm"
|
||||||
|
onClick={() => setOpenId(item.id)}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-1 pl-5 text-[11px] text-ink-500">
|
||||||
|
{item.priority > 0 && (
|
||||||
|
<span className="rounded bg-accent/15 px-1 text-accent">
|
||||||
|
{PRIORITY_LABEL[item.priority]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.due_at && <span>{new Date(item.due_at).toLocaleDateString("zh-CN")}</span>}
|
||||||
|
{item.tags.map((t) => (
|
||||||
|
<span key={t.id}>#{t.name}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
{list.length === 0 && (
|
||||||
|
<p className="px-1 py-4 text-center text-xs text-ink-500">空</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{openId && wsId && (
|
||||||
|
<TaskDetail
|
||||||
|
wsId={wsId}
|
||||||
|
itemId={openId}
|
||||||
|
onClose={() => setOpenId(null)}
|
||||||
|
onChanged={() => void load()}
|
||||||
|
onDeleted={() => void load()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
+86
-72
@@ -1,26 +1,19 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { Layout } from "../components/Layout";
|
import { Layout } from "../components/Layout";
|
||||||
|
import { TaskDetail } from "../components/TaskDetail";
|
||||||
import { ApiError, api } from "../lib/client";
|
import { ApiError, api } from "../lib/client";
|
||||||
import type { Item, ItemDetail, ItemStatus, Page } from "../lib/types";
|
import { PRIORITY_LABEL, STATUS_LABEL, parseQuickAdd } from "../lib/parse";
|
||||||
|
import type { Item, ItemStatus, Page } from "../lib/types";
|
||||||
|
|
||||||
const VIEWS: { key: string; label: string; hint: string }[] = [
|
const VIEWS: { key: string; label: string; hint: string; createAs: ItemStatus }[] = [
|
||||||
{ key: "today", label: "今天", hint: "今天之前到期、还没做完的" },
|
{ key: "today", label: "今天", hint: "今天之前到期、还没做完的", createAs: "todo" },
|
||||||
{ key: "active", label: "进行中", hint: "待办 + 进行中 + 被阻塞" },
|
{ key: "active", label: "进行中", hint: "待办 + 进行中 + 被阻塞", createAs: "todo" },
|
||||||
{ key: "inbox", label: "收件箱", hint: "想到还没归类的事" },
|
{ key: "inbox", label: "收件箱", hint: "想到还没归类的事", createAs: "inbox" },
|
||||||
{ key: "done", label: "已完成", hint: "最近完成的" },
|
{ key: "done", label: "已完成", hint: "最近完成的", createAs: "todo" },
|
||||||
{ key: "all", label: "全部", hint: "含已取消" },
|
{ key: "all", label: "全部", hint: "含已取消", createAs: "todo" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_LABEL: Record<ItemStatus, string> = {
|
|
||||||
inbox: "收件箱",
|
|
||||||
todo: "待办",
|
|
||||||
doing: "进行中",
|
|
||||||
done: "已完成",
|
|
||||||
blocked: "被阻塞",
|
|
||||||
canceled: "已取消",
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_STYLE: Record<ItemStatus, string> = {
|
const STATUS_STYLE: Record<ItemStatus, string> = {
|
||||||
inbox: "bg-ink-100 text-ink-700",
|
inbox: "bg-ink-100 text-ink-700",
|
||||||
todo: "bg-ink-100 text-ink-700",
|
todo: "bg-ink-100 text-ink-700",
|
||||||
@@ -30,12 +23,11 @@ const STATUS_STYLE: Record<ItemStatus, string> = {
|
|||||||
canceled: "bg-ink-100 text-ink-500 line-through",
|
canceled: "bg-ink-100 text-ink-500 line-through",
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDue(iso: string | null): { text: string; overdue: boolean } | null {
|
function dueLabel(iso: string | null): { text: string; overdue: boolean } | null {
|
||||||
if (!iso) return null;
|
if (!iso) return null;
|
||||||
const due = new Date(iso);
|
const due = new Date(iso);
|
||||||
const now = new Date();
|
|
||||||
const text = due.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" });
|
const text = due.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" });
|
||||||
return { text, overdue: due.getTime() < now.getTime() };
|
return { text, overdue: due.getTime() < Date.now() };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TasksPage() {
|
export function TasksPage() {
|
||||||
@@ -49,8 +41,9 @@ export function TasksPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [openId, setOpenId] = useState<string | null>(null);
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
const [detail, setDetail] = useState<ItemDetail | null>(null);
|
const [activeIdx, setActiveIdx] = useState(0);
|
||||||
const draftRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
const current = VIEWS.find((v) => v.key === view);
|
||||||
|
|
||||||
const load = useCallback(
|
const load = useCallback(
|
||||||
async (nextCursor?: string | null) => {
|
async (nextCursor?: string | null) => {
|
||||||
@@ -78,28 +71,48 @@ export function TasksPage() {
|
|||||||
void load(null);
|
void load(null);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
const activeItem = useMemo(() => items[activeIdx] ?? null, [items, activeIdx]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!openId || !wsId) {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
setDetail(null);
|
const target = e.target as HTMLElement;
|
||||||
return;
|
const typing = ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) || target.isContentEditable;
|
||||||
}
|
if (e.key === "Escape") {
|
||||||
void api
|
setOpenId(null);
|
||||||
.get<ItemDetail>(`/workspaces/${wsId}/items/${openId}`)
|
(target as HTMLInputElement).blur?.();
|
||||||
.then(setDetail)
|
return;
|
||||||
.catch(() => setDetail(null));
|
}
|
||||||
}, [openId, wsId]);
|
if (typing || items.length === 0) return;
|
||||||
|
|
||||||
|
if (e.key === "j" || e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIdx((i) => Math.min(i + 1, items.length - 1));
|
||||||
|
} else if (e.key === "k" || e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIdx((i) => Math.max(i - 1, 0));
|
||||||
|
} else if (e.key === "e") {
|
||||||
|
if (activeItem) setOpenId(activeItem.id);
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
if (activeItem) void changeStatus(activeItem, activeItem.status === "done" ? "todo" : "done");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
});
|
||||||
|
|
||||||
async function addTask() {
|
async function addTask() {
|
||||||
const title = draft.trim();
|
const raw = draft.trim();
|
||||||
if (!title || !wsId) return;
|
if (!raw || !wsId || !current) return;
|
||||||
|
const parsed = parseQuickAdd(raw, current.createAs);
|
||||||
try {
|
try {
|
||||||
const created = await api.post<Item>(`/workspaces/${wsId}/items`, {
|
const created = await api.post<Item>(`/workspaces/${wsId}/items`, {
|
||||||
title,
|
title: parsed.title,
|
||||||
status: view === "inbox" ? "inbox" : "todo",
|
status: parsed.status,
|
||||||
|
priority: parsed.priority,
|
||||||
|
tag_names: parsed.tagNames,
|
||||||
});
|
});
|
||||||
setDraft("");
|
setDraft("");
|
||||||
setItems((prev) => [created, ...prev]);
|
if (view !== "done") setItems((prev) => [created, ...prev]);
|
||||||
draftRef.current?.focus();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : "创建失败");
|
setError(err instanceof ApiError ? err.message : "创建失败");
|
||||||
}
|
}
|
||||||
@@ -113,7 +126,6 @@ export function TasksPage() {
|
|||||||
version: item.version,
|
version: item.version,
|
||||||
});
|
});
|
||||||
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)));
|
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)));
|
||||||
if (openId === updated.id) setDetail((prev) => (prev ? { ...prev, ...updated } : prev));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError && err.code === 4009) {
|
if (err instanceof ApiError && err.code === 4009) {
|
||||||
setError("这条任务刚被别人改过,已为你刷新");
|
setError("这条任务刚被别人改过,已为你刷新");
|
||||||
@@ -124,18 +136,23 @@ export function TasksPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = VIEWS.find((v) => v.key === view);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold">任务</h1>
|
<h1 className="text-lg font-semibold">任务</h1>
|
||||||
<p className="mt-0.5 text-xs text-ink-500">{current?.hint}</p>
|
<p className="mt-0.5 text-xs text-ink-500">
|
||||||
|
{current?.hint}
|
||||||
|
<span className="ml-2 text-ink-100">|</span>
|
||||||
|
<span className="ml-2">j/k 选择 · Enter 完成 · e 编辑 · Esc 关闭</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<Link to={`/w/${wsId}/board`} className="text-ink-500 hover:text-ink-900">
|
||||||
|
看板
|
||||||
|
</Link>
|
||||||
<Link to={`/w/${wsId}/members`} className="text-ink-500 hover:text-ink-900">
|
<Link to={`/w/${wsId}/members`} className="text-ink-500 hover:text-ink-900">
|
||||||
成员与邀请
|
成员
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/" className="text-ink-500 hover:text-ink-900">
|
<Link to="/" className="text-ink-500 hover:text-ink-900">
|
||||||
切换工作区
|
切换工作区
|
||||||
@@ -166,9 +183,8 @@ export function TasksPage() {
|
|||||||
<div className="card mb-4">
|
<div className="card mb-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
ref={draftRef}
|
|
||||||
className="input flex-1"
|
className="input flex-1"
|
||||||
placeholder="加一件要做的事,回车创建"
|
placeholder="加一件要做的事,回车创建。支持 #标签 !高"
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -190,13 +206,19 @@ export function TasksPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{items.map((item) => {
|
{items.map((item, index) => {
|
||||||
const due = formatDue(item.due_at);
|
const due = dueLabel(item.due_at);
|
||||||
const done = item.status === "done";
|
const done = item.status === "done";
|
||||||
// 收件箱里的东西还没"收下",不给完成勾选框——先归类,再完成。
|
// 收件箱里的东西还没"收下",不给完成勾选框——先归类,再完成。
|
||||||
const canComplete = item.status !== "inbox" && item.status !== "canceled";
|
const canComplete = item.status !== "inbox" && item.status !== "canceled";
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="flex items-start gap-3 px-4 py-3">
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`flex items-start gap-3 px-4 py-3 ${
|
||||||
|
index === activeIdx ? "bg-ink-50/70" : ""
|
||||||
|
}`}
|
||||||
|
onMouseEnter={() => setActiveIdx(index)}
|
||||||
|
>
|
||||||
{canComplete ? (
|
{canComplete ? (
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -211,7 +233,7 @@ export function TasksPage() {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<button
|
<button
|
||||||
className={`block w-full text-left text-sm ${done ? "text-ink-500 line-through" : ""}`}
|
className={`block w-full text-left text-sm ${done ? "text-ink-500 line-through" : ""}`}
|
||||||
onClick={() => setOpenId(openId === item.id ? null : item.id)}
|
onClick={() => setOpenId(item.id)}
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
</button>
|
</button>
|
||||||
@@ -219,6 +241,9 @@ export function TasksPage() {
|
|||||||
<span className={`rounded px-1.5 py-0.5 ${STATUS_STYLE[item.status]}`}>
|
<span className={`rounded px-1.5 py-0.5 ${STATUS_STYLE[item.status]}`}>
|
||||||
{STATUS_LABEL[item.status]}
|
{STATUS_LABEL[item.status]}
|
||||||
</span>
|
</span>
|
||||||
|
{item.priority > 0 && (
|
||||||
|
<span className="text-ink-500">优先级 {PRIORITY_LABEL[item.priority]}</span>
|
||||||
|
)}
|
||||||
{due && (
|
{due && (
|
||||||
<span className={due.overdue && !done ? "text-red-600" : "text-ink-500"}>
|
<span className={due.overdue && !done ? "text-red-600" : "text-ink-500"}>
|
||||||
截止 {due.text}
|
截止 {due.text}
|
||||||
@@ -231,29 +256,6 @@ export function TasksPage() {
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{openId === item.id && (
|
|
||||||
<div className="mt-3 rounded-lg bg-ink-50 p-3 text-xs">
|
|
||||||
{detail && detail.id === item.id ? (
|
|
||||||
<>
|
|
||||||
{detail.body_md ? (
|
|
||||||
<p className="whitespace-pre-wrap text-ink-700">{detail.body_md}</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-ink-500">没有备注。</p>
|
|
||||||
)}
|
|
||||||
<ul className="mt-3 space-y-1 text-ink-500">
|
|
||||||
{detail.activities.slice(0, 8).map((a) => (
|
|
||||||
<li key={a.id}>
|
|
||||||
{new Date(a.created_at).toLocaleString("zh-CN")} · {a.action}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-ink-500">加载中…</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 gap-1">
|
<div className="flex shrink-0 gap-1">
|
||||||
@@ -286,6 +288,18 @@ export function TasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{loading && items.length === 0 && <p className="mt-4 text-center text-sm text-ink-500">加载中…</p>}
|
{loading && items.length === 0 && <p className="mt-4 text-center text-sm text-ink-500">加载中…</p>}
|
||||||
|
|
||||||
|
{openId && wsId && (
|
||||||
|
<TaskDetail
|
||||||
|
wsId={wsId}
|
||||||
|
itemId={openId}
|
||||||
|
onClose={() => setOpenId(null)}
|
||||||
|
onChanged={(updated) =>
|
||||||
|
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)))
|
||||||
|
}
|
||||||
|
onDeleted={(id) => setItems((prev) => prev.filter((it) => it.id !== id))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
{"root":["./src/app.tsx","./src/main.tsx","./src/components/layout.tsx","./src/lib/client.ts","./src/lib/types.ts","./src/pages/home.tsx","./src/pages/join.tsx","./src/pages/login.tsx","./src/pages/placeholder.tsx","./src/pages/tasks.tsx","./src/pages/workspace.tsx"],"version":"5.9.3"}
|
|
||||||
Reference in New Issue
Block a user