feat(tasks): 任务列表支持批量修改分类

勾选多个任务后可一次性移动到目标分类,或选「未分类」把它们移出当前分类。

- db: 新增 SetTasksCategory,在单个事务里校验分类存在性并 UPDATE ... WHERE
  id=ANY(...),返回真正被更新的 id 和刷新后的分类(含 task_count)。分类检查
  放在事务内,避免并发删除把 UPDATE 变成外键冲突。单批上限 100。
- server: 新增 POST /api/tasks/category/batch。写入原子,响应里的逐任务失败
  只可能是勾选后又被删掉的任务;顺带把 category_id 的解析(必填但可为 null)
  抽成 parseCategoryIDField 与单任务接口共用。
- server: 把批量 id 的 trim/归一/去重逻辑从 controlTasksBatch 抽成
  normalizeBatchTaskIDs,两个批量端点共用同一套重复判定。
- manager: SetTasksCategory 在 taskStateMu 内完成 DB 写入与运行中任务 DTO
  刷新,防止与单任务更新交错后留下过期的 category_name。
- web: 选中工具栏新增「修改分类」对话框(需确认目标,避免误点批量改动);
  「未分类」哨兵值统一提取为 UNCATEGORIZED_VALUE。

验证: go build / vet / gofmt、db 与 server 新增测试连本地 PG 实跑通过、
tsc --noEmit、biome check、next build 均通过。
This commit is contained in:
Autumn-27 committed 2026-08-24 09:16:44 -04:00
1 parent 3eca390089
commit fea78b2d6f
12 files changed
+607 -47

No files matched your search

+35 -24
View File
@@ -27,6 +27,40 @@ type intentControlResult struct {
Deleted *db.IntentCleanup `json:"deleted,omitempty"`
}
// parsedTaskID carries one requested batch id together with whether it parsed.
// Invalid ids are kept rather than dropped so the response can name them.
type parsedTaskID struct {
id string
valid bool
}
// normalizeBatchTaskIDs trims, canonicalizes and de-duplicates the ids of one
// batch request while preserving the caller's order. Shared by every batch
// endpoint so they agree on what counts as a duplicate.
func normalizeBatchTaskIDs(raw []string) []parsedTaskID {
seen := map[string]bool{}
seenInvalid := map[string]bool{}
taskIDs := make([]parsedTaskID, 0, len(raw))
for _, item := range raw {
trimmed := strings.TrimSpace(item)
id, valid := canonicalTaskID(trimmed)
if !valid {
if seenInvalid[trimmed] {
continue
}
seenInvalid[trimmed] = true
taskIDs = append(taskIDs, parsedTaskID{id: trimmed})
continue
}
if seen[id] {
continue
}
seen[id] = true
taskIDs = append(taskIDs, parsedTaskID{id: id, valid: true})
}
return taskIDs
}
type batchControlItem struct {
ID string `json:"id"`
OK bool `json:"ok"`
@@ -192,30 +226,7 @@ func (s *Server) controlTasksBatch(w http.ResponseWriter, r *http.Request) {
writeErr(w, 400, "action must be pause|resume")
return
}
type parsedTaskID struct {
id string
valid bool
}
seen := map[string]bool{}
seenInvalid := map[string]bool{}
taskIDs := make([]parsedTaskID, 0, len(req.TaskIDs))
for _, raw := range req.TaskIDs {
trimmed := strings.TrimSpace(raw)
id, valid := canonicalTaskID(trimmed)
if !valid {
if seenInvalid[trimmed] {
continue
}
seenInvalid[trimmed] = true
taskIDs = append(taskIDs, parsedTaskID{id: trimmed})
continue
}
if seen[id] {
continue
}
seen[id] = true
taskIDs = append(taskIDs, parsedTaskID{id: id, valid: true})
}
taskIDs := normalizeBatchTaskIDs(req.TaskIDs)
if len(taskIDs) == 0 || len(taskIDs) > maxBatchControlIDs {
writeErr(w, 400, fmt.Sprintf("task_ids 数量必须为 1-%d", maxBatchControlIDs))
return