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() { /> 计划名称 + 备注 时间线 运行周期 开始时间 @@ -738,6 +740,18 @@ export default function SchedulesPage() {
{item.name}
计划 #{item.id}
+ + { + const updated = await api.updateScheduleRemark(item.id, remark); + setItems((current) => + current.map((entry) => entry.id === item.id ? { ...entry, remark: updated.remark } : entry), + ); + }} + /> +