From fea78b2d6f0267726656d6358704fa5c52c7e793 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:16:44 -0400 Subject: [PATCH] =?UTF-8?q?feat(tasks):=20=E4=BB=BB=E5=8A=A1=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=94=AF=E6=8C=81=E6=89=B9=E9=87=8F=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 勾选多个任务后可一次性移动到目标分类,或选「未分类」把它们移出当前分类。 - 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 均通过。 --- CHANGELOG.md | 6 + db/task_categories.go | 81 ++++++++++++ db/task_categories_test.go | 92 +++++++++++++ server/manager.go | 43 +++++++ server/server.go | 1 + server/task_categories.go | 112 +++++++++++++--- server/task_categories_test.go | 84 ++++++++++++ server/task_control.go | 59 +++++---- web/src/app/(main)/function/tasks/page.tsx | 143 +++++++++++++++++++-- web/src/lib/api.ts | 7 + web/src/lib/mock/handler.ts | 19 +++ web/src/lib/types.ts | 7 + 12 files changed, 607 insertions(+), 47 deletions(-) create mode 100644 server/task_categories_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f604033..ee389dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ## [Unreleased] +### UI + +#### 新增的功能 + +- 任务列表勾选多个任务后可批量修改分类,目标分类支持「未分类」以将任务移出当前分类;整批写入在一个事务内完成,失败项只会是勾选后已被删除的任务。 + ## [0.3.3] - 2026-08-23 ### Worker diff --git a/db/task_categories.go b/db/task_categories.go index 5a4b30a..e06185c 100644 --- a/db/task_categories.go +++ b/db/task_categories.go @@ -13,6 +13,10 @@ import ( const MaxTaskCategoryNameRunes = 80 +// MaxTaskCategoryBatchSize bounds one batch move so a single request cannot lock +// an unbounded number of task rows. +const MaxTaskCategoryBatchSize = 100 + var ( ErrTaskCategoryInvalid = errors.New("invalid task category") ErrTaskCategoryNameConflict = errors.New("task category name already exists") @@ -199,3 +203,80 @@ FROM selected, updated`, taskID, *categoryID)) } return &category, nil } + +// SetTasksCategory moves several tasks into one category (nil = uncategorized) +// inside a single transaction, so a half-applied batch is never observable. +// It returns the ids that were actually updated — ids missing from that slice +// were deleted between selection and submit — plus the refreshed category row +// whose task_count already reflects this move. +func (d *DB) SetTasksCategory(taskIDs []int64, categoryID *int64) ([]int64, *TaskCategory, error) { + if len(taskIDs) == 0 { + return nil, nil, fmt.Errorf("%w: task ids are required", ErrTaskCategoryInvalid) + } + if len(taskIDs) > MaxTaskCategoryBatchSize { + return nil, nil, fmt.Errorf("%w: at most %d tasks per request", ErrTaskCategoryInvalid, MaxTaskCategoryBatchSize) + } + for _, id := range taskIDs { + if id <= 0 { + return nil, nil, fmt.Errorf("%w: task id must be positive", ErrTaskCategoryInvalid) + } + } + if categoryID != nil && *categoryID <= 0 { + return nil, nil, fmt.Errorf("%w: category id must be positive", ErrTaskCategoryInvalid) + } + tx, err := d.Begin() + if err != nil { + return nil, nil, err + } + defer tx.Rollback() //nolint:errcheck + + // Checking the category inside the transaction keeps a concurrent delete from + // turning the UPDATE below into a foreign key violation. + if categoryID != nil { + var exists bool + if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM task_categories WHERE id=$1)`, *categoryID).Scan(&exists); err != nil { + return nil, nil, err + } + if !exists { + return nil, nil, ErrTaskCategoryNotFound + } + } + rows, err := tx.Query(` +UPDATE tasks SET category_id=$2 +WHERE id=ANY($1::bigint[]) AND deleted_at IS NULL +RETURNING id`, taskIDs, categoryID) + if err != nil { + return nil, nil, err + } + updated := make([]int64, 0, len(taskIDs)) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + rows.Close() + return nil, nil, err + } + updated = append(updated, id) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, nil, err + } + + var category *TaskCategory + if categoryID != nil { + fetched, err := scanTaskCategory(tx.QueryRow(` +SELECT `+taskCategoryCols+` +FROM task_categories category +LEFT JOIN tasks task ON task.category_id=category.id +WHERE category.id=$1 +GROUP BY category.id`, *categoryID)) + if err != nil { + return nil, nil, err + } + category = &fetched + } + if err := tx.Commit(); err != nil { + return nil, nil, err + } + return updated, category, nil +} diff --git a/db/task_categories_test.go b/db/task_categories_test.go index e1d5d60..e409a69 100644 --- a/db/task_categories_test.go +++ b/db/task_categories_test.go @@ -89,3 +89,95 @@ func TestCreateTaskRejectsMissingCategoryAtomically(t *testing.T) { t.Fatalf("failed create leaked %d exploration rows", count) } } + +func TestSetTasksCategoryBatch(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) - skipping", err) + } + defer d.Close() + + stamp := time.Now().UnixNano() + category, err := d.CreateTaskCategory(fmt.Sprintf("Batch %d", stamp)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = d.DeleteTaskCategory(category.ID) }) + + ids := make([]int64, 0, 3) + for i := range 3 { + task, err := d.CreateTaskWithOptions(fmt.Sprintf("batch-move-%d-%d", stamp, i), "goal", TaskCreateOptions{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.DeleteTask(task.ID) }) + ids = append(ids, task.ID) + } + + // A deleted id must not abort the move for the surviving tasks; it is simply + // absent from the returned slice so the caller can report it. + missing := int64(1 << 62) + updated, moved, err := d.SetTasksCategory(append(append([]int64{}, ids...), missing), &category.ID) + if err != nil { + t.Fatal(err) + } + if len(updated) != len(ids) { + t.Fatalf("updated=%v, want the %d live ids only", updated, len(ids)) + } + if moved == nil || moved.TaskCount != len(ids) { + t.Fatalf("category=%+v, want task_count=%d", moved, len(ids)) + } + for _, id := range ids { + loaded, err := d.GetTask(id) + if err != nil || loaded.CategoryID == nil || *loaded.CategoryID != category.ID { + t.Fatalf("task %d not moved: %+v err=%v", id, loaded, err) + } + } + + // A nil category clears the assignment for the whole batch. + updated, moved, err = d.SetTasksCategory(ids, nil) + if err != nil || len(updated) != len(ids) || moved != nil { + t.Fatalf("clear updated=%v category=%+v err=%v", updated, moved, err) + } + for _, id := range ids { + loaded, _ := d.GetTask(id) + if loaded.CategoryID != nil || loaded.CategoryName != "" { + t.Fatalf("task %d still categorized: %+v", id, loaded) + } + } +} + +func TestSetTasksCategoryRejectsBadInputAtomically(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) - skipping", err) + } + defer d.Close() + + stamp := time.Now().UnixNano() + task, err := d.CreateTaskWithOptions(fmt.Sprintf("batch-reject-%d", stamp), "goal", TaskCreateOptions{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.DeleteTask(task.ID) }) + + missingCategory := int64(1 << 62) + if _, _, err := d.SetTasksCategory([]int64{task.ID}, &missingCategory); !errors.Is(err, ErrTaskCategoryNotFound) { + t.Fatalf("missing category error=%v, want %v", err, ErrTaskCategoryNotFound) + } + if _, _, err := d.SetTasksCategory(nil, nil); !errors.Is(err, ErrTaskCategoryInvalid) { + t.Fatalf("empty ids error=%v, want %v", err, ErrTaskCategoryInvalid) + } + oversized := make([]int64, MaxTaskCategoryBatchSize+1) + for i := range oversized { + oversized[i] = task.ID + } + if _, _, err := d.SetTasksCategory(oversized, nil); !errors.Is(err, ErrTaskCategoryInvalid) { + t.Fatalf("oversized error=%v, want %v", err, ErrTaskCategoryInvalid) + } + // The rejected calls must leave the task untouched. + loaded, _ := d.GetTask(task.ID) + if loaded.CategoryID != nil { + t.Fatalf("rejected batch mutated task: %+v", loaded) + } +} diff --git a/server/manager.go b/server/manager.go index 46cf066..41a6bae 100644 --- a/server/manager.go +++ b/server/manager.go @@ -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 diff --git a/server/server.go b/server/server.go index 219ab50..b7ad774 100644 --- a/server/server.go +++ b/server/server.go @@ -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) diff --git a/server/task_categories.go b/server/task_categories.go index 9cca440..e176312 100644 --- a/server/task_categories.go +++ b/server/task_categories.go @@ -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}) +} diff --git a/server/task_categories_test.go b/server/task_categories_test.go new file mode 100644 index 0000000..64f721a --- /dev/null +++ b/server/task_categories_test.go @@ -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) + } +} diff --git a/server/task_control.go b/server/task_control.go index 4e61f57..3f09087 100644 --- a/server/task_control.go +++ b/server/task_control.go @@ -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 diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx index 155b365..26bb14f 100644 --- a/web/src/app/(main)/function/tasks/page.tsx +++ b/web/src/app/(main)/function/tasks/page.tsx @@ -8,6 +8,7 @@ import { CheckIcon, ChevronRightIcon, EyeIcon, + FolderInputIcon, FolderKanbanIcon, Loader2Icon, PaperclipIcon, @@ -203,6 +204,10 @@ const STATUS_OPTIONS: { value: TaskStatus; label: string }[] = [ { value: "timeout", label: "已超时" }, ]; +// Select 不接受空字符串 value,所以「无分类」在筛选器、新建表单和批量移动里 +// 统一用这个哨兵值,提交时再翻译成后端的 null。 +const UNCATEGORIZED_VALUE = "uncategorized"; + // 可暂停 = 非终态且未暂停,与后端 applyTaskControlWithCause 的门控一致 // (done/failed/timeout 为终态);paused 才可继续。行内按钮与批量控制共用此判断, // 两边不会出现一个可点、另一个不可点的分歧。 @@ -224,13 +229,18 @@ export default function TasksPage() { const [pageSize, setPageSize] = React.useState(20); const [nowSec, setNowSec] = React.useState(() => Math.floor(Date.now() / 1000)); const [batchControlling, setBatchControlling] = React.useState<"pause" | "resume" | null>(null); + const [movingCategory, setMovingCategory] = React.useState(false); const filtered = React.useMemo(() => { const q = query.trim().toLowerCase(); return tasks.filter((t) => { if (statusFilter !== "all" && t.status !== statusFilter) return false; - if (categoryFilter === "uncategorized" && t.category_id != null) return false; - if (categoryFilter !== "all" && categoryFilter !== "uncategorized" && String(t.category_id) !== categoryFilter) + if (categoryFilter === UNCATEGORIZED_VALUE && t.category_id != null) return false; + if ( + categoryFilter !== "all" && + categoryFilter !== UNCATEGORIZED_VALUE && + String(t.category_id) !== categoryFilter + ) return false; if (!q) return true; return ( @@ -344,7 +354,7 @@ export default function TasksPage() { }, [load, loadCategories]); React.useEffect(() => { - if (categoryFilter === "all" || categoryFilter === "uncategorized") return; + if (categoryFilter === "all" || categoryFilter === UNCATEGORIZED_VALUE) return; if (!categories.some((category) => String(category.id) === categoryFilter)) setCategoryFilter("all"); }, [categories, categoryFilter]); @@ -505,6 +515,42 @@ export default function TasksPage() { [batchControlling, load], ); + // 后端把整批分类写入放在一个事务里,所以失败项只可能是勾选后又被删掉的任务。 + const moveSelectedTasksCategory = React.useCallback( + async (categoryID?: number) => { + const ids = [...selectedIds]; + if (ids.length === 0 || movingCategory) return; + if (ids.length > 100) { + toast.error("一次最多修改 100 个任务的分类"); + return; + } + setMovingCategory(true); + try { + const result = await api.updateTasksCategory(ids, categoryID); + const succeeded = result.items.filter((item) => item.ok); + const failed = result.items.filter((item) => !item.ok); + const target = result.category?.name ?? "未分类"; + if (succeeded.length > 0) { + toast.success(`已将 ${succeeded.length} 个任务移动到「${target}」`); + setSelectedIds(new Set()); + } + if (failed.length > 0) { + const details = failed + .slice(0, 3) + .map((item) => `#${item.id}(${item.error || "任务已不存在"})`) + .join(";"); + toast.error(`${failed.length} 个任务未能移动:${details}${failed.length > 3 ? " 等" : ""}`); + } + refreshCategoriesAndTasks(); + } catch (error) { + toast.error(`修改分类失败:${(error as Error).message}`); + } finally { + setMovingCategory(false); + } + }, + [movingCategory, refreshCategoriesAndTasks, selectedIds], + ); + return ( @@ -550,7 +596,7 @@ export default function TasksPage() { 全部分类 - 未分类 + 未分类 {categories.map((category) => ( {category.name} @@ -598,6 +644,12 @@ export default function TasksPage() { 继续 {resumableTaskIDs.length} )} + )} @@ -1112,6 +1164,81 @@ function DeleteTaskDialog({ // BulkDeleteTasksDialog deletes every checked task with one shared set of cleanup options. // The backend has no batch endpoint, so deletion runs one task at a time (see deleteTasks) // and the button shows live progress. +// MoveTasksCategoryDialog confirms a target before applying, so a mis-click on a +// large selection cannot silently re-file every task. UNCATEGORIZED_VALUE stands +// in for "no category" because Select rejects an empty string value. +function MoveTasksCategoryDialog({ + categories, + count, + moving, + onMove, +}: { + categories: TaskCategory[]; + count: number; + moving: boolean; + onMove: (categoryID?: number) => Promise; +}) { + const [open, setOpen] = React.useState(false); + const [target, setTarget] = React.useState(UNCATEGORIZED_VALUE); + + const handleOpenChange = (next: boolean) => { + if (moving) return; + setOpen(next); + if (next) setTarget(UNCATEGORIZED_VALUE); + }; + + return ( + + + + + + + 修改所选 {count} 个任务的分类 + 目标分类对所选任务统一生效;选「未分类」会把它们移出当前分类。 + + + 目标分类 + + {categories.length === 0 && 还没有任何分类,先用「分类管理」创建一个。} + + + + + + + + + + ); +} + function BulkDeleteTasksDialog({ ids, onDelete, @@ -1527,7 +1654,7 @@ function CreateTaskSheet({ }) { const [open, setOpen] = React.useState(false); const [name, setName] = React.useState(""); - const [categoryID, setCategoryID] = React.useState("uncategorized"); + const [categoryID, setCategoryID] = React.useState(UNCATEGORIZED_VALUE); const [description, setDescription] = React.useState(""); const [goal, setGoal] = React.useState(""); const [selectedTemplateID, setSelectedTemplateID] = React.useState(null); @@ -1597,7 +1724,7 @@ function CreateTaskSheet({ const heartbeatSec = Math.max(10, Math.floor(Number(heartbeatMin) || 10)) * 60; // 下限 10min,与后端归一一致 await api.createTask({ name: name.trim(), - categoryId: categoryID === "uncategorized" ? undefined : Number(categoryID), + categoryId: categoryID === UNCATEGORIZED_VALUE ? undefined : Number(categoryID), description: description.trim(), goal: goal.trim(), llmProfileIds: llmProfileIDs.map(Number), @@ -1610,7 +1737,7 @@ function CreateTaskSheet({ }); toast.success("任务已创建"); setName(""); - setCategoryID("uncategorized"); + setCategoryID(UNCATEGORIZED_VALUE); setDescription(""); setGoal(""); setSelectedTemplateID(null); @@ -1682,7 +1809,7 @@ function CreateTaskSheet({ - 未分类 + 未分类 {categories.map((category) => ( {category.name} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 927dce4..aaceaff 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -12,6 +12,7 @@ import type { AgentTrigger, Asset, Audit, + BatchCategoryItem, BatchControlItem, ChatAttachment, CommandRecord, @@ -218,6 +219,12 @@ export const api = { deleteTaskCategory: (id: number) => del<{ deleted: number }>(`/task-categories/${id}`), updateTaskCategory: (taskId: string, categoryId?: number) => patch(`/tasks/${taskId}/category`, { category_id: categoryId ?? null }), + // categoryId 省略/undefined = 移出分类(后端收到 null) + updateTasksCategory: (taskIds: string[], categoryId?: number) => + post<{ items: BatchCategoryItem[]; category: TaskCategory | null }>("/tasks/category/batch", { + task_ids: taskIds, + category_id: categoryId ?? null, + }), taskTemplates: () => get<{ templates: TaskTemplate[] }>("/task-templates").then((r) => arr(r.templates)), createTaskTemplate: (input: Pick) => post("/task-templates", input), diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index cb3e260..8dfb02d 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -356,6 +356,25 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re } return { deleted: categoryID }; } + if (path === "/tasks/category/batch" && m === "POST") { + const requested = Array.isArray(b.task_ids) ? b.task_ids.map(String) : []; + const taskIDs = [...new Set(requested)]; + if (taskIDs.length === 0 || taskIDs.length > 100) throw new Error("task_ids 数量必须为 1-100"); + const categoryID = typeof b.category_id === "number" ? b.category_id : undefined; + const category = categoryID === undefined ? undefined : mockTaskCategories.find((item) => item.id === categoryID); + if (categoryID !== undefined && !category) throw new Error("任务分类不存在"); + const items = taskIDs.map((id) => { + const task = mockTasks.find((item) => item.id === id); + if (!task) return { id, ok: false, error: "task not found" }; + task.category_id = category?.id; + task.category_name = category?.name; + return { id, ok: true }; + }); + return { + items, + category: category ? mockTaskCategorySnapshot().find((item) => item.id === category.id) : null, + }; + } if (seg[0] === "tasks" && seg[2] === "category" && seg.length === 3 && m === "PATCH") { const task = mockTasks.find((item) => item.id === seg[1]); if (!task) throw new Error("任务不存在"); diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 238939d..87d8381 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -624,6 +624,13 @@ export interface BatchControlItem { error?: string; } +// 批量改分类的逐任务结果。失败只可能是任务已被删除,分类本身的写入是原子的。 +export interface BatchCategoryItem { + id: string; + ok: boolean; + error?: string; +} + // Whole-task (all agents) token aggregate. export interface TokenTotal { input_tokens: number;