diff --git a/db/schedules.go b/db/schedules.go
index b5e908f..43e01cf 100644
--- a/db/schedules.go
+++ b/db/schedules.go
@@ -14,6 +14,7 @@ import (
type TaskSchedule struct {
ID int64 `json:"id"`
Name string `json:"name"`
+ Remark string `json:"remark"`
Enabled bool `json:"enabled"`
TimezoneMode string `json:"timezone_mode"`
ScheduleType string `json:"schedule_type"`
@@ -46,7 +47,7 @@ type TaskScheduleInput struct {
TaskIDs []int64
}
-const scheduleCols = `id,name,enabled,timezone_mode,schedule_type,
+const scheduleCols = `id,name,remark,enabled,timezone_mode,schedule_type,
COALESCE(to_char(run_date,'YYYY-MM-DD'),''),weekdays,to_char(start_time,'HH24:MI:SS'),
COALESCE(to_char(end_date,'YYYY-MM-DD'),''),
COALESCE(to_char(end_time,'HH24:MI:SS'),''),start_immediately,status,last_window_key,
@@ -63,7 +64,7 @@ func marshalWeekdays(days []int) string {
func scanSchedule(sc interface{ Scan(...any) error }) (*TaskSchedule, error) {
var s TaskSchedule
var rawDays string
- if err := sc.Scan(&s.ID, &s.Name, &s.Enabled, &s.TimezoneMode, &s.ScheduleType,
+ if err := sc.Scan(&s.ID, &s.Name, &s.Remark, &s.Enabled, &s.TimezoneMode, &s.ScheduleType,
&s.RunDate, &rawDays, &s.StartTime, &s.EndDate, &s.EndTime, &s.StartImmediately,
&s.Status, &s.LastWindowKey, &s.LastTransitionAt, &s.LastError,
&s.CreatedAt, &s.UpdatedAt); err != nil {
@@ -403,3 +404,16 @@ func (d *DB) ListScheduleHistory(id int64) ([]ScheduleHistory, error) {
}
return items, rows.Err()
}
+
+// UpdateTaskScheduleRemark leaves calendar state and task bindings untouched.
+func (d *DB) UpdateTaskScheduleRemark(id int64, remark string) (*TaskSchedule, error) {
+ item, err := scanSchedule(d.QueryRow(`UPDATE task_schedules SET remark=$2 WHERE id=$1 RETURNING `+scheduleCols, id, remark))
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ item.TaskIDs, err = d.scheduleTaskIDs(id)
+ return item, err
+}
diff --git a/db/schema.sql b/db/schema.sql
index a6cad75..c8b68f6 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -553,6 +553,7 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queued_at TIMESTAMPTZ;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queue_mode TEXT NOT NULL DEFAULT '';
-- 可选的任务名称;补旧库。空串=未命名,前端展示时回退到描述。
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT '';
+ALTER TABLE tasks ADD COLUMN IF NOT EXISTS remark TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS active_llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL;
@@ -1057,6 +1058,7 @@ CREATE TABLE IF NOT EXISTS task_schedules (
);
CREATE INDEX IF NOT EXISTS idx_task_schedules_enabled ON task_schedules(enabled, id);
ALTER TABLE task_schedules ADD COLUMN IF NOT EXISTS end_date DATE;
+ALTER TABLE task_schedules ADD COLUMN IF NOT EXISTS remark TEXT NOT NULL DEFAULT '';
DROP TRIGGER IF EXISTS trg_task_schedules_upd ON task_schedules;
CREATE TRIGGER trg_task_schedules_upd BEFORE UPDATE ON task_schedules
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
diff --git a/db/task_archives_restore.go b/db/task_archives_restore.go
index aa23ed6..5249cd1 100644
--- a/db/task_archives_restore.go
+++ b/db/task_archives_restore.go
@@ -119,7 +119,7 @@ AND NOT EXISTS (
return err
}
if _, err := tx.Exec(`UPDATE tasks SET
- name='',category_id=NULL,description='',goal='',paused=true,queued=false,queued_at=NULL,queue_mode='',
+ name='',remark='',category_id=NULL,description='',goal='',paused=true,queued=false,queued_at=NULL,queue_mode='',
llm_profile_id=NULL,active_llm_profile_id=NULL,llm_chain_revision=llm_chain_revision+1,
company_id=NULL,parent_ref=NULL,timeout_seconds=0,coverage_enabled=true,pinned_at=NULL,
first_run_at=NULL,deadline_at=NULL,archived_at=now(),deleted_at=now()
@@ -316,7 +316,7 @@ func restoreTaskStub(tx *sql.Tx, row map[string]any, taskID, remaining int64) er
return err
}
_, err = tx.Exec(`UPDATE tasks current SET
- name=archived.name,category_id=archived.category_id,description=archived.description,goal=archived.goal,
+ name=archived.name,remark=COALESCE(archived.remark,''),category_id=archived.category_id,description=archived.description,goal=archived.goal,
status=archived.status,paused=archived.paused,queued=false,queued_at=NULL,queue_mode='',
llm_profile_id=archived.llm_profile_id,active_llm_profile_id=archived.active_llm_profile_id,
llm_chain_revision=archived.llm_chain_revision,company_id=archived.company_id,parent_ref=archived.parent_ref,
diff --git a/db/task_archives_test.go b/db/task_archives_test.go
index 498fa97..a3296ec 100644
--- a/db/task_archives_test.go
+++ b/db/task_archives_test.go
@@ -136,6 +136,10 @@ func TestTaskArchiveDatabaseRoundTrip(t *testing.T) {
if err != nil {
t.Fatal(err)
}
+ remark := "归档后保留的备注 📝"
+ if _, err := d.UpdateTask(task.ID, TaskPatch{Remark: &remark}); err != nil {
+ t.Fatal(err)
+ }
var companyID, llmProfileID int64
if err := d.QueryRow(`INSERT INTO companies(name,nkey) VALUES($1,$2) RETURNING id`,
fmt.Sprintf("archive-company-%d", task.ID), fmt.Sprintf("archive-company-%d", task.ID)).Scan(&companyID); err != nil {
@@ -282,7 +286,7 @@ VALUES($1,$2,0,'quota_exhausted','balance exhausted',$3,$4,$3)`, task.ID, llmPro
if err != nil || live == nil {
t.Fatalf("restored task = %+v, %v", live, err)
}
- if live.Name != "cold task" || !live.Paused {
+ if live.Name != "cold task" || live.Remark != remark || !live.Paused {
t.Fatalf("restored task metadata mismatch: %+v", live)
}
if err := d.QueryRow(`SELECT count(*) FROM exploration_nodes WHERE exploration_id=$1`, task.ExplorationID).Scan(&nodes); err != nil {
diff --git a/db/tasks.go b/db/tasks.go
index 4e05cb9..b675bea 100644
--- a/db/tasks.go
+++ b/db/tasks.go
@@ -13,6 +13,7 @@ import (
type Task struct {
ID int64 `json:"id"`
Name string `json:"name"` // 可选任务名称;空=未命名
+ Remark string `json:"remark"`
CategoryID *int64 `json:"category_id,omitempty"`
CategoryName string `json:"category_name,omitempty"`
Pinned bool `json:"pinned"`
@@ -360,13 +361,13 @@ func insertTaskLLMProfiles(tx *sql.Tx, taskID int64, profileIDs []int64) error {
return nil
}
-const taskCols = `id, COALESCE(name,''), category_id,
+const taskCols = `id, COALESCE(name,''), remark, category_id,
COALESCE((SELECT category.name FROM task_categories category WHERE category.id=tasks.category_id),''),
description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), first_run_at, deadline_at`
func scanTask(sc interface{ Scan(...any) error }) (*Task, error) {
var t Task
- if err := sc.Scan(&t.ID, &t.Name, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.FirstRunAt, &t.DeadlineAt); err != nil {
+ if err := sc.Scan(&t.ID, &t.Name, &t.Remark, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.FirstRunAt, &t.DeadlineAt); err != nil {
return nil, err
}
t.Pinned = t.PinnedAt != nil
@@ -409,21 +410,23 @@ ORDER BY (pinned_at IS NOT NULL) DESC, pinned_at DESC NULLS LAST, id DESC`)
// TaskPatch updates task list metadata without changing execution state.
type TaskPatch struct {
Name *string
+ Remark *string
Pinned *bool
}
-// UpdateTask applies a partial task name/pin mutation and returns the updated row.
+// UpdateTask applies a partial task name/remark/pin mutation and returns the updated row.
// Re-pinning an already pinned task preserves its original position.
func (d *DB) UpdateTask(id int64, patch TaskPatch) (*Task, error) {
task, err := scanTask(d.QueryRow(`UPDATE tasks SET
name = CASE WHEN $2::boolean THEN $3 ELSE name END,
+ remark = CASE WHEN $5::boolean THEN $6 ELSE remark END,
pinned_at = CASE
WHEN $4::boolean IS NULL THEN pinned_at
WHEN $4::boolean THEN COALESCE(pinned_at, now())
ELSE NULL
END
WHERE id=$1 AND deleted_at IS NULL
-RETURNING `+taskCols, id, patch.Name != nil, patch.Name, patch.Pinned))
+RETURNING `+taskCols, id, patch.Name != nil, patch.Name, patch.Pinned, patch.Remark != nil, patch.Remark))
if err == sql.ErrNoRows {
return nil, nil
}
diff --git a/server/dto.go b/server/dto.go
index bcb349f..60785a5 100644
--- a/server/dto.go
+++ b/server/dto.go
@@ -35,6 +35,7 @@ type TaskDTO struct {
ID string `json:"id"`
ExplorationID int64 `json:"exploration_id"`
Name string `json:"name"` // 可选任务名称;空=未命名
+ Remark string `json:"remark"`
CategoryID *int64 `json:"category_id,omitempty"`
CategoryName string `json:"category_name,omitempty"`
Pinned bool `json:"pinned"`
@@ -115,6 +116,7 @@ func taskDTO(t *Task, status string) TaskDTO {
ID: t.ID,
ExplorationID: t.ExpID,
Name: lifecycle.Name,
+ Remark: lifecycle.Remark,
CategoryID: lifecycle.CategoryID,
CategoryName: lifecycle.CategoryName,
Pinned: lifecycle.PinnedAt > 0,
diff --git a/server/manager.go b/server/manager.go
index ee9a3d0..11a4dd1 100644
--- a/server/manager.go
+++ b/server/manager.go
@@ -29,6 +29,7 @@ import (
type Task struct {
ID string `json:"id"`
ExpID int64 `json:"exploration_id"`
+ Remark string `json:"remark"`
Name string `json:"name"` // 可选任务名称;空=未命名
CategoryID *int64 `json:"category_id,omitempty"`
CategoryName string `json:"category_name,omitempty"`
@@ -79,6 +80,7 @@ type Task struct {
// directly after the task has been published by Manager.
type taskLifecycleState struct {
Name string
+ Remark string
PinnedAt int64
Status string
Paused bool
@@ -106,6 +108,7 @@ func (t *Task) lifecycleSnapshot() taskLifecycleState {
func (t *Task) lifecycleSnapshotLocked() taskLifecycleState {
return taskLifecycleState{
Name: t.Name,
+ Remark: t.Remark,
PinnedAt: t.PinnedAt,
Status: t.Status,
Paused: t.Paused,
@@ -130,6 +133,7 @@ func (t *Task) updateLifecycle(update func(*taskLifecycleState)) {
state := t.lifecycleSnapshotLocked()
update(&state)
t.Name = state.Name
+ t.Remark = state.Remark
t.PinnedAt = state.PinnedAt
t.Status = state.Status
t.Paused = state.Paused
@@ -832,6 +836,7 @@ func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Inter
return &Task{
ID: strconv.FormatInt(pt.ID, 10), ExpID: pt.ExplorationID,
Name: pt.Name,
+ Remark: pt.Remark,
CategoryID: cloneInt64Ptr(pt.CategoryID), CategoryName: pt.CategoryName,
PinnedAt: unixOrZero(pt.PinnedAt),
Description: pt.Description, Goal: pt.Goal, CreatedAt: pt.CreatedAt.Unix(), Paused: pt.Paused, Queued: pt.Queued,
@@ -871,6 +876,7 @@ func (m *Manager) UpdateTaskMetadata(taskID string, patch pgdb.TaskPatch) (*Task
}
task.updateLifecycle(func(state *taskLifecycleState) {
state.Name = updated.Name
+ state.Remark = updated.Remark
state.PinnedAt = unixOrZero(updated.PinnedAt)
})
return task, nil
diff --git a/server/remarks.go b/server/remarks.go
new file mode 100644
index 0000000..fe4619a
--- /dev/null
+++ b/server/remarks.go
@@ -0,0 +1,59 @@
+package server
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "unicode/utf8"
+)
+
+const maxRemarkRunes = 2000
+
+func normalizeRemark(value string) (string, error) {
+ value = strings.TrimSpace(value)
+ if utf8.RuneCountInString(value) > maxRemarkRunes {
+ return "", fmt.Errorf("备注最多 %d 个字符", maxRemarkRunes)
+ }
+ return value, nil
+}
+
+func (s *Server) updateScheduleRemark(w http.ResponseWriter, r *http.Request) {
+ id, err := parseScheduleID(r)
+ if err != nil || id <= 0 {
+ writeErr(w, 400, "计划编号无效")
+ return
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, maxTaskMetadataRequestSize)
+ var request struct {
+ Remark *string `json:"remark"`
+ }
+ if err := decode(r, &request); err != nil {
+ var tooLarge *http.MaxBytesError
+ if errors.As(err, &tooLarge) {
+ writeErr(w, 413, "请求正文过大")
+ } else {
+ writeErr(w, 400, err.Error())
+ }
+ return
+ }
+ if request.Remark == nil {
+ writeErr(w, 400, "需要提供 remark")
+ return
+ }
+ remark, err := normalizeRemark(*request.Remark)
+ if err != nil {
+ writeErr(w, 400, err.Error())
+ return
+ }
+ item, err := s.m.pg.UpdateTaskScheduleRemark(id, remark)
+ if err != nil {
+ writeErr(w, 500, err.Error())
+ return
+ }
+ if item == nil {
+ writeErr(w, 404, "计划不存在")
+ return
+ }
+ writeJSON(w, 200, item)
+}
diff --git a/server/remarks_test.go b/server/remarks_test.go
new file mode 100644
index 0000000..ea8b82f
--- /dev/null
+++ b/server/remarks_test.go
@@ -0,0 +1,170 @@
+package server
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/Autumn-27/artex/db"
+)
+
+func TestRemarksRoundTripPreservesExecutionState(t *testing.T) {
+ m, err := NewManager(t.TempDir(), "")
+ if err != nil {
+ t.Skipf("postgres unavailable: %v", err)
+ }
+ defer m.Close()
+ task, err := m.CreateTask("remark test description", "original goal", nil, 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ taskID, _ := strconv.ParseInt(task.ID, 10, 64)
+ defer func() { _ = m.pg.DeleteTask(taskID) }()
+ scheduleInput := db.TaskScheduleInput{Name: "remark schedule", Enabled: false, ScheduleType: "once", RunDate: "2099-01-01", StartTime: "09:00", TaskIDs: []int64{taskID}}
+ schedule, err := m.pg.CreateTaskSchedule(scheduleInput)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = m.pg.DeleteTaskSchedule(schedule.ID) }()
+ if _, err := m.pg.Exec(`UPDATE task_schedules SET status='completed',last_window_key='preserve-window',last_error='preserve-error',last_transition_at=now() WHERE id=$1`, schedule.ID); err != nil {
+ t.Fatal(err)
+ }
+ original, err := m.pg.GetTaskSchedule(schedule.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ originalLifecycle := task.lifecycleSnapshot()
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
+ token, err := signJWT(s.jwtKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ handler := s.Handler()
+ request := func(path, body string, authenticated bool) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(http.MethodPatch, path, strings.NewReader(body))
+ if authenticated {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+ return rec
+ }
+ taskPath := "/api/tasks/" + task.ID
+ schedulePath := fmt.Sprintf("/api/schedules/%d/remark", schedule.ID)
+ for _, path := range []string{taskPath, schedulePath} {
+ t.Run(path, func(t *testing.T) {
+ for _, remark := range []string{" 中文备注 📝\n第二行 ", strings.Repeat("📝", maxRemarkRunes), ""} {
+ body, _ := json.Marshal(map[string]string{"remark": remark})
+ rec := request(path, string(body), true)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("save: %d %s", rec.Code, rec.Body.String())
+ }
+ var response struct {
+ Remark string `json:"remark"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
+ t.Fatal(err)
+ }
+ expected := strings.TrimSpace(remark)
+ if response.Remark != expected {
+ t.Fatal("response remark mismatch")
+ }
+ if path == taskPath {
+ persisted, err := m.pg.GetTask(taskID)
+ if err != nil || persisted.Remark != expected {
+ t.Fatalf("persisted task: %+v %v", persisted, err)
+ }
+ listed, err := m.pg.ListTasks()
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, item := range listed {
+ if item.ID == taskID {
+ found = item.Remark == expected
+ }
+ }
+ if !found {
+ t.Fatal("task list lost remark")
+ }
+ dto := taskDTO(task, s.resolvedTaskStatus(task))
+ if dto.Remark != expected || dto.Goal != "original goal" || dto.Description != "remark test description" {
+ t.Fatal("task DTO lost remark or changed goal")
+ }
+ state := task.lifecycleSnapshot()
+ if state.Status != originalLifecycle.Status || state.Paused != originalLifecycle.Paused || state.Queued != originalLifecycle.Queued {
+ t.Fatal("remark changed task lifecycle")
+ }
+ } else {
+ persisted, err := m.pg.GetTaskSchedule(schedule.ID)
+ if err != nil || persisted.Remark != expected {
+ t.Fatalf("persisted schedule: %+v %v", persisted, err)
+ }
+ if persisted.Status != original.Status || persisted.Enabled != original.Enabled || persisted.LastWindowKey != original.LastWindowKey || persisted.LastError != original.LastError || !persisted.LastTransitionAt.Equal(*original.LastTransitionAt) || len(persisted.TaskIDs) != 1 || persisted.TaskIDs[0] != taskID {
+ t.Fatal("remark changed calendar state or bindings")
+ }
+ listed, err := m.pg.ListTaskSchedules()
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, item := range listed {
+ if item.ID == schedule.ID {
+ found = item.Remark == expected
+ }
+ }
+ if !found {
+ t.Fatal("schedule list lost remark")
+ }
+ }
+ }
+ cases := []struct {
+ body string
+ authenticated bool
+ status int
+ }{
+ {`{}`, true, 400}, {`{"remark":null}`, true, 400}, {`{"remark":123}`, true, 400}, {`{`, true, 400},
+ {`{"remark":"` + strings.Repeat("文", maxRemarkRunes+1) + `"}`, true, 400},
+ {`{"remark":"` + strings.Repeat("x", maxTaskMetadataRequestSize) + `"}`, true, 413},
+ {`{"remark":"denied"}`, false, 401},
+ }
+ for _, tc := range cases {
+ rec := request(path, tc.body, tc.authenticated)
+ if rec.Code != tc.status {
+ t.Fatalf("validation status=%d want=%d body=%s", rec.Code, tc.status, rec.Body.String())
+ }
+ }
+ })
+ }
+ // A normal rename/pin patch must preserve a previously saved remark.
+ note := "保留的任务备注"
+ if _, err := m.UpdateTaskMetadata(task.ID, db.TaskPatch{Remark: ¬e}); err != nil {
+ t.Fatal(err)
+ }
+ rec := request(taskPath, `{"name":"新名称","pinned":true}`, true)
+ if rec.Code != 200 || !bytes.Contains(rec.Body.Bytes(), []byte(note)) {
+ t.Fatalf("rename lost remark: %s", rec.Body.String())
+ }
+ note = "保留的计划备注"
+ if _, err := m.pg.UpdateTaskScheduleRemark(schedule.ID, note); err != nil {
+ t.Fatal(err)
+ }
+ scheduleInput.Name = "新计划名称"
+ updated, err := m.pg.UpdateTaskSchedule(schedule.ID, scheduleInput)
+ if err != nil || updated.Remark != note {
+ t.Fatalf("schedule edit lost remark: %+v %v", updated, err)
+ }
+ for _, path := range []string{"/api/tasks/9223372036854775807", "/api/schedules/9223372036854775807/remark"} {
+ if rec := request(path, `{"remark":"missing"}`, true); rec.Code != 404 {
+ t.Fatalf("missing record: %d %s", rec.Code, rec.Body.String())
+ }
+ }
+}
diff --git a/server/server.go b/server/server.go
index aad2fce..b6aef93 100644
--- a/server/server.go
+++ b/server/server.go
@@ -717,6 +717,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/schedules", s.createSchedule)
mux.HandleFunc("GET /api/schedules/{id}", s.getSchedule)
mux.HandleFunc("PATCH /api/schedules/{id}", s.updateSchedule)
+ mux.HandleFunc("PATCH /api/schedules/{id}/remark", s.updateScheduleRemark)
mux.HandleFunc("DELETE /api/schedules/{id}", s.deleteSchedule)
mux.HandleFunc("POST /api/schedules/{id}/pause", s.pauseSchedule)
mux.HandleFunc("POST /api/schedules/{id}/resume", s.resumeSchedule)
diff --git a/server/task_metadata.go b/server/task_metadata.go
index 9fec25d..89bc370 100644
--- a/server/task_metadata.go
+++ b/server/task_metadata.go
@@ -26,6 +26,7 @@ func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxTaskMetadataRequestSize)
var request struct {
Name *string `json:"name"`
+ Remark *string `json:"remark"`
Pinned *bool `json:"pinned"`
}
if err := decode(r, &request); err != nil {
@@ -37,8 +38,8 @@ func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) {
}
return
}
- if request.Name == nil && request.Pinned == nil {
- writeErr(w, http.StatusBadRequest, "至少需要提供 name 或 pinned")
+ if request.Name == nil && request.Pinned == nil && request.Remark == nil {
+ writeErr(w, http.StatusBadRequest, "至少需要提供 name、pinned 或 remark")
return
}
if request.Name != nil {
@@ -53,7 +54,16 @@ func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) {
}
request.Name = &name
}
- task, err := s.m.UpdateTaskMetadata(taskID, db.TaskPatch{Name: request.Name, Pinned: request.Pinned})
+
+ if request.Remark != nil {
+ remark, err := normalizeRemark(*request.Remark)
+ if err != nil {
+ writeErr(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ request.Remark = &remark
+ }
+ task, err := s.m.UpdateTaskMetadata(taskID, db.TaskPatch{Name: request.Name, Pinned: request.Pinned, Remark: request.Remark})
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
diff --git a/web/src/app/(main)/function/schedules/page.tsx b/web/src/app/(main)/function/schedules/page.tsx
index 4e500e1..dbedc63 100644
--- a/web/src/app/(main)/function/schedules/page.tsx
+++ b/web/src/app/(main)/function/schedules/page.tsx
@@ -17,6 +17,7 @@ import {
import { toast } from "sonner";
import { DateSelect } from "@/components/date-select";
+import { RemarkEditor } from "@/components/remark-editor";
import { TimeSelect } from "@/components/time-select";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -713,6 +714,7 @@ export default function SchedulesPage() {
/>