diff --git a/db/task_categories.go b/db/task_categories.go new file mode 100644 index 0000000..5a4b30a --- /dev/null +++ b/db/task_categories.go @@ -0,0 +1,201 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/jackc/pgx/v5/pgconn" +) + +const MaxTaskCategoryNameRunes = 80 + +var ( + ErrTaskCategoryInvalid = errors.New("invalid task category") + ErrTaskCategoryNameConflict = errors.New("task category name already exists") + ErrTaskCategoryNotFound = errors.New("task category not found") + ErrTaskCategoryTaskNotFound = errors.New("task not found") +) + +// TaskCategory is a globally reusable task grouping label. +type TaskCategory struct { + ID int64 `json:"id"` + Name string `json:"name"` + NKey string `json:"-"` + TaskCount int `json:"task_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +const taskCategoryCols = `category.id, category.name, category.nkey, + count(task.id) FILTER (WHERE task.deleted_at IS NULL), + category.created_at, category.updated_at` + +func scanTaskCategory(row interface{ Scan(...any) error }) (TaskCategory, error) { + var category TaskCategory + err := row.Scan(&category.ID, &category.Name, &category.NKey, &category.TaskCount, &category.CreatedAt, &category.UpdatedAt) + return category, err +} + +func normalizeTaskCategoryName(name string) (string, string, error) { + name = strings.Join(strings.Fields(name), " ") + if name == "" { + return "", "", fmt.Errorf("%w: name is required", ErrTaskCategoryInvalid) + } + if utf8.RuneCountInString(name) > MaxTaskCategoryNameRunes { + return "", "", fmt.Errorf("%w: name exceeds %d characters", ErrTaskCategoryInvalid, MaxTaskCategoryNameRunes) + } + return name, strings.ToLower(name), nil +} + +func taskCategoryUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23505" +} + +func (d *DB) CreateTaskCategory(name string) (*TaskCategory, error) { + name, nkey, err := normalizeTaskCategoryName(name) + if err != nil { + return nil, err + } + category, err := scanTaskCategory(d.QueryRow(` +WITH inserted AS ( + INSERT INTO task_categories(name, nkey) + VALUES ($1,$2) + ON CONFLICT (nkey) DO NOTHING + RETURNING * +) +SELECT inserted.id, inserted.name, inserted.nkey, 0, inserted.created_at, inserted.updated_at +FROM inserted`, name, nkey)) + if err == sql.ErrNoRows { + return nil, ErrTaskCategoryNameConflict + } + if err != nil { + return nil, err + } + return &category, nil +} + +func (d *DB) ListTaskCategories() ([]*TaskCategory, error) { + rows, err := d.Query(` +SELECT ` + taskCategoryCols + ` +FROM task_categories category +LEFT JOIN tasks task ON task.category_id=category.id +GROUP BY category.id +ORDER BY category.name, category.id`) + if err != nil { + return nil, err + } + defer rows.Close() + categories := []*TaskCategory{} + for rows.Next() { + category, err := scanTaskCategory(rows) + if err != nil { + return nil, err + } + categories = append(categories, &category) + } + return categories, rows.Err() +} + +func (d *DB) GetTaskCategory(id int64) (*TaskCategory, error) { + category, err := scanTaskCategory(d.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`, id)) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &category, nil +} + +func (d *DB) RenameTaskCategory(id int64, name string) (*TaskCategory, error) { + name, nkey, err := normalizeTaskCategoryName(name) + if err != nil { + return nil, err + } + category, err := scanTaskCategory(d.QueryRow(` +WITH updated AS ( + UPDATE task_categories SET name=$2, nkey=$3 WHERE id=$1 RETURNING * +) +SELECT updated.id, updated.name, updated.nkey, + (SELECT count(*) FROM tasks WHERE category_id=updated.id AND deleted_at IS NULL), + updated.created_at, updated.updated_at +FROM updated`, id, name, nkey)) + if err == sql.ErrNoRows { + return nil, ErrTaskCategoryNotFound + } + if taskCategoryUniqueViolation(err) { + return nil, ErrTaskCategoryNameConflict + } + if err != nil { + return nil, err + } + return &category, nil +} + +// DeleteTaskCategory moves affected tasks to the uncategorized bucket through +// the tasks.category_id ON DELETE SET NULL foreign key. +func (d *DB) DeleteTaskCategory(id int64) (bool, error) { + result, err := d.Exec(`DELETE FROM task_categories WHERE id=$1`, id) + if err != nil { + return false, err + } + rows, err := result.RowsAffected() + return rows > 0, err +} + +// SetTaskCategory updates one live task. A nil category means uncategorized. +func (d *DB) SetTaskCategory(taskID int64, categoryID *int64) (*TaskCategory, error) { + if categoryID == nil { + result, err := d.Exec(`UPDATE tasks SET category_id=NULL WHERE id=$1 AND deleted_at IS NULL`, taskID) + if err != nil { + return nil, err + } + rows, err := result.RowsAffected() + if err != nil { + return nil, err + } + if rows == 0 { + return nil, ErrTaskCategoryTaskNotFound + } + return nil, nil + } + if *categoryID <= 0 { + return nil, fmt.Errorf("%w: category id must be positive", ErrTaskCategoryInvalid) + } + category, err := scanTaskCategory(d.QueryRow(` +WITH selected AS ( + SELECT * FROM task_categories WHERE id=$2 +), updated AS ( + UPDATE tasks SET category_id=$2 + WHERE id=$1 AND deleted_at IS NULL AND EXISTS (SELECT 1 FROM selected) + RETURNING id +) +SELECT selected.id, selected.name, selected.nkey, + (SELECT count(*) FROM tasks WHERE category_id=selected.id AND deleted_at IS NULL), + selected.created_at, selected.updated_at +FROM selected, updated`, taskID, *categoryID)) + if err == sql.ErrNoRows { + var taskExists bool + if checkErr := d.QueryRow(`SELECT EXISTS(SELECT 1 FROM tasks WHERE id=$1 AND deleted_at IS NULL)`, taskID).Scan(&taskExists); checkErr != nil { + return nil, checkErr + } + if !taskExists { + return nil, ErrTaskCategoryTaskNotFound + } + return nil, ErrTaskCategoryNotFound + } + if err != nil { + return nil, err + } + return &category, nil +} diff --git a/db/task_categories_test.go b/db/task_categories_test.go new file mode 100644 index 0000000..e1d5d60 --- /dev/null +++ b/db/task_categories_test.go @@ -0,0 +1,91 @@ +package db + +import ( + "errors" + "fmt" + "strings" + "testing" + "time" +) + +func TestTaskCategoryCRUDAndTaskAssignment(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) - skipping", err) + } + defer d.Close() + + name := fmt.Sprintf("Category %d", time.Now().UnixNano()) + category, err := d.CreateTaskCategory(" " + strings.ReplaceAll(name, " ", " ") + " ") + if err != nil { + t.Fatal(err) + } + if category.Name != name { + t.Fatalf("category name=%q, want %q", category.Name, name) + } + if _, err := d.CreateTaskCategory(strings.ToUpper(name)); !errors.Is(err, ErrTaskCategoryNameConflict) { + t.Fatalf("duplicate error=%v, want %v", err, ErrTaskCategoryNameConflict) + } + + task, err := d.CreateTaskWithOptions("categorized task", "goal", TaskCreateOptions{CategoryID: &category.ID}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.DeleteTask(task.ID) }) + loaded, err := d.GetTask(task.ID) + if err != nil || loaded == nil || loaded.CategoryID == nil || *loaded.CategoryID != category.ID || loaded.CategoryName != name { + t.Fatalf("categorized task=%+v err=%v", loaded, err) + } + + renamed, err := d.RenameTaskCategory(category.ID, name+" renamed") + if err != nil { + t.Fatal(err) + } + if renamed.TaskCount != 1 { + t.Fatalf("renamed task count=%d, want 1", renamed.TaskCount) + } + loaded, _ = d.GetTask(task.ID) + if loaded.CategoryName != renamed.Name { + t.Fatalf("task category name=%q, want %q", loaded.CategoryName, renamed.Name) + } + + if _, err := d.SetTaskCategory(task.ID, nil); err != nil { + t.Fatal(err) + } + loaded, _ = d.GetTask(task.ID) + if loaded.CategoryID != nil || loaded.CategoryName != "" { + t.Fatalf("uncategorized task retained category: %+v", loaded) + } + if _, err := d.SetTaskCategory(task.ID, &category.ID); err != nil { + t.Fatal(err) + } + deleted, err := d.DeleteTaskCategory(category.ID) + if err != nil || !deleted { + t.Fatalf("delete category=%v err=%v", deleted, err) + } + loaded, _ = d.GetTask(task.ID) + if loaded.CategoryID != nil || loaded.CategoryName != "" { + t.Fatalf("category deletion did not clear task: %+v", loaded) + } +} + +func TestCreateTaskRejectsMissingCategoryAtomically(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) - skipping", err) + } + defer d.Close() + + missing := int64(1 << 62) + description := fmt.Sprintf("missing-category-%d", time.Now().UnixNano()) + if _, err := d.CreateTaskWithOptions(description, "goal", TaskCreateOptions{CategoryID: &missing}); !errors.Is(err, ErrTaskCategoryNotFound) { + t.Fatalf("create error=%v, want %v", err, ErrTaskCategoryNotFound) + } + var count int + if err := d.QueryRow(`SELECT count(*) FROM explorations WHERE description=$1`, description).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("failed create leaked %d exploration rows", count) + } +} diff --git a/db/task_templates.go b/db/task_templates.go new file mode 100644 index 0000000..2a5865a --- /dev/null +++ b/db/task_templates.go @@ -0,0 +1,231 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/jackc/pgx/v5/pgconn" +) + +const ( + MaxTaskTemplateNameRunes = 120 + MaxTaskTemplateTextRunes = 16000 +) + +var ( + ErrTaskTemplateInvalid = errors.New("invalid task template") + ErrTaskTemplateNameConflict = errors.New("task template name already exists") + ErrTaskTemplateNotFound = errors.New("task template not found") +) + +// TaskTemplate is a reusable task description and goal preset. +type TaskTemplate struct { + ID int64 `json:"id"` + Name string `json:"name"` + NKey string `json:"-"` + Description string `json:"description"` + Goal string `json:"goal"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TaskTemplateInput is the create/update payload after normalization. +type TaskTemplateInput struct { + Name string + Description string + Goal string +} + +// TaskTemplatePatch changes only fields whose pointers are non-nil. +type TaskTemplatePatch struct { + Name *string + Description *string + Goal *string +} + +const taskTemplateCols = `id, name, nkey, description, goal, created_at, updated_at` + +func scanTaskTemplate(row interface{ Scan(...any) error }) (TaskTemplate, error) { + var t TaskTemplate + err := row.Scan(&t.ID, &t.Name, &t.NKey, &t.Description, &t.Goal, &t.CreatedAt, &t.UpdatedAt) + return t, err +} + +// taskTemplateName normalizes display whitespace while preserving the user's case. +func taskTemplateName(name string) string { return strings.Join(strings.Fields(name), " ") } + +// taskTemplateNKey is the case-insensitive identity used by the unique index. +func taskTemplateNKey(name string) string { return strings.ToLower(taskTemplateName(name)) } + +func normalizeTaskTemplateInput(in TaskTemplateInput) (TaskTemplateInput, string, error) { + in.Name = taskTemplateName(in.Name) + in.Description = strings.TrimSpace(in.Description) + in.Goal = strings.TrimSpace(in.Goal) + switch { + case in.Name == "": + return in, "", fmt.Errorf("%w: name is required", ErrTaskTemplateInvalid) + case utf8.RuneCountInString(in.Name) > MaxTaskTemplateNameRunes: + return in, "", fmt.Errorf("%w: name exceeds %d characters", ErrTaskTemplateInvalid, MaxTaskTemplateNameRunes) + case in.Description == "": + return in, "", fmt.Errorf("%w: description is required", ErrTaskTemplateInvalid) + case utf8.RuneCountInString(in.Description) > MaxTaskTemplateTextRunes: + return in, "", fmt.Errorf("%w: description exceeds %d characters", ErrTaskTemplateInvalid, MaxTaskTemplateTextRunes) + case in.Goal == "": + return in, "", fmt.Errorf("%w: goal is required", ErrTaskTemplateInvalid) + case utf8.RuneCountInString(in.Goal) > MaxTaskTemplateTextRunes: + return in, "", fmt.Errorf("%w: goal exceeds %d characters", ErrTaskTemplateInvalid, MaxTaskTemplateTextRunes) + } + return in, taskTemplateNKey(in.Name), nil +} + +func normalizeTaskTemplatePatch(patch TaskTemplatePatch) (TaskTemplatePatch, *string, error) { + if patch.Name == nil && patch.Description == nil && patch.Goal == nil { + return patch, nil, fmt.Errorf("%w: no fields supplied", ErrTaskTemplateInvalid) + } + var nkey *string + if patch.Name != nil { + name := taskTemplateName(*patch.Name) + if name == "" { + return patch, nil, fmt.Errorf("%w: name is required", ErrTaskTemplateInvalid) + } + if utf8.RuneCountInString(name) > MaxTaskTemplateNameRunes { + return patch, nil, fmt.Errorf("%w: name exceeds %d characters", ErrTaskTemplateInvalid, MaxTaskTemplateNameRunes) + } + key := taskTemplateNKey(name) + patch.Name = &name + nkey = &key + } + if patch.Description != nil { + description := strings.TrimSpace(*patch.Description) + if description == "" { + return patch, nil, fmt.Errorf("%w: description is required", ErrTaskTemplateInvalid) + } + if utf8.RuneCountInString(description) > MaxTaskTemplateTextRunes { + return patch, nil, fmt.Errorf("%w: description exceeds %d characters", ErrTaskTemplateInvalid, MaxTaskTemplateTextRunes) + } + patch.Description = &description + } + if patch.Goal != nil { + goal := strings.TrimSpace(*patch.Goal) + if goal == "" { + return patch, nil, fmt.Errorf("%w: goal is required", ErrTaskTemplateInvalid) + } + if utf8.RuneCountInString(goal) > MaxTaskTemplateTextRunes { + return patch, nil, fmt.Errorf("%w: goal exceeds %d characters", ErrTaskTemplateInvalid, MaxTaskTemplateTextRunes) + } + patch.Goal = &goal + } + return patch, nkey, nil +} + +func taskTemplateUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23505" +} + +// CreateTaskTemplate inserts one globally reusable preset. +func (d *DB) CreateTaskTemplate(in TaskTemplateInput) (*TaskTemplate, error) { + in, nkey, err := normalizeTaskTemplateInput(in) + if err != nil { + return nil, err + } + t, err := scanTaskTemplate(d.QueryRow(` +INSERT INTO task_templates(name, nkey, description, goal) +VALUES ($1,$2,$3,$4) +ON CONFLICT (nkey) DO NOTHING +RETURNING `+taskTemplateCols, in.Name, nkey, in.Description, in.Goal)) + if err == sql.ErrNoRows { + return nil, ErrTaskTemplateNameConflict + } + if err != nil { + return nil, err + } + return &t, nil +} + +// ListTaskTemplates returns the most recently maintained templates first. +func (d *DB) ListTaskTemplates() ([]*TaskTemplate, error) { + rows, err := d.Query(`SELECT ` + taskTemplateCols + ` FROM task_templates ORDER BY updated_at DESC, id DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []*TaskTemplate{} + for rows.Next() { + t, err := scanTaskTemplate(rows) + if err != nil { + return nil, err + } + out = append(out, &t) + } + return out, rows.Err() +} + +// GetTaskTemplate returns nil when id does not exist. +func (d *DB) GetTaskTemplate(id int64) (*TaskTemplate, error) { + t, err := scanTaskTemplate(d.QueryRow(`SELECT `+taskTemplateCols+` FROM task_templates WHERE id=$1`, id)) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &t, nil +} + +// UpdateTaskTemplate replaces the editable fields of one preset. +func (d *DB) UpdateTaskTemplate(id int64, in TaskTemplateInput) (*TaskTemplate, error) { + in, _, err := normalizeTaskTemplateInput(in) + if err != nil { + return nil, err + } + return d.PatchTaskTemplate(id, TaskTemplatePatch{ + Name: &in.Name, Description: &in.Description, Goal: &in.Goal, + }) +} + +// PatchTaskTemplate atomically changes only the supplied fields. Keeping the +// merge in one UPDATE prevents concurrent disjoint PATCH requests from losing +// each other's changes. +func (d *DB) PatchTaskTemplate(id int64, patch TaskTemplatePatch) (*TaskTemplate, error) { + patch, nkey, err := normalizeTaskTemplatePatch(patch) + if err != nil { + return nil, err + } + t, err := scanTaskTemplate(d.QueryRow(`UPDATE task_templates +SET name=CASE WHEN $2 THEN $3::text ELSE name END, + nkey=CASE WHEN $2 THEN $4::text ELSE nkey END, + description=CASE WHEN $5 THEN $6::text ELSE description END, + goal=CASE WHEN $7 THEN $8::text ELSE goal END +WHERE id=$1 +RETURNING `+taskTemplateCols, + id, + patch.Name != nil, patch.Name, nkey, + patch.Description != nil, patch.Description, + patch.Goal != nil, patch.Goal, + )) + if err == sql.ErrNoRows { + return nil, ErrTaskTemplateNotFound + } + if taskTemplateUniqueViolation(err) { + return nil, ErrTaskTemplateNameConflict + } + if err != nil { + return nil, err + } + return &t, nil +} + +// DeleteTaskTemplate deletes one preset and reports whether it existed. +func (d *DB) DeleteTaskTemplate(id int64) (bool, error) { + result, err := d.Exec(`DELETE FROM task_templates WHERE id=$1`, id) + if err != nil { + return false, err + } + n, err := result.RowsAffected() + return n > 0, err +} diff --git a/db/task_templates_test.go b/db/task_templates_test.go new file mode 100644 index 0000000..a3425b9 --- /dev/null +++ b/db/task_templates_test.go @@ -0,0 +1,131 @@ +package db + +import ( + "errors" + "fmt" + "strings" + "sync" + "testing" + "time" +) + +func TestTaskTemplateCRUDAndNormalizedUniqueness(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer d.Close() + + suffix := time.Now().UnixNano() + name := fmt.Sprintf("Template %d", suffix) + created, err := d.CreateTaskTemplate(TaskTemplateInput{ + Name: " " + strings.ReplaceAll(name, " ", " ") + " ", + Description: " initial description ", + Goal: " initial goal ", + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = d.DeleteTaskTemplate(created.ID) }) + if created.Name != name || created.Description != "initial description" || created.Goal != "initial goal" { + t.Fatalf("template was not normalized: %+v", created) + } + + if _, err := d.CreateTaskTemplate(TaskTemplateInput{ + Name: strings.ToUpper(name), Description: "duplicate", Goal: "duplicate", + }); !errors.Is(err, ErrTaskTemplateNameConflict) { + t.Fatalf("duplicate create error = %v, want %v", err, ErrTaskTemplateNameConflict) + } + + got, err := d.GetTaskTemplate(created.ID) + if err != nil || got == nil || got.Name != name { + t.Fatalf("GetTaskTemplate = %+v, %v", got, err) + } + listed, err := d.ListTaskTemplates() + if err != nil { + t.Fatal(err) + } + found := false + for _, template := range listed { + if template.ID == created.ID { + found = true + break + } + } + if !found { + t.Fatalf("created template %d missing from list", created.ID) + } + + updatedName := name + " updated" + updated, err := d.UpdateTaskTemplate(created.ID, TaskTemplateInput{ + Name: updatedName, Description: "new description", Goal: "new goal", + }) + if err != nil { + t.Fatal(err) + } + if updated.Name != updatedName || updated.Description != "new description" || updated.Goal != "new goal" { + t.Fatalf("unexpected updated template: %+v", updated) + } + + if _, err := d.CreateTaskTemplate(TaskTemplateInput{Name: "", Description: "x", Goal: "y"}); !errors.Is(err, ErrTaskTemplateInvalid) { + t.Fatalf("empty name error = %v, want %v", err, ErrTaskTemplateInvalid) + } + deleted, err := d.DeleteTaskTemplate(created.ID) + if err != nil || !deleted { + t.Fatalf("DeleteTaskTemplate = %v, %v", deleted, err) + } + deleted, err = d.DeleteTaskTemplate(created.ID) + if err != nil || deleted { + t.Fatalf("second DeleteTaskTemplate = %v, %v", deleted, err) + } +} + +func TestTaskTemplateDisjointPatchesCompose(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) - skipping", err) + } + defer d.Close() + + created, err := d.CreateTaskTemplate(TaskTemplateInput{ + Name: fmt.Sprintf("Concurrent template %d", time.Now().UnixNano()), + Description: "initial description", + Goal: "initial goal", + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = d.DeleteTaskTemplate(created.ID) }) + + description := "description from concurrent patch" + goal := "goal from concurrent patch" + start := make(chan struct{}) + errs := make(chan error, 2) + var wg sync.WaitGroup + for _, patch := range []TaskTemplatePatch{{Description: &description}, {Goal: &goal}} { + patch := patch + wg.Add(1) + go func() { + defer wg.Done() + <-start + _, err := d.PatchTaskTemplate(created.ID, patch) + errs <- err + }() + } + close(start) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + + got, err := d.GetTaskTemplate(created.ID) + if err != nil { + t.Fatal(err) + } + if got == nil || got.Description != description || got.Goal != goal { + t.Fatalf("disjoint patches lost an update: %+v", got) + } +} diff --git a/server/task_categories.go b/server/task_categories.go new file mode 100644 index 0000000..9cca440 --- /dev/null +++ b/server/task_categories.go @@ -0,0 +1,159 @@ +package server + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "strings" + "unicode/utf8" + + "github.com/Autumn-27/artex/db" +) + +const maxTaskCategoryRequestBytes = 16 << 10 + +type taskCategoryRequest struct { + Name *string `json:"name"` +} + +func decodeTaskCategoryRequest(w http.ResponseWriter, r *http.Request) (*taskCategoryRequest, bool) { + r.Body = http.MaxBytesReader(w, r.Body, maxTaskCategoryRequestBytes) + var request taskCategoryRequest + 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 nil, false + } + if request.Name == nil || strings.TrimSpace(*request.Name) == "" { + writeErr(w, http.StatusBadRequest, "分类名称不能为空") + return nil, false + } + if utf8.RuneCountInString(strings.TrimSpace(*request.Name)) > db.MaxTaskCategoryNameRunes { + writeErr(w, http.StatusBadRequest, "分类名称最多 80 个字符") + return nil, false + } + return &request, true +} + +func writeTaskCategoryError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, db.ErrTaskCategoryInvalid): + writeErr(w, http.StatusBadRequest, err.Error()) + case errors.Is(err, db.ErrTaskCategoryNameConflict): + writeErr(w, http.StatusConflict, "分类名称已存在") + case errors.Is(err, db.ErrTaskCategoryNotFound), errors.Is(err, db.ErrTaskCategoryTaskNotFound): + writeErr(w, http.StatusNotFound, err.Error()) + default: + writeErr(w, http.StatusInternalServerError, err.Error()) + } +} + +func (s *Server) pgListTaskCategories(w http.ResponseWriter, _ *http.Request) { + pg := s.pg(w) + if pg == nil { + return + } + categories, err := pg.ListTaskCategories() + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"categories": categories}) +} + +func (s *Server) pgCreateTaskCategory(w http.ResponseWriter, r *http.Request) { + pg := s.pg(w) + if pg == nil { + return + } + request, ok := decodeTaskCategoryRequest(w, r) + if !ok { + return + } + category, err := pg.CreateTaskCategory(*request.Name) + if err != nil { + writeTaskCategoryError(w, err) + return + } + writeJSON(w, http.StatusCreated, category) +} + +func (s *Server) pgRenameTaskCategory(w http.ResponseWriter, r *http.Request) { + id, ok := pathInt(r, "id") + if !ok { + writeErr(w, http.StatusBadRequest, "bad task category id") + return + } + request, ok := decodeTaskCategoryRequest(w, r) + if !ok { + return + } + category, err := s.m.RenameTaskCategory(id, *request.Name) + if err != nil { + writeTaskCategoryError(w, err) + return + } + writeJSON(w, http.StatusOK, category) +} + +func (s *Server) pgDeleteTaskCategory(w http.ResponseWriter, r *http.Request) { + id, ok := pathInt(r, "id") + if !ok { + writeErr(w, http.StatusBadRequest, "bad task category id") + return + } + deleted, err := s.m.DeleteTaskCategory(id) + if err != nil { + writeTaskCategoryError(w, err) + return + } + if !deleted { + writeErr(w, http.StatusNotFound, "task category not found") + return + } + writeJSON(w, http.StatusOK, map[string]any{"deleted": id}) +} + +func (s *Server) updateTaskCategory(w http.ResponseWriter, r *http.Request) { + taskID := r.PathValue("id") + if _, ok := s.m.Task(taskID); !ok { + writeErr(w, http.StatusNotFound, "task not found") + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxTaskCategoryRequestBytes) + var request struct { + CategoryID json.RawMessage `json:"category_id"` + } + if err := decode(r, &request); err != nil { + 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 无效") + return + } + if _, err := s.m.SetTaskCategory(taskID, categoryID); err != nil { + writeTaskCategoryError(w, err) + return + } + task, _ := s.m.Task(taskID) + writeJSON(w, http.StatusOK, taskDTO(task, s.resolvedTaskStatus(task))) +} diff --git a/server/task_templates.go b/server/task_templates.go new file mode 100644 index 0000000..2e728b1 --- /dev/null +++ b/server/task_templates.go @@ -0,0 +1,172 @@ +package server + +import ( + "errors" + "fmt" + "net/http" + "strings" + "unicode/utf8" + + "github.com/Autumn-27/artex/db" +) + +const maxTaskTemplateRequestBytes = 512 << 10 + +// taskTemplateRequest uses pointers so PATCH can distinguish omitted fields from +// explicit empty values. Empty values are still rejected by the DB validator. +type taskTemplateRequest struct { + Name *string `json:"name"` + Description *string `json:"description"` + Goal *string `json:"goal"` +} + +func decodeTaskTemplateRequest(w http.ResponseWriter, r *http.Request, req *taskTemplateRequest) bool { + r.Body = http.MaxBytesReader(w, r.Body, maxTaskTemplateRequestBytes) + if err := decode(r, req); err != nil { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + writeErr(w, http.StatusRequestEntityTooLarge, "请求正文过大") + } else { + writeErr(w, http.StatusBadRequest, err.Error()) + } + return false + } + return true +} + +func validateTaskTemplateRequest(req taskTemplateRequest) error { + checks := []struct { + name string + value *string + limit int + }{ + {name: "name", value: req.Name, limit: db.MaxTaskTemplateNameRunes}, + {name: "description", value: req.Description, limit: db.MaxTaskTemplateTextRunes}, + {name: "goal", value: req.Goal, limit: db.MaxTaskTemplateTextRunes}, + } + for _, check := range checks { + if check.value == nil { + continue + } + value := strings.TrimSpace(*check.value) + if check.name == "name" { + value = strings.Join(strings.Fields(value), " ") + } + if utf8.RuneCountInString(value) > check.limit { + return fmt.Errorf("%s 最多 %d 个字符", check.name, check.limit) + } + } + return nil +} + +func writeTaskTemplateErr(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, db.ErrTaskTemplateInvalid): + writeErr(w, http.StatusBadRequest, err.Error()) + case errors.Is(err, db.ErrTaskTemplateNameConflict): + writeErr(w, http.StatusConflict, "模板名称已存在") + case errors.Is(err, db.ErrTaskTemplateNotFound): + writeErr(w, http.StatusNotFound, "task template not found") + default: + writeErr(w, http.StatusInternalServerError, err.Error()) + } +} + +func (s *Server) pgListTaskTemplates(w http.ResponseWriter, _ *http.Request) { + pg := s.pg(w) + if pg == nil { + return + } + templates, err := pg.ListTaskTemplates() + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"templates": templates}) +} + +func (s *Server) pgCreateTaskTemplate(w http.ResponseWriter, r *http.Request) { + pg := s.pg(w) + if pg == nil { + return + } + var req taskTemplateRequest + if !decodeTaskTemplateRequest(w, r, &req) { + return + } + if err := validateTaskTemplateRequest(req); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + template, err := pg.CreateTaskTemplate(db.TaskTemplateInput{ + Name: stringValue(req.Name), + Description: stringValue(req.Description), + Goal: stringValue(req.Goal), + }) + if err != nil { + writeTaskTemplateErr(w, err) + return + } + writeJSON(w, http.StatusCreated, template) +} + +func (s *Server) pgUpdateTaskTemplate(w http.ResponseWriter, r *http.Request) { + pg := s.pg(w) + if pg == nil { + return + } + id, ok := pathInt(r, "id") + if !ok { + writeErr(w, http.StatusBadRequest, "bad task template id") + return + } + var req taskTemplateRequest + if !decodeTaskTemplateRequest(w, r, &req) { + return + } + if err := validateTaskTemplateRequest(req); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + if req.Name == nil && req.Description == nil && req.Goal == nil { + writeErr(w, http.StatusBadRequest, "至少需要提供 name、description 或 goal") + return + } + template, err := pg.PatchTaskTemplate(id, db.TaskTemplatePatch{ + Name: req.Name, Description: req.Description, Goal: req.Goal, + }) + if err != nil { + writeTaskTemplateErr(w, err) + return + } + writeJSON(w, http.StatusOK, template) +} + +func (s *Server) pgDeleteTaskTemplate(w http.ResponseWriter, r *http.Request) { + pg := s.pg(w) + if pg == nil { + return + } + id, ok := pathInt(r, "id") + if !ok { + writeErr(w, http.StatusBadRequest, "bad task template id") + return + } + deleted, err := pg.DeleteTaskTemplate(id) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + if !deleted { + writeErr(w, http.StatusNotFound, "task template not found") + return + } + writeJSON(w, http.StatusOK, map[string]any{"deleted": id}) +} + +func stringValue(value *string) string { + if value == nil { + return "" + } + return *value +} diff --git a/server/task_templates_test.go b/server/task_templates_test.go new file mode 100644 index 0000000..a3a9b1b --- /dev/null +++ b/server/task_templates_test.go @@ -0,0 +1,163 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Autumn-27/artex/db" +) + +func TestTaskTemplateHTTPCRUD(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer m.Close() + s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir()) + h := s.Handler() + token, err := signJWT(s.jwtKey) + if err != nil { + t.Fatal(err) + } + + do := func(method, path string, body any) (int, map[string]any) { + var data []byte + if body != nil { + data, _ = json.Marshal(body) + } + req := httptest.NewRequest(method, path, bytes.NewReader(data)) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + var out map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &out) + return rec.Code, out + } + + name := fmt.Sprintf("http-template-%d", time.Now().UnixNano()) + code, created := do(http.MethodPost, "/api/task-templates", map[string]any{ + "name": name, "description": "description", "goal": "goal", + }) + if code != http.StatusCreated { + t.Fatalf("create status=%d body=%v", code, created) + } + id := int64(created["id"].(float64)) + t.Cleanup(func() { _, _ = m.pg.DeleteTaskTemplate(id) }) + + code, duplicate := do(http.MethodPost, "/api/task-templates", map[string]any{ + "name": " " + name + " ", "description": "other", "goal": "other", + }) + if code != http.StatusConflict { + t.Fatalf("duplicate status=%d body=%v", code, duplicate) + } + + code, updated := do(http.MethodPatch, fmt.Sprintf("/api/task-templates/%d", id), map[string]any{"goal": "patched goal"}) + if code != http.StatusOK || updated["goal"] != "patched goal" || updated["description"] != "description" { + t.Fatalf("patch status=%d body=%v", code, updated) + } + code, _ = do(http.MethodPatch, fmt.Sprintf("/api/task-templates/%d", id), map[string]any{ + "goal": strings.Repeat("界", db.MaxTaskTemplateTextRunes+1), + }) + if code != http.StatusBadRequest { + t.Fatalf("overlong patch status=%d, want %d", code, http.StatusBadRequest) + } + code, _ = do(http.MethodPost, "/api/task-templates", map[string]any{ + "name": "oversized", "description": strings.Repeat("x", maxTaskTemplateRequestBytes+1), "goal": "goal", + }) + if code != http.StatusRequestEntityTooLarge { + t.Fatalf("oversized create status=%d, want %d", code, http.StatusRequestEntityTooLarge) + } + code, listed := do(http.MethodGet, "/api/task-templates", nil) + if code != http.StatusOK || listed["templates"] == nil { + t.Fatalf("list status=%d body=%v", code, listed) + } + code, deleted := do(http.MethodDelete, fmt.Sprintf("/api/task-templates/%d", id), nil) + if code != http.StatusOK || int64(deleted["deleted"].(float64)) != id { + t.Fatalf("delete status=%d body=%v", code, deleted) + } +} + +func TestConversationPatchReturnsPinState(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer m.Close() + s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir()) + conversation, err := m.pg.CreateConversation("mainagent", "pin through http", nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = m.pg.DeleteConversation(conversation.ID) }) + token, err := signJWT(s.jwtKey) + if err != nil { + t.Fatal(err) + } + + body, _ := json.Marshal(map[string]any{"title": " pinned title ", "pinned": true}) + req := httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/api/conversations/%d", conversation.ID), bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("patch status=%d body=%s", rec.Code, rec.Body.String()) + } + var updated struct { + Title string `json:"title"` + Pinned bool `json:"pinned"` + PinnedAt *string `json:"pinned_at"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil { + t.Fatal(err) + } + if updated.Title != "pinned title" || !updated.Pinned || updated.PinnedAt == nil { + t.Fatalf("unexpected conversation patch response: %+v", updated) + } + + body, _ = json.Marshal(map[string]any{"title": strings.Repeat("会", maxConversationTitleRunes+1)}) + req = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/api/conversations/%d", conversation.ID), bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + rec = httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("overlong conversation title status=%d body=%s", rec.Code, rec.Body.String()) + } + + body, _ = json.Marshal(map[string]any{"title": strings.Repeat("x", maxConversationRequestBytes+1)}) + req = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/api/conversations/%d", conversation.ID), bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + rec = httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("oversized conversation patch status=%d body=%s", rec.Code, rec.Body.String()) + } + + body, _ = json.Marshal(map[string]any{ + "agent_key": "mainagent", "title": strings.Repeat("会", maxConversationTitleRunes+1), + }) + req = httptest.NewRequest(http.MethodPost, "/api/conversations", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + rec = httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("overlong conversation create status=%d body=%s", rec.Code, rec.Body.String()) + } + + body, _ = json.Marshal(map[string]any{ + "agent_key": "mainagent", "title": strings.Repeat("x", maxConversationRequestBytes+1), + }) + req = httptest.NewRequest(http.MethodPost, "/api/conversations", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + rec = httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("oversized conversation create status=%d body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx index eb0c2ba..155b365 100644 --- a/web/src/app/(main)/function/tasks/page.tsx +++ b/web/src/app/(main)/function/tasks/page.tsx @@ -1,13 +1,14 @@ "use client"; import * as React from "react"; + import Link from "next/link"; -<<<<<<< Updated upstream -======= import { + CheckIcon, ChevronRightIcon, EyeIcon, + FolderKanbanIcon, Loader2Icon, PaperclipIcon, PauseIcon, @@ -16,24 +17,16 @@ import { SearchIcon, SlidersHorizontalIcon, StarIcon, + TagsIcon, Trash2Icon, XIcon, } from "lucide-react"; ->>>>>>> Stashed changes import { toast } from "sonner"; -import { - PlusIcon, - Trash2Icon, - ArrowRightIcon, - StarIcon, - SearchIcon, - XIcon, - ChevronRightIcon, - PaperclipIcon, - Loader2Icon, -} from "lucide-react"; import { StatusBadge } from "@/components/status-badge"; +import { TablePagination } from "@/components/table-pagination"; +import { TaskLLMProfileChain } from "@/components/task-llm-profile-chain"; +import { TaskTemplateControls } from "@/components/task-template-controls"; import { AlertDialog, AlertDialogAction, @@ -48,17 +41,40 @@ import { import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { + Combobox, + ComboboxChip, + ComboboxChips, + ComboboxChipsInput, + ComboboxContent, + ComboboxEmpty, + ComboboxItem, + ComboboxList, + ComboboxValue, +} from "@/components/ui/combobox"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Field, + FieldContent, + FieldDescription, + FieldGroup, + FieldLabel, + FieldLegend, + FieldSet, +} from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Sheet, SheetClose, @@ -69,21 +85,21 @@ import { SheetTitle, SheetTrigger, } from "@/components/ui/sheet"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { TablePagination } from "@/components/table-pagination"; +import { Spinner } from "@/components/ui/spinner"; +import { Switch } from "@/components/ui/switch"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Textarea } from "@/components/ui/textarea"; import { api } from "@/lib/api"; -import type { Task, TaskStatus, LLMProfile, ChatAttachment } from "@/lib/types"; +import type { + ChatAttachment, + Company, + DeleteTaskOptions, + DeleteTaskResult, + LLMProfile, + Task, + TaskCategory, + TaskStatus, +} from "@/lib/types"; // fmtBytes renders a human file size for the upload manifest. function fmtBytes(n: number): string { @@ -108,12 +124,10 @@ function appendUploads(desc: string, atts: ChatAttachment[]): string { return `${head}${UPLOAD_MARKER} worker 可用 Read/Bash 按路径打开:\n${bullets}\n`; } -// ACTIVE_PROFILE is the sentinel Select value for "use the global active profile". -const ACTIVE_PROFILE = "__active__"; - // POLL_MS is the task-list refresh interval. Task state moves on the server (planner / // worker), so the list has to be pulled; 10s is plenty for status / 进度 / token 变化. const POLL_MS = 10_000; +const MAX_SOURCE_TASKS = 8; // fmtTokens renders a compact token count (1234 → 1.2k, 2_000_000 → 2M). function fmtTokens(n: number): string { @@ -146,10 +160,30 @@ function taskDuration(task: Task, nowSec: number): number { ? nowSec : task.completed_unix && task.completed_unix > 0 ? task.completed_unix - : task.last_activity_unix ?? 0; + : (task.last_activity_unix ?? 0); return end > start ? end - start : 0; } +// deleteDetails takes only the countable part of the result so callers can pass an +// aggregate accumulated over a bulk delete. +type DeleteCounts = Omit; + +function deleteDetails(result: DeleteCounts): string[] { + const details: string[] = []; + if (result.assets_deleted > 0) details.push(`删除资产 ${result.assets_deleted} 条`); + if (result.assets_detached > 0) details.push(`解除共享资产关联 ${result.assets_detached} 条`); + if (result.traffic_deleted > 0) details.push(`删除流量 ${result.traffic_deleted} 条`); + if (result.files_deleted) details.push("删除任务文件"); + if (result.findings_deleted > 0) details.push(`删除漏洞 ${result.findings_deleted} 条`); + if (result.llm_records_deleted > 0) details.push(`删除 LLM 请求/响应记录 ${result.llm_records_deleted} 条`); + return details; +} + +function deleteSummary(result: DeleteTaskResult): string { + const details = deleteDetails(result); + return details.length > 0 ? `任务已删除(${details.join(",")})` : "任务已删除"; +} + // fmtDateTime renders a unix-seconds timestamp as a compact local date-time // (MM-DD HH:mm), or "—" when unset. function fmtDateTime(unix?: number): string { @@ -161,6 +195,7 @@ function fmtDateTime(unix?: number): string { const STATUS_OPTIONS: { value: TaskStatus; label: string }[] = [ { value: "created", label: "已创建" }, + { value: "queued", label: "排队中" }, { value: "running", label: "运行中" }, { value: "paused", label: "已暂停" }, { value: "done", label: "已完成" }, @@ -181,44 +216,99 @@ function taskControlAction(status: TaskStatus): "pause" | "resume" | null { export default function TasksPage() { const [tasks, setTasks] = React.useState([]); + const [categories, setCategories] = React.useState([]); const [query, setQuery] = React.useState(""); const [statusFilter, setStatusFilter] = React.useState("all"); + const [categoryFilter, setCategoryFilter] = React.useState("all"); const [page, setPage] = React.useState(1); 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 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) + return false; if (!q) return true; return ( -<<<<<<< Updated upstream -======= (t.name ?? "").toLowerCase().includes(q) || ->>>>>>> Stashed changes + (t.category_name ?? "").toLowerCase().includes(q) || t.description.toLowerCase().includes(q) || t.goal.toLowerCase().includes(q) || t.id.toLowerCase().includes(q) ); }); - }, [tasks, query, statusFilter]); + }, [tasks, query, statusFilter, categoryFilter]); // reset to page 1 whenever filters change - React.useEffect(() => { setPage(1); }, [query, statusFilter]); + // biome-ignore lint/correctness/useExhaustiveDependencies: both filters intentionally reset pagination. + React.useEffect(() => { + setPage(1); + }, [query, statusFilter, categoryFilter]); const paginated = React.useMemo( () => filtered.slice((page - 1) * pageSize, page * pageSize), [filtered, page, pageSize], ); + // 多选删除:选择跨翻页/筛选保留,只在任务真的消失(被删或后端不再返回)时收敛。 + const [selectedIds, setSelectedIds] = React.useState>(() => new Set()); + + React.useEffect(() => { + setSelectedIds((prev) => { + if (prev.size === 0) return prev; + const live = new Set(tasks.map((t) => t.id)); + const next = new Set([...prev].filter((id) => live.has(id))); + return next.size === prev.size ? prev : next; + }); + }, [tasks]); + + const toggleSelected = React.useCallback((id: string, checked: boolean) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (checked) next.add(id); + else next.delete(id); + return next; + }); + }, []); + + const pageIds = React.useMemo(() => paginated.map((t) => t.id), [paginated]); + const pageSelectedCount = React.useMemo( + () => pageIds.filter((id) => selectedIds.has(id)).length, + [pageIds, selectedIds], + ); + let headerChecked: boolean | "indeterminate" = false; + if (pageIds.length > 0 && pageSelectedCount === pageIds.length) { + headerChecked = true; + } else if (pageSelectedCount > 0) { + headerChecked = "indeterminate"; + } + + const toggleSelectedPage = React.useCallback( + (checked: boolean) => { + setSelectedIds((prev) => { + const next = new Set(prev); + for (const id of pageIds) { + if (checked) next.add(id); + else next.delete(id); + } + return next; + }); + }, + [pageIds], + ); + // lastRef holds the previous poll's serialized payload: the list is re-fetched every // POLL_MS but usually comes back unchanged, and setTasks on an identical payload would // re-render the whole page for nothing. Bail out when it matches. const lastRef = React.useRef(""); const load = React.useCallback(() => { - api.tasks() + api + .tasks() .then((r) => { const next = r.tasks.map((t) => (t.id === r.active ? { ...t, active: true } : t)); const sig = JSON.stringify(next); @@ -226,14 +316,37 @@ export default function TasksPage() { lastRef.current = sig; setTasks(next); }) - .catch(() => {}); + .catch(() => { + // Polling is best-effort; the next interval retries automatically. + }); + }, []); + + const loadCategories = React.useCallback(() => { + api + .taskCategories() + .then(setCategories) + .catch(() => { + // Category management remains retryable without blocking the task list. + }); }, []); React.useEffect(() => { load(); + loadCategories(); const i = setInterval(load, POLL_MS); return () => clearInterval(i); - }, [load]); + }, [load, loadCategories]); + + const refreshCategoriesAndTasks = React.useCallback(() => { + lastRef.current = ""; + loadCategories(); + load(); + }, [load, loadCategories]); + + React.useEffect(() => { + if (categoryFilter === "all" || categoryFilter === "uncategorized") return; + if (!categories.some((category) => String(category.id) === categoryFilter)) setCategoryFilter("all"); + }, [categories, categoryFilter]); // 只在确有 running 任务时才每秒 tick——其余情况「运行时长」是静态值,空转的 tick 会白白 // 重渲染整张表。 @@ -247,17 +360,6 @@ export default function TasksPage() { return () => clearInterval(i); }, [hasRunning]); -<<<<<<< Updated upstream - const deleteTask = React.useCallback(async (id: string) => { - try { - await api.deleteTask(id); - toast.success("任务已删除(全局资产图保留)"); - load(); - } catch (e) { - toast.error("删除失败:" + (e as Error).message); - } - }, [load]); -======= const deleteTask = React.useCallback( async (id: string, options: DeleteTaskOptions) => { try { @@ -402,7 +504,6 @@ export default function TasksPage() { }, [batchControlling, load], ); ->>>>>>> Stashed changes return ( @@ -432,18 +533,77 @@ export default function TasksPage() { - 全部状态 - {STATUS_OPTIONS.map((s) => ( - - {s.label} - - ))} + + 全部状态 + {STATUS_OPTIONS.map((s) => ( + + {s.label} + + ))} + + + + {filtered.length}/{tasks.length} 条 - + {selectedIds.size > 0 && ( + <> + 已选 {selectedIds.size} 个 + + {pausableTaskIDs.length > 0 && ( + + )} + {resumableTaskIDs.length > 0 && ( + + )} + + + )} + + + {tasks.length === 0 ? ( @@ -458,6 +618,13 @@ export default function TasksPage() { + + toggleSelectedPage(checked === true)} + aria-label="选择本页全部任务" + /> + ID 名称 描述 @@ -467,7 +634,9 @@ export default function TasksPage() { 创建时间 运行时长 Token - 操作 + + 操作 + @@ -479,12 +648,9 @@ export default function TasksPage() { // 带来的整表重渲染,只让在跑的那几行走时长。 nowSec={task.status === "running" ? nowSec : 0} onDelete={deleteTask} -<<<<<<< Updated upstream -======= onControl={controlTask} selected={selectedIds.has(task.id)} onSelectedChange={toggleSelected} ->>>>>>> Stashed changes /> ))} @@ -495,13 +661,102 @@ export default function TasksPage() { pageSize={pageSize} total={filtered.length} onPageChange={setPage} - onPageSizeChange={setPageSize} + onPageSizeChange={(nextPageSize) => { + setPageSize(nextPageSize); + setPage(1); + }} /> ); } +function ConcurrencySettingsDialog() { + const [open, setOpen] = React.useState(false); + const [enabled, setEnabled] = React.useState(false); + const [limit, setLimit] = React.useState("5"); + const [loading, setLoading] = React.useState(false); + const [saving, setSaving] = React.useState(false); + + React.useEffect(() => { + if (!open) return; + setLoading(true); + api + .settings() + .then((settings) => { + setEnabled(!!settings.task_concurrency_enabled); + setLimit(String(settings.task_concurrency_limit ?? 5)); + }) + .catch(() => { + // Keep the dialog usable with defaults; reopening retries the request. + }) + .finally(() => setLoading(false)); + }, [open]); + + async function save() { + const nextLimit = Math.max(1, Math.floor(Number(limit) || 5)); + setSaving(true); + try { + await api.setSettings({ task_concurrency_enabled: enabled, task_concurrency_limit: nextLimit }); + toast.success(enabled ? `已开启并发限制:最多同时运行 ${nextLimit} 个任务` : "已关闭任务并发限制"); + setOpen(false); + } catch (error) { + toast.error(`保存失败:${(error as Error).message}`); + } finally { + setSaving(false); + } + } + + return ( + + + + + + + 任务并发限制 + + 限制同时运行的任务数量。达到上限后,新任务会按创建顺序排队并在空位出现时自动启动。 + + +
+
+
+ + 默认关闭 +
+ +
+ {enabled && ( +
+ + setLimit(event.target.value)} + disabled={loading} + /> +
+ )} +
+ + + + + + +
+
+ ); +} + // TaskRow renders one row of the task table. Memoized so the per-second 运行时长 tick and // the POLL_MS list refresh only re-render the rows whose data actually moved — a table page // is 20 rows × (StatusBadge + Link + a Radix AlertDialog tree), far too heavy to rebuild @@ -510,12 +765,6 @@ const TaskRow = React.memo(function TaskRow({ task, nowSec, onDelete, -<<<<<<< Updated upstream -}: { - task: Task; - nowSec: number; - onDelete: (id: string) => void; -======= onControl, selected, onSelectedChange, @@ -526,32 +775,29 @@ const TaskRow = React.memo(function TaskRow({ onControl: (id: string, action: "pause" | "resume") => Promise; selected: boolean; onSelectedChange: (id: string, checked: boolean) => void; ->>>>>>> Stashed changes }) { return ( - + + + onSelectedChange(task.id, checked === true)} + aria-label={`选择任务 ${task.id}`} + /> + {task.id}
-<<<<<<< Updated upstream - - {task.description} - - {task.active && ( - - )} -======= - {task.name || 未命名} + {task.name?.trim() ? task.name : 未命名} {task.active && } ->>>>>>> Stashed changes
@@ -568,9 +814,11 @@ const TaskRow = React.memo(function TaskRow({ - {typeof task.goals_total === "number" && task.goals_total > 0 - ? `${task.goals_met}/${task.goals_total}` - : —} + {typeof task.goals_total === "number" && task.goals_total > 0 ? ( + `${task.goals_met}/${task.goals_total}` + ) : ( + — + )} {fmtDateTime(task.created_unix)} @@ -607,38 +855,6 @@ const TaskRow = React.memo(function TaskRow({ )} -<<<<<<< Updated upstream -
- - - - - - - - 确认删除任务 #{task.id}? - - {task.description - ? `「${task.description.length > 80 ? task.description.slice(0, 80) + "…" : task.description}」` - : "该任务"} - 的执行记录、会话与产物将被删除,此操作不可撤销(全局资产图保留)。 - - - - 取消 - onDelete(task.id)}> - 删除 - - - - -=======
->>>>>>> Stashed changes
); }); -<<<<<<< Updated upstream -======= // 三态图标分开写而非嵌套三元:仓库 Biome 基线禁 noNestedTernary。 function taskControlIcon(pending: boolean, action: "pause" | "resume" | null) { if (pending) return ; @@ -973,18 +1186,357 @@ function BulkDeleteTasksDialog({ ); } ->>>>>>> Stashed changes // CreateTaskSheet is the 新建任务 drawer. Its form state lives HERE, not in TasksPage: with // 描述/目标 held by the page component every keystroke re-rendered the whole task table // behind the drawer (plus its sticky column and 20 AlertDialog trees), which showed up as // input lag. Now typing only re-renders the drawer. -function CreateTaskSheet({ onCreated }: { onCreated: () => void }) { +function SourceTaskPicker({ + tasks, + value, + onValueChange, + portalContainer, +}: { + tasks: Task[]; + value: string[]; + onValueChange: (value: string[]) => void; + portalContainer?: React.RefObject; +}) { + const tasksByID = React.useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]); + const taskIDs = React.useMemo(() => tasks.map((task) => task.id), [tasks]); + const atLimit = value.length >= MAX_SOURCE_TASKS; + + const handleValueChange = (next: string[]) => { + onValueChange(next.slice(0, MAX_SOURCE_TASKS)); + }; + + return ( + { + const task = tasksByID.get(taskID); + return task ? `${task.id} ${task.description} ${task.goal}` : taskID; + }} + multiple + value={value} + onValueChange={handleValueChange} + > + + + {value.map((taskID) => ( + #{taskID} + ))} + + + + + 没有匹配的任务 + + {(taskID) => { + const task = tasksByID.get(taskID); + return ( + +
+
+

+ #{taskID} · {task?.description ?? "未知任务"} +

+ {task?.goal &&

{task.goal}

} +
+ {task && } +
+
+ ); + }} +
+
+
+ ); +} + +const COMPANY_SCOPE_LABELS: Record = { + domain: "域名", + ip: "IP", + cidr: "CIDR", + icp: "ICP", + keyword: "关键词", +}; + +function companyScopeSummary(company: Company): string { + const rows = company.scope ?? []; + if (rows.length === 0) return "未配置资产范围"; + const preview = rows.slice(0, 3).map((row) => { + const value = row.raw || row.value || row.domain || row.net || ""; + return `${COMPANY_SCOPE_LABELS[row.kind] ?? row.kind}:${value}`; + }); + return `${preview.join(" · ")}${rows.length > preview.length ? ` · 另 ${rows.length - preview.length} 条` : ""}`; +} + +function CompanyPicker({ + companies, + value, + onValueChange, + portalContainer, +}: { + companies: Company[]; + value: number[]; + onValueChange: (value: number[]) => void; + portalContainer?: React.RefObject; +}) { + const companiesByID = React.useMemo( + () => new Map(companies.map((company) => [String(company.id), company])), + [companies], + ); + const companyIDs = React.useMemo(() => companies.map((company) => String(company.id)), [companies]); + const selectedIDs = React.useMemo(() => value.map(String), [value]); + + return ( + { + const company = companiesByID.get(companyID); + return company ? `${company.name} ${companyScopeSummary(company)}` : companyID; + }} + multiple + value={selectedIDs} + onValueChange={(next) => onValueChange(next.map(Number).filter(Number.isFinite))} + > + + + {selectedIDs.map((companyID) => ( + {companiesByID.get(companyID)?.name ?? `企业 #${companyID}`} + ))} + + + + + 没有匹配的企业 + + {(companyID) => { + const company = companiesByID.get(companyID); + return ( + +
+
+ {company?.name ?? `企业 #${companyID}`} + + {company?.asset_count ?? 0} 个资产 + +
+ {company && ( + + {companyScopeSummary(company)} + + )} +
+
+ ); + }} +
+
+
+ ); +} + +function CategoryManagementSheet({ categories, onChanged }: { categories: TaskCategory[]; onChanged: () => void }) { + const [open, setOpen] = React.useState(false); + const [newName, setNewName] = React.useState(""); + const [editingID, setEditingID] = React.useState(null); + const [editingName, setEditingName] = React.useState(""); + const [saving, setSaving] = React.useState(false); + const [deleting, setDeleting] = React.useState(null); + + async function createCategory() { + const name = newName.trim(); + if (!name || saving) return; + setSaving(true); + try { + await api.createTaskCategory(name); + setNewName(""); + toast.success("分类已创建"); + onChanged(); + } catch (error) { + toast.error(`创建分类失败:${(error as Error).message}`); + } finally { + setSaving(false); + } + } + + async function renameCategory() { + const name = editingName.trim(); + if (!editingID || !name || saving) return; + setSaving(true); + try { + await api.renameTaskCategory(editingID, name); + setEditingID(null); + setEditingName(""); + toast.success("分类已更新"); + onChanged(); + } catch (error) { + toast.error(`更新分类失败:${(error as Error).message}`); + } finally { + setSaving(false); + } + } + + async function deleteCategory() { + if (!deleting || saving) return; + setSaving(true); + try { + await api.deleteTaskCategory(deleting.id); + toast.success("分类已删除,关联任务已移入未分类"); + setDeleting(null); + onChanged(); + } catch (error) { + toast.error(`删除分类失败:${(error as Error).message}`); + } finally { + setSaving(false); + } + } + + return ( + <> + + + + + + + 任务分类 + 创建、重命名或删除全局分类。删除分类不会删除任务。 + +
+ + + 新分类 +
+ setNewName(event.target.value)} + placeholder="例如:外网评估" + maxLength={80} + onKeyDown={(event) => { + if (event.key === "Enter") void createCategory(); + }} + /> + +
+
+
+
+ {categories.length === 0 &&

暂无分类

} + {categories.map((category) => ( +
+ + {editingID === category.id ? ( + setEditingName(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") void renameCategory(); + if (event.key === "Escape") setEditingID(null); + }} + aria-label={`重命名分类 ${category.name}`} + /> + ) : ( + + )} + + {category.task_count} 个任务 + + {editingID === category.id && ( + + )} + +
+ ))} +
+
+
+
+ !next && setDeleting(null)}> + + + 删除分类“{deleting?.name}”? + + 分类删除后,其中 {deleting?.task_count ?? 0} 个任务会自动移入“未分类”,任务数据不会被删除。 + + + + 取消 + void deleteCategory()} disabled={saving}> + 删除 + + + + + + ); +} + +function CreateTaskSheet({ + tasks, + categories, + onCreated, +}: { + tasks: Task[]; + categories: TaskCategory[]; + onCreated: () => void; +}) { const [open, setOpen] = React.useState(false); const [name, setName] = React.useState(""); + const [categoryID, setCategoryID] = React.useState("uncategorized"); const [description, setDescription] = React.useState(""); const [goal, setGoal] = React.useState(""); + const [selectedTemplateID, setSelectedTemplateID] = React.useState(null); const [profiles, setProfiles] = React.useState([]); - const [llmProfile, setLlmProfile] = React.useState(ACTIVE_PROFILE); // sentinel = active + const [companies, setCompanies] = React.useState([]); + const [sourceTaskIDs, setSourceTaskIDs] = React.useState([]); + const [companyIDs, setCompanyIDs] = React.useState([]); + const [llmProfileIDs, setLLMProfileIDs] = React.useState([]); + const [creating, setCreating] = React.useState(false); const [timeoutMin, setTimeoutMin] = React.useState(""); // 任务级超时(分钟);空/0 = 不限时 const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致) const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行 @@ -994,10 +1546,18 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) { const [uploadCount, setUploadCount] = React.useState(0); const draftIdRef = React.useRef(""); const fileInputRef = React.useRef(null); + const sheetContentRef = React.useRef(null); // load LLM profiles once for the create-task profile picker. React.useEffect(() => { - api.llmProfiles().then(setProfiles).catch(() => setProfiles([])); + api + .llmProfiles() + .then(setProfiles) + .catch(() => setProfiles([])); + api + .companies() + .then(setCompanies) + .catch(() => setCompanies([])); }, []); // pickFiles uploads the chosen files into this draft's staging dir and appends their @@ -1007,8 +1567,7 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) { // crypto.randomUUID 仅在安全上下文可用(https/localhost);经 IP+http 访问时降级。 if (!draftIdRef.current) { draftIdRef.current = - globalThis.crypto?.randomUUID?.() ?? - `d${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`; + globalThis.crypto?.randomUUID?.() ?? `d${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`; } setUploading(true); try { @@ -1028,15 +1587,17 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) { toast.error("请填写描述与目标"); return; } + if (sourceTaskIDs.length > MAX_SOURCE_TASKS) { + toast.error(`最多关联 ${MAX_SOURCE_TASKS} 个来源任务`); + return; + } + setCreating(true); try { - const pid = llmProfile === ACTIVE_PROFILE ? undefined : Number(llmProfile); const timeoutSec = Math.max(0, Math.floor(Number(timeoutMin) || 0)) * 60; const heartbeatSec = Math.max(10, Math.floor(Number(heartbeatMin) || 10)) * 60; // 下限 10min,与后端归一一致 -<<<<<<< Updated upstream - await api.createTask(description.trim(), goal.trim(), pid, timeoutSec, seedFirstIntent, heartbeatSec); -======= await api.createTask({ name: name.trim(), + categoryId: categoryID === "uncategorized" ? undefined : Number(categoryID), description: description.trim(), goal: goal.trim(), llmProfileIds: llmProfileIDs.map(Number), @@ -1047,12 +1608,15 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) { planHeartbeatSeconds: heartbeatSec, coverageEnabled, }); ->>>>>>> Stashed changes toast.success("任务已创建"); setName(""); + setCategoryID("uncategorized"); setDescription(""); setGoal(""); - setLlmProfile(ACTIVE_PROFILE); + setSelectedTemplateID(null); + setSourceTaskIDs([]); + setCompanyIDs([]); + setLLMProfileIDs([]); setTimeoutMin(""); setHeartbeatMin("10"); setSeedFirstIntent(false); @@ -1063,16 +1627,12 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) { onCreated(); } catch (e) { toast.error("创建失败:" + (e as Error).message); + } finally { + setCreating(false); } } return ( -<<<<<<< Updated upstream - - -
+ + 任务分类 + + 用于任务列表筛选和归档,不影响 Agent 执行。 +