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

+43
View File
@@ -812,6 +812,49 @@ func (m *Manager) SetTaskCategory(taskID string, categoryID *int64) (*pgdb.TaskC
return category, nil
}
// SetTasksCategory applies one category change to several tasks at once. The
// database write and the in-memory refresh share taskStateMu, so a concurrent
// single-task update cannot interleave and leave a live DTO stale. The returned
// set holds the ids that were actually moved; callers report the rest as gone.
func (m *Manager) SetTasksCategory(taskIDs []string, categoryID *int64) (map[string]bool, *pgdb.TaskCategory, error) {
m.taskStateMu.Lock()
defer m.taskStateMu.Unlock()
numeric := make([]int64, 0, len(taskIDs))
for _, taskID := range taskIDs {
id, err := strconv.ParseInt(taskID, 10, 64)
if err != nil || id <= 0 {
return nil, nil, pgdb.ErrTaskCategoryTaskNotFound
}
numeric = append(numeric, id)
}
updatedIDs, category, err := m.pg.SetTasksCategory(numeric, categoryID)
if err != nil {
return nil, nil, err
}
categoryName := ""
if category != nil {
categoryName = category.Name
}
updated := make(map[string]bool, len(updatedIDs))
m.mu.RLock()
tasks := make([]*Task, 0, len(updatedIDs))
for _, id := range updatedIDs {
taskID := strconv.FormatInt(id, 10)
updated[taskID] = true
if task := m.tasks[taskID]; task != nil {
tasks = append(tasks, task)
}
}
m.mu.RUnlock()
for _, task := range tasks {
task.updateLifecycle(func(state *taskLifecycleState) {
state.CategoryID = cloneInt64Ptr(categoryID)
state.CategoryName = categoryName
})
}
return updated, category, nil
}
// DeleteCompanyWithAssets keeps the database cascade and live task handles in
// one manager-level critical section. This closes the gap where a task could
// commit its company scope immediately before registration and miss the
+1
View File
@@ -650,6 +650,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/task-categories", s.pgCreateTaskCategory)
mux.HandleFunc("PATCH /api/task-categories/{id}", s.pgRenameTaskCategory)
mux.HandleFunc("DELETE /api/task-categories/{id}", s.pgDeleteTaskCategory)
mux.HandleFunc("POST /api/tasks/category/batch", s.updateTasksCategoryBatch)
mux.HandleFunc("GET /api/task-templates", s.pgListTaskTemplates)
mux.HandleFunc("POST /api/task-templates", s.pgCreateTaskTemplate)
mux.HandleFunc("PATCH /api/task-templates/{id}", s.pgUpdateTaskTemplate)
+97 -15
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"unicode/utf8"
@@ -119,6 +120,26 @@ func (s *Server) pgDeleteTaskCategory(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
}
// parseCategoryIDField reads the category_id field shared by the single-task and
// batch endpoints. The field is required but may be null — that clears the
// category — so it arrives as RawMessage instead of *int64, which cannot tell
// an explicit null from an omitted key.
func parseCategoryIDField(w http.ResponseWriter, raw json.RawMessage) (*int64, bool) {
if len(raw) == 0 {
writeErr(w, http.StatusBadRequest, "category_id is required")
return nil, false
}
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return nil, true
}
var id int64
if err := json.Unmarshal(raw, &id); err != nil || id <= 0 {
writeErr(w, http.StatusBadRequest, "任务分类 id 无效")
return nil, false
}
return &id, true
}
func (s *Server) updateTaskCategory(w http.ResponseWriter, r *http.Request) {
taskID := r.PathValue("id")
if _, ok := s.m.Task(taskID); !ok {
@@ -133,21 +154,8 @@ func (s *Server) updateTaskCategory(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if len(request.CategoryID) == 0 {
writeErr(w, http.StatusBadRequest, "category_id is required")
return
}
var categoryID *int64
if !bytes.Equal(bytes.TrimSpace(request.CategoryID), []byte("null")) {
var id int64
if err := json.Unmarshal(request.CategoryID, &id); err != nil {
writeErr(w, http.StatusBadRequest, "任务分类 id 无效")
return
}
categoryID = &id
}
if categoryID != nil && *categoryID <= 0 {
writeErr(w, http.StatusBadRequest, "任务分类 id 无效")
categoryID, ok := parseCategoryIDField(w, request.CategoryID)
if !ok {
return
}
if _, err := s.m.SetTaskCategory(taskID, categoryID); err != nil {
@@ -157,3 +165,77 @@ func (s *Server) updateTaskCategory(w http.ResponseWriter, r *http.Request) {
task, _ := s.m.Task(taskID)
writeJSON(w, http.StatusOK, taskDTO(task, s.resolvedTaskStatus(task)))
}
// batchCategoryItem reports one requested task. Unlike batch pause/resume there
// is no per-task runtime outcome, so only the failure reason is carried.
type batchCategoryItem struct {
ID string `json:"id"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
// updateTasksCategoryBatch moves every selected task into one category, or
// clears it when category_id is null. The database write is atomic; per-task
// entries only distinguish ids that never resolved to a live task, so a partial
// success here means those tasks were deleted, not that the move half-applied.
func (s *Server) updateTasksCategoryBatch(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxTaskCategoryRequestBytes)
var request struct {
TaskIDs []string `json:"task_ids"`
CategoryID json.RawMessage `json:"category_id"`
}
if err := decode(r, &request); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
writeErr(w, http.StatusRequestEntityTooLarge, "请求正文过大")
} else {
writeErr(w, http.StatusBadRequest, err.Error())
}
return
}
categoryID, ok := parseCategoryIDField(w, request.CategoryID)
if !ok {
return
}
taskIDs := normalizeBatchTaskIDs(request.TaskIDs)
if len(taskIDs) == 0 || len(taskIDs) > db.MaxTaskCategoryBatchSize {
writeErr(w, http.StatusBadRequest, fmt.Sprintf("task_ids 数量必须为 1-%d", db.MaxTaskCategoryBatchSize))
return
}
items := make([]batchCategoryItem, 0, len(taskIDs))
pending := make([]string, 0, len(taskIDs))
for _, parsed := range taskIDs {
item := batchCategoryItem{ID: parsed.id}
switch {
case !parsed.valid:
item.Error = "bad task id"
default:
if _, live := s.m.Task(parsed.id); live {
pending = append(pending, parsed.id)
} else {
item.Error = "task not found"
}
}
items = append(items, item)
}
var category *db.TaskCategory
if len(pending) > 0 {
updated, moved, err := s.m.SetTasksCategory(pending, categoryID)
if err != nil {
writeTaskCategoryError(w, err)
return
}
category = moved
for i := range items {
if items[i].Error != "" {
continue
}
if updated[items[i].ID] {
items[i].OK = true
} else {
items[i].Error = "task not found"
}
}
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "category": category})
}
+84
View File
@@ -0,0 +1,84 @@
package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestTaskCategoryBatchRoute pins the contract of the batch move endpoint: it is
// registered ahead of the /api/tasks/{id}/... patterns, it validates the request
// before touching the database, and it reports unknown ids per task instead of
// failing the whole batch.
func TestTaskCategoryBatchRoute(t *testing.T) {
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
defer m.Close()
td := t.TempDir()
s := New(context.Background(), m, td, td, td)
h := s.Handler()
token, err := signJWT(s.jwtKey)
if err != nil {
t.Fatalf("sign jwt: %v", err)
}
post := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/api/tasks/category/batch", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// category_id is required, and omitting it must not be read as "uncategorize".
if rec := post(`{"task_ids":["1"]}`); rec.Code != http.StatusBadRequest ||
!strings.Contains(rec.Body.String(), "category_id is required") {
t.Fatalf("missing category_id: status=%d body=%s", rec.Code, rec.Body.String())
}
// An empty selection is rejected before any database work.
if rec := post(`{"task_ids":[],"category_id":null}`); rec.Code != http.StatusBadRequest ||
!strings.Contains(rec.Body.String(), "task_ids 数量必须为") {
t.Fatalf("empty task_ids: status=%d body=%s", rec.Code, rec.Body.String())
}
// Unknown and malformed ids come back as per-task failures on a 200, so the
// caller can tell exactly which selections went stale.
rec := post(`{"task_ids":["999999999","abc","999999999"],"category_id":null}`)
if rec.Code != http.StatusOK {
t.Fatalf("batch status=%d body=%s", rec.Code, rec.Body.String())
}
var response struct {
Items []struct {
ID string `json:"id"`
OK bool `json:"ok"`
Error string `json:"error"`
} `json:"items"`
Category *struct {
ID int64 `json:"id"`
} `json:"category"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("decode: %v body=%s", err, rec.Body.String())
}
// The duplicate id is collapsed, so two entries remain.
if len(response.Items) != 2 {
t.Fatalf("items=%+v, want 2 after de-duplication", response.Items)
}
if response.Items[0].ID != "999999999" || response.Items[0].OK || response.Items[0].Error != "task not found" {
t.Fatalf("unknown id entry=%+v", response.Items[0])
}
if response.Items[1].ID != "abc" || response.Items[1].OK || response.Items[1].Error != "bad task id" {
t.Fatalf("malformed id entry=%+v", response.Items[1])
}
if response.Category != nil {
t.Fatalf("category=%+v, want null when clearing", response.Category)
}
}
+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