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:
1 parent
3eca390089
commit
fea78b2d6f
12 files changed
+607
-47
No files matched your search
@@ -4,6 +4,12 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
#### 新增的功能
|
||||||
|
|
||||||
|
- 任务列表勾选多个任务后可批量修改分类,目标分类支持「未分类」以将任务移出当前分类;整批写入在一个事务内完成,失败项只会是勾选后已被删除的任务。
|
||||||
|
|
||||||
## [0.3.3] - 2026-08-23
|
## [0.3.3] - 2026-08-23
|
||||||
|
|
||||||
### Worker
|
### Worker
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import (
|
|||||||
|
|
||||||
const MaxTaskCategoryNameRunes = 80
|
const MaxTaskCategoryNameRunes = 80
|
||||||
|
|
||||||
|
// MaxTaskCategoryBatchSize bounds one batch move so a single request cannot lock
|
||||||
|
// an unbounded number of task rows.
|
||||||
|
const MaxTaskCategoryBatchSize = 100
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrTaskCategoryInvalid = errors.New("invalid task category")
|
ErrTaskCategoryInvalid = errors.New("invalid task category")
|
||||||
ErrTaskCategoryNameConflict = errors.New("task category name already exists")
|
ErrTaskCategoryNameConflict = errors.New("task category name already exists")
|
||||||
@@ -199,3 +203,80 @@ FROM selected, updated`, taskID, *categoryID))
|
|||||||
}
|
}
|
||||||
return &category, nil
|
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
|
||||||
|
}
|
||||||
@@ -89,3 +89,95 @@ func TestCreateTaskRejectsMissingCategoryAtomically(t *testing.T) {
|
|||||||
t.Fatalf("failed create leaked %d exploration rows", count)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -812,6 +812,49 @@ func (m *Manager) SetTaskCategory(taskID string, categoryID *int64) (*pgdb.TaskC
|
|||||||
return category, nil
|
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
|
// DeleteCompanyWithAssets keeps the database cascade and live task handles in
|
||||||
// one manager-level critical section. This closes the gap where a task could
|
// one manager-level critical section. This closes the gap where a task could
|
||||||
// commit its company scope immediately before registration and miss the
|
// commit its company scope immediately before registration and miss the
|
||||||
|
|||||||
@@ -650,6 +650,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("POST /api/task-categories", s.pgCreateTaskCategory)
|
mux.HandleFunc("POST /api/task-categories", s.pgCreateTaskCategory)
|
||||||
mux.HandleFunc("PATCH /api/task-categories/{id}", s.pgRenameTaskCategory)
|
mux.HandleFunc("PATCH /api/task-categories/{id}", s.pgRenameTaskCategory)
|
||||||
mux.HandleFunc("DELETE /api/task-categories/{id}", s.pgDeleteTaskCategory)
|
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("GET /api/task-templates", s.pgListTaskTemplates)
|
||||||
mux.HandleFunc("POST /api/task-templates", s.pgCreateTaskTemplate)
|
mux.HandleFunc("POST /api/task-templates", s.pgCreateTaskTemplate)
|
||||||
mux.HandleFunc("PATCH /api/task-templates/{id}", s.pgUpdateTaskTemplate)
|
mux.HandleFunc("PATCH /api/task-templates/{id}", s.pgUpdateTaskTemplate)
|
||||||
|
|||||||
+97
-15
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"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})
|
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) {
|
func (s *Server) updateTaskCategory(w http.ResponseWriter, r *http.Request) {
|
||||||
taskID := r.PathValue("id")
|
taskID := r.PathValue("id")
|
||||||
if _, ok := s.m.Task(taskID); !ok {
|
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())
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(request.CategoryID) == 0 {
|
categoryID, ok := parseCategoryIDField(w, request.CategoryID)
|
||||||
writeErr(w, http.StatusBadRequest, "category_id is required")
|
if !ok {
|
||||||
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 无效")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := s.m.SetTaskCategory(taskID, categoryID); err != nil {
|
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)
|
task, _ := s.m.Task(taskID)
|
||||||
writeJSON(w, http.StatusOK, taskDTO(task, s.resolvedTaskStatus(task)))
|
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})
|
||||||
|
}
|
||||||
@@ -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
@@ -27,6 +27,40 @@ type intentControlResult struct {
|
|||||||
Deleted *db.IntentCleanup `json:"deleted,omitempty"`
|
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 {
|
type batchControlItem struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
OK bool `json:"ok"`
|
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")
|
writeErr(w, 400, "action must be pause|resume")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
type parsedTaskID struct {
|
taskIDs := normalizeBatchTaskIDs(req.TaskIDs)
|
||||||
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})
|
|
||||||
}
|
|
||||||
if len(taskIDs) == 0 || len(taskIDs) > maxBatchControlIDs {
|
if len(taskIDs) == 0 || len(taskIDs) > maxBatchControlIDs {
|
||||||
writeErr(w, 400, fmt.Sprintf("task_ids 数量必须为 1-%d", maxBatchControlIDs))
|
writeErr(w, 400, fmt.Sprintf("task_ids 数量必须为 1-%d", maxBatchControlIDs))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
CheckIcon,
|
CheckIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
|
FolderInputIcon,
|
||||||
FolderKanbanIcon,
|
FolderKanbanIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
PaperclipIcon,
|
PaperclipIcon,
|
||||||
@@ -203,6 +204,10 @@ const STATUS_OPTIONS: { value: TaskStatus; label: string }[] = [
|
|||||||
{ value: "timeout", label: "已超时" },
|
{ value: "timeout", label: "已超时" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Select 不接受空字符串 value,所以「无分类」在筛选器、新建表单和批量移动里
|
||||||
|
// 统一用这个哨兵值,提交时再翻译成后端的 null。
|
||||||
|
const UNCATEGORIZED_VALUE = "uncategorized";
|
||||||
|
|
||||||
// 可暂停 = 非终态且未暂停,与后端 applyTaskControlWithCause 的门控一致
|
// 可暂停 = 非终态且未暂停,与后端 applyTaskControlWithCause 的门控一致
|
||||||
// (done/failed/timeout 为终态);paused 才可继续。行内按钮与批量控制共用此判断,
|
// (done/failed/timeout 为终态);paused 才可继续。行内按钮与批量控制共用此判断,
|
||||||
// 两边不会出现一个可点、另一个不可点的分歧。
|
// 两边不会出现一个可点、另一个不可点的分歧。
|
||||||
@@ -224,13 +229,18 @@ export default function TasksPage() {
|
|||||||
const [pageSize, setPageSize] = React.useState(20);
|
const [pageSize, setPageSize] = React.useState(20);
|
||||||
const [nowSec, setNowSec] = React.useState(() => Math.floor(Date.now() / 1000));
|
const [nowSec, setNowSec] = React.useState(() => Math.floor(Date.now() / 1000));
|
||||||
const [batchControlling, setBatchControlling] = React.useState<"pause" | "resume" | null>(null);
|
const [batchControlling, setBatchControlling] = React.useState<"pause" | "resume" | null>(null);
|
||||||
|
const [movingCategory, setMovingCategory] = React.useState(false);
|
||||||
|
|
||||||
const filtered = React.useMemo(() => {
|
const filtered = React.useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
return tasks.filter((t) => {
|
return tasks.filter((t) => {
|
||||||
if (statusFilter !== "all" && t.status !== statusFilter) return false;
|
if (statusFilter !== "all" && t.status !== statusFilter) return false;
|
||||||
if (categoryFilter === "uncategorized" && t.category_id != null) return false;
|
if (categoryFilter === UNCATEGORIZED_VALUE && t.category_id != null) return false;
|
||||||
if (categoryFilter !== "all" && categoryFilter !== "uncategorized" && String(t.category_id) !== categoryFilter)
|
if (
|
||||||
|
categoryFilter !== "all" &&
|
||||||
|
categoryFilter !== UNCATEGORIZED_VALUE &&
|
||||||
|
String(t.category_id) !== categoryFilter
|
||||||
|
)
|
||||||
return false;
|
return false;
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
return (
|
return (
|
||||||
@@ -344,7 +354,7 @@ export default function TasksPage() {
|
|||||||
}, [load, loadCategories]);
|
}, [load, loadCategories]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
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");
|
if (!categories.some((category) => String(category.id) === categoryFilter)) setCategoryFilter("all");
|
||||||
}, [categories, categoryFilter]);
|
}, [categories, categoryFilter]);
|
||||||
|
|
||||||
@@ -505,6 +515,42 @@ export default function TasksPage() {
|
|||||||
[batchControlling, load],
|
[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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col gap-4 px-0 pt-6">
|
<CardContent className="flex flex-col gap-4 px-0 pt-6">
|
||||||
@@ -550,7 +596,7 @@ export default function TasksPage() {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectItem value="all">全部分类</SelectItem>
|
<SelectItem value="all">全部分类</SelectItem>
|
||||||
<SelectItem value="uncategorized">未分类</SelectItem>
|
<SelectItem value={UNCATEGORIZED_VALUE}>未分类</SelectItem>
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<SelectItem key={category.id} value={String(category.id)}>
|
<SelectItem key={category.id} value={String(category.id)}>
|
||||||
{category.name}
|
{category.name}
|
||||||
@@ -598,6 +644,12 @@ export default function TasksPage() {
|
|||||||
继续 {resumableTaskIDs.length}
|
继续 {resumableTaskIDs.length}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<MoveTasksCategoryDialog
|
||||||
|
categories={categories}
|
||||||
|
count={selectedIds.size}
|
||||||
|
moving={movingCategory}
|
||||||
|
onMove={moveSelectedTasksCategory}
|
||||||
|
/>
|
||||||
<BulkDeleteTasksDialog ids={[...selectedIds]} onDelete={deleteTasks} />
|
<BulkDeleteTasksDialog ids={[...selectedIds]} onDelete={deleteTasks} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1112,6 +1164,81 @@ function DeleteTaskDialog({
|
|||||||
// BulkDeleteTasksDialog deletes every checked task with one shared set of cleanup options.
|
// 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)
|
// The backend has no batch endpoint, so deletion runs one task at a time (see deleteTasks)
|
||||||
// and the button shows live progress.
|
// 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<void>;
|
||||||
|
}) {
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
<FolderInputIcon data-icon="inline-start" /> 修改分类
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>修改所选 {count} 个任务的分类</DialogTitle>
|
||||||
|
<DialogDescription>目标分类对所选任务统一生效;选「未分类」会把它们移出当前分类。</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="bulk-category">目标分类</FieldLabel>
|
||||||
|
<Select value={target} onValueChange={setTarget} disabled={moving}>
|
||||||
|
<SelectTrigger id="bulk-category" className="w-full">
|
||||||
|
<SelectValue placeholder="选择分类" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value={UNCATEGORIZED_VALUE}>未分类</SelectItem>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<SelectItem key={category.id} value={String(category.id)}>
|
||||||
|
{category.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{categories.length === 0 && <FieldDescription>还没有任何分类,先用「分类管理」创建一个。</FieldDescription>}
|
||||||
|
</Field>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline" disabled={moving}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button
|
||||||
|
disabled={moving}
|
||||||
|
onClick={() => {
|
||||||
|
void onMove(target === UNCATEGORIZED_VALUE ? undefined : Number(target)).then(() => setOpen(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{moving && <Spinner data-icon="inline-start" />}
|
||||||
|
移动
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function BulkDeleteTasksDialog({
|
function BulkDeleteTasksDialog({
|
||||||
ids,
|
ids,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -1527,7 +1654,7 @@ function CreateTaskSheet({
|
|||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [name, setName] = React.useState("");
|
const [name, setName] = React.useState("");
|
||||||
const [categoryID, setCategoryID] = React.useState("uncategorized");
|
const [categoryID, setCategoryID] = React.useState(UNCATEGORIZED_VALUE);
|
||||||
const [description, setDescription] = React.useState("");
|
const [description, setDescription] = React.useState("");
|
||||||
const [goal, setGoal] = React.useState("");
|
const [goal, setGoal] = React.useState("");
|
||||||
const [selectedTemplateID, setSelectedTemplateID] = React.useState<number | null>(null);
|
const [selectedTemplateID, setSelectedTemplateID] = React.useState<number | null>(null);
|
||||||
@@ -1597,7 +1724,7 @@ function CreateTaskSheet({
|
|||||||
const heartbeatSec = Math.max(10, Math.floor(Number(heartbeatMin) || 10)) * 60; // 下限 10min,与后端归一一致
|
const heartbeatSec = Math.max(10, Math.floor(Number(heartbeatMin) || 10)) * 60; // 下限 10min,与后端归一一致
|
||||||
await api.createTask({
|
await api.createTask({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
categoryId: categoryID === "uncategorized" ? undefined : Number(categoryID),
|
categoryId: categoryID === UNCATEGORIZED_VALUE ? undefined : Number(categoryID),
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
goal: goal.trim(),
|
goal: goal.trim(),
|
||||||
llmProfileIds: llmProfileIDs.map(Number),
|
llmProfileIds: llmProfileIDs.map(Number),
|
||||||
@@ -1610,7 +1737,7 @@ function CreateTaskSheet({
|
|||||||
});
|
});
|
||||||
toast.success("任务已创建");
|
toast.success("任务已创建");
|
||||||
setName("");
|
setName("");
|
||||||
setCategoryID("uncategorized");
|
setCategoryID(UNCATEGORIZED_VALUE);
|
||||||
setDescription("");
|
setDescription("");
|
||||||
setGoal("");
|
setGoal("");
|
||||||
setSelectedTemplateID(null);
|
setSelectedTemplateID(null);
|
||||||
@@ -1682,7 +1809,7 @@ function CreateTaskSheet({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectItem value="uncategorized">未分类</SelectItem>
|
<SelectItem value={UNCATEGORIZED_VALUE}>未分类</SelectItem>
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<SelectItem key={category.id} value={String(category.id)}>
|
<SelectItem key={category.id} value={String(category.id)}>
|
||||||
{category.name}
|
{category.name}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
AgentTrigger,
|
AgentTrigger,
|
||||||
Asset,
|
Asset,
|
||||||
Audit,
|
Audit,
|
||||||
|
BatchCategoryItem,
|
||||||
BatchControlItem,
|
BatchControlItem,
|
||||||
ChatAttachment,
|
ChatAttachment,
|
||||||
CommandRecord,
|
CommandRecord,
|
||||||
@@ -218,6 +219,12 @@ export const api = {
|
|||||||
deleteTaskCategory: (id: number) => del<{ deleted: number }>(`/task-categories/${id}`),
|
deleteTaskCategory: (id: number) => del<{ deleted: number }>(`/task-categories/${id}`),
|
||||||
updateTaskCategory: (taskId: string, categoryId?: number) =>
|
updateTaskCategory: (taskId: string, categoryId?: number) =>
|
||||||
patch<Task>(`/tasks/${taskId}/category`, { category_id: categoryId ?? null }),
|
patch<Task>(`/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)),
|
taskTemplates: () => get<{ templates: TaskTemplate[] }>("/task-templates").then((r) => arr(r.templates)),
|
||||||
createTaskTemplate: (input: Pick<TaskTemplate, "name" | "description" | "goal">) =>
|
createTaskTemplate: (input: Pick<TaskTemplate, "name" | "description" | "goal">) =>
|
||||||
post<TaskTemplate>("/task-templates", input),
|
post<TaskTemplate>("/task-templates", input),
|
||||||
|
|||||||
@@ -356,6 +356,25 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
|||||||
}
|
}
|
||||||
return { deleted: categoryID };
|
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") {
|
if (seg[0] === "tasks" && seg[2] === "category" && seg.length === 3 && m === "PATCH") {
|
||||||
const task = mockTasks.find((item) => item.id === seg[1]);
|
const task = mockTasks.find((item) => item.id === seg[1]);
|
||||||
if (!task) throw new Error("任务不存在");
|
if (!task) throw new Error("任务不存在");
|
||||||
|
|||||||
@@ -624,6 +624,13 @@ export interface BatchControlItem {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 批量改分类的逐任务结果。失败只可能是任务已被删除,分类本身的写入是原子的。
|
||||||
|
export interface BatchCategoryItem {
|
||||||
|
id: string;
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Whole-task (all agents) token aggregate.
|
// Whole-task (all agents) token aggregate.
|
||||||
export interface TokenTotal {
|
export interface TokenTotal {
|
||||||
input_tokens: number;
|
input_tokens: number;
|
||||||
|
|||||||
Reference in new issue
Block a user