feat(tasks): add categories templates and batch controls

This commit is contained in:
pphua committed 2026-08-24 19:29:46 +08:00
1 parent 3bedbdc669
commit 04fda4cd01
9 files changed
+2337 -295

No files matched your search

+201
View File
@@ -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
}
+91
View File
@@ -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)
}
}
+231
View File
@@ -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
}
+131
View File
@@ -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)
}
}
+159
View File
@@ -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)))
}
+172
View File
@@ -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
}
+163
View File
@@ -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())
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,459 @@
"use client";
import * as React from "react";
import { LibraryIcon, PlusIcon, SaveIcon, Settings2Icon, Trash2Icon } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import type { TaskTemplate } from "@/lib/types";
import { cn } from "@/lib/utils";
interface TemplateDraft {
name: string;
description: string;
goal: string;
}
interface TaskTemplateManagerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
templates: TaskTemplate[];
seed: Pick<TemplateDraft, "description" | "goal"> | null;
onCreated: (template: TaskTemplate) => void;
onUpdated: (template: TaskTemplate) => void;
onDeleted: (id: number) => void;
}
const emptyDraft = (): TemplateDraft => ({ name: "", description: "", goal: "" });
function templateDraft(template: TaskTemplate): TemplateDraft {
return {
name: template.name,
description: template.description,
goal: template.goal,
};
}
function TaskTemplateManager({
open,
onOpenChange,
templates,
seed,
onCreated,
onUpdated,
onDeleted,
}: TaskTemplateManagerProps) {
const [selectedID, setSelectedID] = React.useState<number | null>(null);
const [draft, setDraft] = React.useState<TemplateDraft>(emptyDraft);
const [saving, setSaving] = React.useState(false);
const [deleteOpen, setDeleteOpen] = React.useState(false);
const [deleting, setDeleting] = React.useState(false);
const wasOpen = React.useRef(false);
React.useEffect(() => {
if (open && !wasOpen.current) {
if (seed) {
setSelectedID(null);
setDraft({ name: "", description: seed.description, goal: seed.goal });
} else if (templates[0]) {
setSelectedID(templates[0].id);
setDraft(templateDraft(templates[0]));
} else {
setSelectedID(null);
setDraft(emptyDraft());
}
}
wasOpen.current = open;
}, [open, seed, templates]);
const selectTemplate = (template: TaskTemplate) => {
setSelectedID(template.id);
setDraft(templateDraft(template));
};
const startNew = () => {
setSelectedID(null);
setDraft(emptyDraft());
};
const updateDraft = (field: keyof TemplateDraft, value: string) => {
setDraft((current) => ({ ...current, [field]: value }));
};
async function save() {
const input = {
name: draft.name.trim(),
description: draft.description.trim(),
goal: draft.goal.trim(),
};
if (!input.name || !input.description || !input.goal) {
toast.error("请填写模板名称、描述和目标");
return;
}
setSaving(true);
try {
if (selectedID == null) {
const created = await api.createTaskTemplate(input);
onCreated(created);
setSelectedID(created.id);
setDraft(templateDraft(created));
toast.success("模板已创建");
} else {
const updated = await api.updateTaskTemplate(selectedID, input);
onUpdated(updated);
setDraft(templateDraft(updated));
toast.success("模板已更新");
}
} catch (error) {
toast.error(`保存失败:${(error as Error).message}`);
} finally {
setSaving(false);
}
}
async function remove() {
if (selectedID == null) return;
const deletedID = selectedID;
setDeleting(true);
try {
await api.deleteTaskTemplate(deletedID);
onDeleted(deletedID);
const next = templates.find((template) => template.id !== deletedID);
if (next) {
setSelectedID(next.id);
setDraft(templateDraft(next));
} else {
startNew();
}
setDeleteOpen(false);
toast.success("模板已删除");
} catch (error) {
toast.error(`删除失败:${(error as Error).message}`);
} finally {
setDeleting(false);
}
}
let saveLabel = saving ? "保存中" : "保存修改";
if (!saving && selectedID == null) saveLabel = "创建模板";
return (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="grid h-full w-full! max-w-none! grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:w-[48rem]! sm:max-w-[48rem]!">
<SheetHeader className="border-b px-6 py-5">
<SheetTitle>任务模板管理</SheetTitle>
<SheetDescription>模板只保存描述与目标;修改不会影响已经创建的任务。</SheetDescription>
</SheetHeader>
<div className="grid min-h-0 overflow-y-auto lg:grid-cols-[15rem_minmax(0,1fr)] lg:overflow-hidden">
<div className="flex min-h-0 flex-col border-b p-3 lg:border-r lg:border-b-0">
<Button type="button" variant="outline" className="w-full" onClick={startNew}>
<PlusIcon data-icon="inline-start" />
新建模板
</Button>
<ScrollArea className="mt-2 max-h-44 lg:max-h-none lg:flex-1">
<div className="flex flex-col gap-1 pr-2">
{templates.length === 0 && (
<p className="px-2 py-6 text-center text-muted-foreground text-sm">暂无模板</p>
)}
{templates.map((template) => (
<button
key={template.id}
type="button"
className={cn(
"min-w-0 rounded-md px-2.5 py-2 text-left transition-colors",
selectedID === template.id ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
)}
onClick={() => selectTemplate(template)}
>
<span className="block truncate font-medium text-sm">{template.name}</span>
<span className="block truncate text-muted-foreground text-xs">{template.description}</span>
</button>
))}
</div>
</ScrollArea>
</div>
<ScrollArea className="min-h-0">
<FieldGroup className="p-6">
<Field>
<FieldLabel htmlFor="task-template-name">模板名称</FieldLabel>
<Input
id="task-template-name"
value={draft.name}
maxLength={120}
placeholder="例如:外部 Web 渗透"
onChange={(event) => updateDraft("name", event.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="task-template-description">描述</FieldLabel>
<Textarea
id="task-template-description"
className="min-h-28"
value={draft.description}
placeholder="测试对象与背景"
onChange={(event) => updateDraft("description", event.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="task-template-goal">目标</FieldLabel>
<Textarea
id="task-template-goal"
className="min-h-28"
value={draft.goal}
placeholder="任务需要达成的目标"
onChange={(event) => updateDraft("goal", event.target.value)}
/>
</Field>
</FieldGroup>
</ScrollArea>
</div>
<SheetFooter className="border-t px-6 py-4 sm:flex-row sm:items-center">
{selectedID != null && (
<Button type="button" variant="destructive" className="sm:mr-auto" onClick={() => setDeleteOpen(true)}>
<Trash2Icon data-icon="inline-start" />
删除模板
</Button>
)}
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
关闭
</Button>
<Button type="button" disabled={saving} onClick={() => void save()}>
{saving ? <Spinner data-icon="inline-start" /> : <SaveIcon data-icon="inline-start" />}
{saveLabel}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除模板「{draft.name || "未命名模板"}」?</AlertDialogTitle>
<AlertDialogDescription>已由该模板创建的任务不会受到影响。</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleting}
onClick={(event) => {
event.preventDefault();
void remove();
}}
>
{deleting && <Spinner data-icon="inline-start" />}
{deleting ? "删除中" : "删除"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
interface TaskTemplateControlsProps {
description: string;
goal: string;
selectedTemplateID: number | null;
onSelectedTemplateIDChange: (id: number | null) => void;
onApply: (template: TaskTemplate) => void;
portalContainer?: React.RefObject<HTMLElement | null>;
}
export function TaskTemplateControls({
description,
goal,
selectedTemplateID,
onSelectedTemplateIDChange,
onApply,
portalContainer,
}: TaskTemplateControlsProps) {
const [templates, setTemplates] = React.useState<TaskTemplate[]>([]);
const [loading, setLoading] = React.useState(true);
const [templateInputValue, setTemplateInputValue] = React.useState("");
const [pendingTemplate, setPendingTemplate] = React.useState<TaskTemplate | null>(null);
const [managerOpen, setManagerOpen] = React.useState(false);
const [managerSeed, setManagerSeed] = React.useState<Pick<TemplateDraft, "description" | "goal"> | null>(null);
const loadTemplates = React.useCallback(async () => {
setLoading(true);
try {
setTemplates(await api.taskTemplates());
} catch (error) {
setTemplates([]);
toast.error(`加载模板失败:${(error as Error).message}`);
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
void loadTemplates();
}, [loadTemplates]);
const selectedTemplate = React.useMemo(
() => templates.find((template) => template.id === selectedTemplateID) ?? null,
[selectedTemplateID, templates],
);
React.useEffect(() => {
setTemplateInputValue(selectedTemplate?.name ?? "");
}, [selectedTemplate]);
const applyTemplate = (template: TaskTemplate) => {
onApply(template);
onSelectedTemplateIDChange(template.id);
setPendingTemplate(null);
};
const chooseTemplate = (template: TaskTemplate | null) => {
if (!template) {
setTemplateInputValue("");
onSelectedTemplateIDChange(null);
return;
}
setTemplateInputValue(template.name);
const hasContent = description.trim() !== "" || goal.trim() !== "";
const changesContent = description !== template.description || goal !== template.goal;
if (hasContent && changesContent) {
setPendingTemplate(template);
return;
}
applyTemplate(template);
};
const openManager = (seed: Pick<TemplateDraft, "description" | "goal"> | null) => {
setManagerSeed(seed);
setManagerOpen(true);
};
const upsertTemplate = (template: TaskTemplate) => {
setTemplates((current) => [template, ...current.filter((item) => item.id !== template.id)]);
};
const deleteTemplate = (id: number) => {
setTemplates((current) => current.filter((template) => template.id !== id));
if (selectedTemplateID === id) onSelectedTemplateIDChange(null);
};
let pickerPlaceholder = loading ? "正在加载模板" : "暂无任务模板";
if (!loading && templates.length > 0) pickerPlaceholder = "搜索并选择任务模板";
return (
<>
<Field>
<div className="flex flex-wrap items-center justify-between gap-2">
<FieldLabel htmlFor="task-template-picker">任务模板</FieldLabel>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="ghost" size="sm" onClick={() => openManager(null)}>
<Settings2Icon data-icon="inline-start" />
管理模板
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!description.trim() || !goal.trim()}
onClick={() => openManager({ description, goal })}
>
<SaveIcon data-icon="inline-start" />
另存为模板
</Button>
</div>
</div>
<Combobox
items={templates}
itemToStringLabel={(template) => template.name}
itemToStringValue={(template) => String(template.id)}
inputValue={templateInputValue}
onInputValueChange={(value) => setTemplateInputValue(value)}
value={selectedTemplate}
onValueChange={chooseTemplate}
>
<ComboboxInput
id="task-template-picker"
className="w-full"
placeholder={pickerPlaceholder}
disabled={loading || templates.length === 0}
showClear
/>
<ComboboxContent portalContainer={portalContainer}>
<ComboboxEmpty>没有匹配的模板</ComboboxEmpty>
<ComboboxList>
{(template) => (
<ComboboxItem key={template.id} value={template}>
<LibraryIcon />
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{template.name}</p>
{template.description && (
<p className="truncate text-muted-foreground text-xs">{template.description}</p>
)}
</div>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<FieldDescription>选择后会复制模板的描述和目标,不与模板保持关联。</FieldDescription>
</Field>
<AlertDialog
open={pendingTemplate != null}
onOpenChange={(open) => {
if (open) return;
setPendingTemplate(null);
setTemplateInputValue(selectedTemplate?.name ?? "");
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>使用模板「{pendingTemplate?.name}」?</AlertDialogTitle>
<AlertDialogDescription>当前已填写的描述和目标将被模板内容覆盖。</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={() => pendingTemplate && applyTemplate(pendingTemplate)}>
覆盖并使用
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<TaskTemplateManager
open={managerOpen}
onOpenChange={setManagerOpen}
templates={templates}
seed={managerSeed}
onCreated={upsertTemplate}
onUpdated={upsertTemplate}
onDeleted={deleteTemplate}
/>
</>
);
}