Compare commits
3
Commits
e25a1a4e31
...
3d02fe845a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d02fe845a | ||
|
|
6e33e1024d | ||
|
|
c4868f04cf |
+2
-1
@@ -19,6 +19,8 @@ htmlcov/
|
||||
node_modules/
|
||||
web/dist/
|
||||
web/.vite/
|
||||
*.tsbuildinfo
|
||||
web/e2e/artifacts/
|
||||
|
||||
# 安卓
|
||||
android/.gradle/
|
||||
@@ -38,4 +40,3 @@ tmprfx*
|
||||
|
||||
# 本机开发用的便携 PostgreSQL / 临时目录(不入库)
|
||||
.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` 时可见。
|
||||
|
||||
### 3.6 评论
|
||||
### 3.8 评论
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/items/{item_id}/comments` | 时间正序,游标分页 |
|
||||
| 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`)是本仓库最重要的一组用例,改动任何租户表都必须重跑它。
|
||||
|
||||
## 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**,不要用管道截断输出,否则看不到进度也不知道卡在哪。
|
||||
- `npm install` 要加 `--ignore-scripts`:沙箱不允许 npm 的 lifecycle 脚本派生进程(`EPERM spawn`)。
|
||||
|
||||
## 8. 提交前自检清单
|
||||
## 9. 提交前自检清单
|
||||
|
||||
1. `pytest` 全绿,`ruff` 无告警。
|
||||
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/` |
|
||||
| pip 缓存 | 装包时写缓存会**卡死**(无输出、CPU 空转) | 必须加 `--no-cache-dir`;装包命令输出要重定向到文件再 tail,不要管道截断 |
|
||||
| `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 也不通 |
|
||||
| Android | 本机无 Android SDK、无 gradle-wrapper.jar | 交给 Android Studio 打开 `android/` 自动补全 |
|
||||
| fnsvr-01 (192.168.8.160) | 只开放 8418(Gitea) / 3306(MySQL) / **8432(PG 17.6,dbadmin/superuser)** | 用途定死:只有 `dadaotask` 与 `dadaotask_test` 两个库,**不再往上装扩展**;能力:无 pgvector,只有 pg_trgm/pgcrypto |
|
||||
| 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 起法(本机专用):
|
||||
|
||||
@@ -164,39 +165,49 @@
|
||||
- [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] 过滤:`status`(可重复)、`assignee=me|<uuid>`、`tag`、`q`(标题+正文 ILIKE,实测中文 `周报` 命中)
|
||||
- [ ] **批量状态变更**未做(M1 先做单条,批量等看板拖拽一起上)
|
||||
- [x] **批量状态变更**(`POST /items/batch-status`,逐条返回成败,不做全回滚)+ 看板数据 `GET /items/board`
|
||||
- [~] 搜索返回高亮片段:未做,只返回命中行。理由:高亮要在客户端按 query 二次切词,纯展示逻辑,等 Web 有真实使用反馈再补
|
||||
- [x] 分页:`(sort_key DESC, id DESC)` keyset 游标;`sort=due` 不分页(NULLS LAST 会把 NULL 行切碎,只有今日/逾期短列表用)
|
||||
|
||||
### 1.4 Web(3d)
|
||||
- [x] 视图 tab(今天/进行中/收件箱/已完成/全部)+ 搜索框 + 回车快速新建(`web/src/pages/Tasks.tsx`)
|
||||
- [x] 勾选完成/重开、`收下`(inbox→todo)、`开工`(todo→doing)、`阻塞`
|
||||
- [x] 点标题展开:正文、标签、最近 8 条事件流;`409` 时提示"刚被别人改过"并重载
|
||||
- [x] 加载更多(游标),空态文案("今天没有到期的事,挺好。")
|
||||
- [x] `npm run build` 通过:38 modules,`183.26 kB │ gzip 60.08 kB`
|
||||
- [ ] 看板拖拽(列 = 状态)未做
|
||||
- [ ] 详情侧栏(优先级/截止日期选择器/评论输入/子任务)未做——当前详情只读
|
||||
- [ ] 全局快速录入 `#标签 !优先级 @人` 语法未做
|
||||
- [ ] 键盘流 `j/k`、`Enter`、`e` 未做
|
||||
- [x] **看板拖拽**(`web/src/pages/Board.tsx`):HTML5 拖放,四列,非法流转当场拦下并说明原因;勾选后可批量移动
|
||||
- [x] **详情侧栏可编辑**(`web/src/components/TaskDetail.tsx`):标题/优先级/截止时间/标签/备注/评论,全部落库;409 时自动刷新为最新内容
|
||||
- [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 有成员选择器再做
|
||||
- [~] 子任务(一层):未做,排到 M2 与工单的 `item_link` 一起做——两者本质是同一种"事项之间的边"
|
||||
|
||||
### 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)
|
||||
- [ ] 站内通知表与未读角标(表在 M2 随工单一起建)
|
||||
- [ ] 每日摘要邮件
|
||||
- [ ] 站内通知表与未读角标 —— **主动推迟到 M2**:通知的绝大多数触发源是工单(被指派、被催、验收),
|
||||
M1 只有自己的任务,做出来也没人收到东西。表随 M2 的工单一起建,避免建两次。
|
||||
- [ ] 每日摘要邮件(同上推迟)
|
||||
|
||||
### 1.7 测试与验收(0.7d)
|
||||
- [x] `backend/tests/test_items.py` 10 个用例:默认收件箱、状态机 happy path 与非法流转、版本冲突、跨租户 3003 且他人列表为空、搜索+标签、`today` 时区边界、软删、评论与事件流、游标翻页不重复
|
||||
- [x] 全量 `pytest -q` → **43 passed**;`ruff check` + `ruff format --check` 全 0
|
||||
- [ ] Playwright e2e 未做
|
||||
- [ ] 性能口径(1000 条下首屏 < 300ms)未实测
|
||||
- [x] `backend/tests/test_items.py` 13 个用例:默认收件箱、状态机 happy path 与非法流转、版本冲突、
|
||||
跨租户 3003 且他人列表为空、搜索+标签、`today` 时区边界、软删、评论与事件流、游标翻页、
|
||||
批量改状态的部分成功、批量非法流转、看板分列
|
||||
- [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)
|
||||
- [ ] 连续 3 天所有待办都记在里面,没有回到微信/备忘录 —— 只有真实使用能证伪,代码侧无法替代
|
||||
- [ ] 冷启动到"建完第一个任务"< 90 秒(新账号实测)
|
||||
- [ ] 1000 条任务下四视图首屏 < 300ms
|
||||
- [x] 跨租户测试、越权测试全绿(`test_tenant_isolation.py` + `test_items.py::test_cross_tenant_access_denied`)
|
||||
- [x] 1000 条任务下四视图首屏 < 300ms —— 实测 P50 38–55ms,最慢的看板 55.4ms
|
||||
- [x] 跨租户测试、越权测试全绿(`test_tenant_isolation.py` + `test_items.py` 的跨租户与批量用例)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -21,10 +21,11 @@
|
||||
|---|---|
|
||||
| 手机号验证码登录、刷新令牌、幂等请求 | ✅ 已实现并有测试 |
|
||||
| 工作区 / 成员 / 邀请码加入,跨租户隔离(RLS) | ✅ 同上 |
|
||||
| 任务:收件箱→待办→进行中→完成、截止时间、标签、评论、事件流、乐观锁 | ✅ 后端 + Web 已通,43 个测试全绿 |
|
||||
| Web 构建 | ✅ `npm run build` 通过 |
|
||||
| 任务:收件箱→待办→进行中→完成、截止时间、标签、评论、事件流、乐观锁 | ✅ 后端 + Web 已通,46 个测试全绿 |
|
||||
| Web 列表 / 看板拖拽 / 详情侧栏编辑 / 键盘流 / 快速录入语法 | ✅ e2e 7 项断言全绿,构建通过 |
|
||||
| 1000 条任务下的首屏性能 | ✅ 实测 P50 38–55ms(目标 < 300ms) |
|
||||
| 安卓端任务列表与详情 | ⚠️ 代码已写,**本机无 JDK/Gradle,未编译验证** |
|
||||
| 工单、知识库、LLM 能力 | ⬜ M2–M4 |
|
||||
| 安卓端任务列表 | ⬜ |
|
||||
|
||||
## 快速开始
|
||||
|
||||
|
||||
@@ -15,9 +15,11 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.dadao.task.data.Api
|
||||
import com.dadao.task.data.TokenStore
|
||||
import com.dadao.task.data.Workspace
|
||||
import com.dadao.task.ui.DadaoTheme
|
||||
import com.dadao.task.ui.HomeScreen
|
||||
import com.dadao.task.ui.LoginScreen
|
||||
import com.dadao.task.ui.TaskListScreen
|
||||
|
||||
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
|
||||
private fun AppRoot() {
|
||||
@@ -41,6 +43,7 @@ private fun AppRoot() {
|
||||
val tokens = remember { TokenStore(context) }
|
||||
val api = remember { Api(tokens) }
|
||||
var screen by remember { mutableStateOf(if (tokens.isLoggedIn) Screen.Splash else Screen.Login) }
|
||||
var current by remember { mutableStateOf<Workspace?>(null) }
|
||||
|
||||
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
|
||||
LaunchedEffect(screen) {
|
||||
@@ -64,6 +67,18 @@ private fun AppRoot() {
|
||||
tokens.clear()
|
||||
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 一一对应 ----
|
||||
|
||||
@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
|
||||
data class Tokens(
|
||||
@@ -61,10 +65,62 @@ data class MeResult(val user: User, val workspaces: List<Workspace> = emptyList(
|
||||
|
||||
@Serializable
|
||||
data class Member(
|
||||
val userId: String,
|
||||
@SerialName("display_name") val displayName: String,
|
||||
val role: String,
|
||||
val id: String = "",
|
||||
@SerialName("user_id") val userId: 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 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
|
||||
@@ -132,6 +188,37 @@ class Api(private val tokens: TokenStore) {
|
||||
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(
|
||||
path: String,
|
||||
body: String? = null,
|
||||
|
||||
@@ -47,7 +47,7 @@ private val ROLE_LABEL = mapOf(
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(api: Api, onLogout: () -> Unit) {
|
||||
fun HomeScreen(api: Api, onLogout: () -> Unit, onOpenWorkspace: (Workspace) -> Unit) {
|
||||
val workspaces = remember { mutableStateListOf<Workspace>() }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
@@ -95,16 +95,7 @@ fun HomeScreen(api: Api, onLogout: () -> Unit) {
|
||||
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
items(workspaces, key = { it.id }) { ws ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable {
|
||||
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
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().clickable { onOpenWorkspace(ws) },
|
||||
colors = CardDefaults.cardColors(),
|
||||
) {
|
||||
Row(
|
||||
@@ -120,11 +111,23 @@ fun HomeScreen(api: Api, onLogout: () -> Unit) {
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
ROLE_LABEL[ws.role] ?: (ws.role ?: ""),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Accent,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
ROLE_LABEL[ws.role] ?: (ws.role ?: ""),
|
||||
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
|
||||
|
||||
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 条事件)")
|
||||
async def get_item(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
|
||||
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="已创建")
|
||||
|
||||
|
||||
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="修改任务字段(乐观锁)")
|
||||
async def update_item(
|
||||
item_id: uuid.UUID, body: UpdateItemIn, ctx: MemberDep, session: SessionDep
|
||||
|
||||
@@ -62,6 +62,14 @@ class Settings(BaseSettings):
|
||||
idempotency_enabled: bool = True
|
||||
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_time: str = "unknown"
|
||||
@@ -100,6 +108,10 @@ class Settings(BaseSettings):
|
||||
return self.test_migration_database_url
|
||||
return self.migration_database_url
|
||||
|
||||
def resolved_vector_database_url(self) -> str | None:
|
||||
"""向量库连接串;没配就是 None,调用方必须显式处理"没有向量能力"。"""
|
||||
return self.vector_database_url
|
||||
|
||||
|
||||
@lru_cache
|
||||
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 len(ids_second) == 1
|
||||
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 name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { LoginPage } from "./pages/Login";
|
||||
import { HomePage } from "./pages/Home";
|
||||
import { WorkspacePage } from "./pages/Workspace";
|
||||
import { TasksPage } from "./pages/Tasks";
|
||||
import { BoardPage } from "./pages/Board";
|
||||
import { JoinPage } from "./pages/Join";
|
||||
|
||||
interface AuthState {
|
||||
@@ -82,6 +83,7 @@ export function App() {
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/w/:wsId" element={<TasksPage />} />
|
||||
<Route path="/w/:wsId/board" element={<BoardPage />} />
|
||||
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
|
||||
<Route path="/join/:wsId/:code" element={<JoinPage />} />
|
||||
<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 { Layout } from "../components/Layout";
|
||||
import { TaskDetail } from "../components/TaskDetail";
|
||||
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 }[] = [
|
||||
{ key: "today", label: "今天", hint: "今天之前到期、还没做完的" },
|
||||
{ key: "active", label: "进行中", hint: "待办 + 进行中 + 被阻塞" },
|
||||
{ key: "inbox", label: "收件箱", hint: "想到还没归类的事" },
|
||||
{ key: "done", label: "已完成", hint: "最近完成的" },
|
||||
{ key: "all", label: "全部", hint: "含已取消" },
|
||||
const VIEWS: { key: string; label: string; hint: string; createAs: ItemStatus }[] = [
|
||||
{ key: "today", label: "今天", hint: "今天之前到期、还没做完的", createAs: "todo" },
|
||||
{ key: "active", label: "进行中", hint: "待办 + 进行中 + 被阻塞", createAs: "todo" },
|
||||
{ key: "inbox", label: "收件箱", hint: "想到还没归类的事", createAs: "inbox" },
|
||||
{ key: "done", label: "已完成", hint: "最近完成的", createAs: "todo" },
|
||||
{ key: "all", label: "全部", hint: "含已取消", createAs: "todo" },
|
||||
];
|
||||
|
||||
const STATUS_LABEL: Record<ItemStatus, string> = {
|
||||
inbox: "收件箱",
|
||||
todo: "待办",
|
||||
doing: "进行中",
|
||||
done: "已完成",
|
||||
blocked: "被阻塞",
|
||||
canceled: "已取消",
|
||||
};
|
||||
|
||||
const STATUS_STYLE: Record<ItemStatus, string> = {
|
||||
inbox: "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",
|
||||
};
|
||||
|
||||
function formatDue(iso: string | null): { text: string; overdue: boolean } | null {
|
||||
function dueLabel(iso: string | null): { text: string; overdue: boolean } | null {
|
||||
if (!iso) return null;
|
||||
const due = new Date(iso);
|
||||
const now = new Date();
|
||||
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() {
|
||||
@@ -49,8 +41,9 @@ export function TasksPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<ItemDetail | null>(null);
|
||||
const draftRef = useRef<HTMLInputElement>(null);
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
|
||||
const current = VIEWS.find((v) => v.key === view);
|
||||
|
||||
const load = useCallback(
|
||||
async (nextCursor?: string | null) => {
|
||||
@@ -78,28 +71,48 @@ export function TasksPage() {
|
||||
void load(null);
|
||||
}, [load]);
|
||||
|
||||
const activeItem = useMemo(() => items[activeIdx] ?? null, [items, activeIdx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openId || !wsId) {
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
void api
|
||||
.get<ItemDetail>(`/workspaces/${wsId}/items/${openId}`)
|
||||
.then(setDetail)
|
||||
.catch(() => setDetail(null));
|
||||
}, [openId, wsId]);
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const typing = ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) || target.isContentEditable;
|
||||
if (e.key === "Escape") {
|
||||
setOpenId(null);
|
||||
(target as HTMLInputElement).blur?.();
|
||||
return;
|
||||
}
|
||||
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() {
|
||||
const title = draft.trim();
|
||||
if (!title || !wsId) return;
|
||||
const raw = draft.trim();
|
||||
if (!raw || !wsId || !current) return;
|
||||
const parsed = parseQuickAdd(raw, current.createAs);
|
||||
try {
|
||||
const created = await api.post<Item>(`/workspaces/${wsId}/items`, {
|
||||
title,
|
||||
status: view === "inbox" ? "inbox" : "todo",
|
||||
title: parsed.title,
|
||||
status: parsed.status,
|
||||
priority: parsed.priority,
|
||||
tag_names: parsed.tagNames,
|
||||
});
|
||||
setDraft("");
|
||||
setItems((prev) => [created, ...prev]);
|
||||
draftRef.current?.focus();
|
||||
if (view !== "done") setItems((prev) => [created, ...prev]);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "创建失败");
|
||||
}
|
||||
@@ -113,7 +126,6 @@ export function TasksPage() {
|
||||
version: item.version,
|
||||
});
|
||||
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)));
|
||||
if (openId === updated.id) setDetail((prev) => (prev ? { ...prev, ...updated } : prev));
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.code === 4009) {
|
||||
setError("这条任务刚被别人改过,已为你刷新");
|
||||
@@ -124,18 +136,23 @@ export function TasksPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const current = VIEWS.find((v) => v.key === view);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<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 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>
|
||||
<Link to="/" className="text-ink-500 hover:text-ink-900">
|
||||
切换工作区
|
||||
@@ -166,9 +183,8 @@ export function TasksPage() {
|
||||
<div className="card mb-4">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={draftRef}
|
||||
className="input flex-1"
|
||||
placeholder="加一件要做的事,回车创建"
|
||||
placeholder="加一件要做的事,回车创建。支持 #标签 !高"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -190,13 +206,19 @@ export function TasksPage() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{items.map((item) => {
|
||||
const due = formatDue(item.due_at);
|
||||
{items.map((item, index) => {
|
||||
const due = dueLabel(item.due_at);
|
||||
const done = item.status === "done";
|
||||
// 收件箱里的东西还没"收下",不给完成勾选框——先归类,再完成。
|
||||
const canComplete = item.status !== "inbox" && item.status !== "canceled";
|
||||
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 ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -211,7 +233,7 @@ export function TasksPage() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
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}
|
||||
</button>
|
||||
@@ -219,6 +241,9 @@ export function TasksPage() {
|
||||
<span className={`rounded px-1.5 py-0.5 ${STATUS_STYLE[item.status]}`}>
|
||||
{STATUS_LABEL[item.status]}
|
||||
</span>
|
||||
{item.priority > 0 && (
|
||||
<span className="text-ink-500">优先级 {PRIORITY_LABEL[item.priority]}</span>
|
||||
)}
|
||||
{due && (
|
||||
<span className={due.overdue && !done ? "text-red-600" : "text-ink-500"}>
|
||||
截止 {due.text}
|
||||
@@ -231,29 +256,6 @@ export function TasksPage() {
|
||||
</span>
|
||||
))}
|
||||
</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 className="flex shrink-0 gap-1">
|
||||
@@ -286,6 +288,18 @@ export function TasksPage() {
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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