Compare commits

...
3 Commits
Author SHA1 Message Date
dadao 3d02fe845a docs+chore: M1 状态校准、向量库独立实例、gitignore 修复
CI / backend (push) Canceled after 0s
- ROADMAP:勾选与实际证据一一对应,未做的写明原因与去向(通知推迟到 M2、子任务与 @人 合并进 item_link)
- DEVELOPMENT:新增向量库、e2e、性能实测三节
- API:补批量状态与看板接口,重排小节编号
- deploy:docker-compose.vector.yml + vector-init(独立 PG+pgvector,5434,不碰 fnsvr-01)
- .gitignore:注释乱码修正,补 *.tsbuildinfo / e2e 产物
2026-10-28 04:05:47 +08:00
dadao 6e33e1024d feat(web,android): 看板拖拽、详情侧栏编辑、快速录入与安卓任务列表
Web:
- 看板四列可拖拽,非法流转当场说明原因;勾选后可批量移动
- 详情侧栏全部可编辑(标题/优先级/截止/标签/备注/评论),409 自动刷新
- 快速录入 #标签 !优先级;键盘流 j/k、Enter、e、Esc
- Playwright 真浏览器 e2e 7 项断言全绿(web/e2e/smoke.mjs)
- 1000 条任务实测首屏 P50 38-55ms

Android(未编译验证:本机无 JDK/Gradle):
- 任务列表/详情/勾选完成/快速新建,冲突时提示并刷新
- 修:User/Member 字段名与后端 nickmame/phone 对齐,原先必然反序列化失败
2026-10-28 04:05:41 +08:00
dadao c4868f04cf feat(task): 批量改状态与看板数据接口
- POST /items/batch-status:逐条成败,不做全回滚;拖 10 条里 1 条冲突不该让另 9 条白干
- GET /items/board:四列一次取全,收件箱不进看板
- 修:/items/board 被 /{item_id} 抢先匹配导致 422
- 新增向量库配置项(M3 用,独立实例,不动 fnsvr-01)
- 3 个新测试,全量 46 passed
2026-10-28 04:05:35 +08:00
24 changed files with 1761 additions and 122 deletions
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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` 的跨租户与批量用例)
--- ---
+4 -3
View File
@@ -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("关闭") } },
)
}
}
+7
View File
@@ -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
+133
View File
@@ -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
+12
View File
@@ -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:
+90
View File
@@ -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())
+87
View File
@@ -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())
+39
View File
@@ -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:
+26
View File
@@ -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;
+126
View File
@@ -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);
}
+6
View File
@@ -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>
+2
View File
@@ -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 />} />
+284
View File
@@ -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>
);
}
+84
View File
@@ -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: "高",
};
+263
View File
@@ -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
View File
@@ -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
View File
@@ -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"}