Merge pull request #19 from RuoJi6/codex/task-remarks

feat: 支持任务与计划任务行内编辑备注
This commit is contained in:
RuoJi6 authored and GitHub committed 2026-10-19 10:04:38 +08:00
commit 36043c0ac1
17 files changed
+570 -18

No files matched your search

+29 -2
View File
@@ -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,29 @@ func (d *DB) ListScheduleHistory(id int64) ([]ScheduleHistory, error) {
}
return items, rows.Err()
}
// TaskSchedulePatch changes list metadata without resetting execution windows.
type TaskSchedulePatch struct {
Name *string
Remark *string
}
// UpdateTaskScheduleRemark leaves calendar state and task bindings untouched.
func (d *DB) UpdateTaskScheduleRemark(id int64, remark string) (*TaskSchedule, error) {
return d.UpdateTaskScheduleMetadata(id, TaskSchedulePatch{Remark: &remark})
}
func (d *DB) UpdateTaskScheduleMetadata(id int64, patch TaskSchedulePatch) (*TaskSchedule, error) {
item, err := scanSchedule(d.QueryRow(`UPDATE task_schedules SET
name=CASE WHEN $2::boolean THEN $3 ELSE name END,
remark=CASE WHEN $4::boolean THEN $5 ELSE remark END
WHERE id=$1 RETURNING `+scheduleCols, id, patch.Name != nil, patch.Name, patch.Remark != nil, patch.Remark))
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
item.TaskIDs, err = d.scheduleTaskIDs(id)
return item, err
}
+2
View File
@@ -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();
+2 -2
View File
@@ -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,
+5 -1
View File
@@ -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 {
+7 -4
View File
@@ -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
}
+2
View File
@@ -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,
+6
View File
@@ -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
+73
View File
@@ -0,0 +1,73 @@
package server
import (
"errors"
"fmt"
"net/http"
"strings"
"unicode/utf8"
"github.com/Autumn-27/artex/db"
)
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) updateScheduleMetadata(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 {
Name *string `json:"name"`
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.Name == nil && request.Remark == nil {
writeErr(w, 400, "需要提供 name 或 remark")
return
}
if request.Name != nil {
name := strings.TrimSpace(*request.Name)
if name == "" || utf8.RuneCountInString(name) > maxTaskNameRunes {
writeErr(w, 400, fmt.Sprintf("计划名称须为 1 至 %d 个字符", maxTaskNameRunes))
return
}
request.Name = &name
}
if request.Remark != nil {
remark, err := normalizeRemark(*request.Remark)
if err != nil {
writeErr(w, 400, err.Error())
return
}
request.Remark = &remark
}
item, err := s.m.pg.UpdateTaskScheduleMetadata(id, db.TaskSchedulePatch{Name: request.Name, Remark: request.Remark})
if err != nil {
writeErr(w, 500, err.Error())
return
}
if item == nil {
writeErr(w, 404, "计划不存在")
return
}
writeJSON(w, 200, item)
}
+194
View File
@@ -0,0 +1,194 @@
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第二行 <script>alert(1)</script> ", 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: &note}); 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)
}
metadataPath := fmt.Sprintf("/api/schedules/%d/metadata", schedule.ID)
rec = request(metadataPath, `{"name":" 行内改名 "}`, true)
if rec.Code != 200 {
t.Fatalf("schedule rename: %d %s", rec.Code, rec.Body.String())
}
renamed, err := m.pg.GetTaskSchedule(schedule.ID)
if err != nil {
t.Fatal(err)
}
if renamed.Name != "行内改名" || renamed.Remark != note || renamed.Status != original.Status || renamed.Enabled != original.Enabled || renamed.LastWindowKey != original.LastWindowKey || renamed.LastError != original.LastError || !renamed.LastTransitionAt.Equal(*original.LastTransitionAt) || len(renamed.TaskIDs) != 1 || renamed.TaskIDs[0] != taskID {
t.Fatalf("rename changed calendar state or metadata: %+v", renamed)
}
for _, body := range []string{`{}`, `{"name":null}`, `{"name":" "}`, `{"name":123}`, `{"name":"` + strings.Repeat("文", maxTaskNameRunes+1) + `"}`, `{"name":"must not apply","remark":"` + strings.Repeat("文", maxRemarkRunes+1) + `"}`} {
if rec := request(metadataPath, body, true); rec.Code != 400 {
t.Fatalf("invalid rename: %d %s", rec.Code, rec.Body.String())
}
}
unchanged, err := m.pg.GetTaskSchedule(schedule.ID)
if err != nil || unchanged.Name != "行内改名" || unchanged.Remark != note {
t.Fatalf("invalid patch partially changed metadata: %+v %v", unchanged, err)
}
if rec := request(metadataPath, `{"name":"denied"}`, false); rec.Code != 401 {
t.Fatalf("rename auth: %d", rec.Code)
}
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())
}
}
}
+2
View File
@@ -717,6 +717,8 @@ 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.updateScheduleMetadata)
mux.HandleFunc("PATCH /api/schedules/{id}/metadata", s.updateScheduleMetadata)
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)
+13 -3
View File
@@ -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
+29 -5
View File
@@ -17,6 +17,7 @@ import {
import { toast } from "sonner";
import { DateSelect } from "@/components/date-select";
import { RemarkEditor, ScheduleNameEditor } from "@/components/remark-editor";
import { TimeSelect } from "@/components/time-select";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -702,7 +703,7 @@ export default function SchedulesPage() {
</Card>
) : (
<div className="overflow-x-auto rounded-lg border bg-card">
<Table>
<Table className="**:data-[slot='table-cell']:px-4 **:data-[slot='table-head']:px-4">
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead className="w-12">
@@ -712,7 +713,9 @@ export default function SchedulesPage() {
onCheckedChange={(checked) => toggleAllVisible(checked === true)}
/>
</TableHead>
<TableHead className="font-mono">ID</TableHead>
<TableHead className="min-w-44">计划名称</TableHead>
<TableHead>备注</TableHead>
<TableHead className="w-28">时间线</TableHead>
<TableHead className="min-w-48">运行周期</TableHead>
<TableHead className="min-w-40">开始时间</TableHead>
@@ -726,7 +729,7 @@ export default function SchedulesPage() {
<TableBody>
{visibleItems.map((item) => (
<React.Fragment key={item.id}>
<TableRow>
<TableRow className="group">
<TableCell className="w-12">
<Checkbox
aria-label={`选择计划 ${item.name}`}
@@ -735,8 +738,29 @@ export default function SchedulesPage() {
/>
</TableCell>
<TableCell>
<div className="font-medium">{item.name}</div>
<div className="text-muted-foreground text-xs">计划 #{item.id}</div>
<code className="bg-muted rounded px-1.5 py-0.5 font-mono text-xs">{item.id}</code>
</TableCell>
<TableCell className="font-medium">
<ScheduleNameEditor
value={item.name}
label={`计划 #${item.id}`}
onSave={async (name) => {
const updated = await api.renameSchedule(item.id, name);
setItems((current) => current.map((entry) => entry.id === item.id ? { ...entry, name: updated.name } : entry));
}}
/>
</TableCell>
<TableCell>
<RemarkEditor
value={item.remark}
label={`计划 #${item.id} ${item.name}`}
onSave={async (remark) => {
const updated = await api.updateScheduleRemark(item.id, remark);
setItems((current) =>
current.map((entry) => entry.id === item.id ? { ...entry, remark: updated.remark } : entry),
);
}}
/>
</TableCell>
<TableCell>
<Button size="sm" variant="outline" onClick={() => void toggleTimeline(item.id)}>
@@ -844,7 +868,7 @@ export default function SchedulesPage() {
</TableRow>
{expandedId === item.id ? (
<TableRow>
<TableCell colSpan={10} className="bg-muted/10 p-3">
<TableCell colSpan={12} className="bg-muted/10 p-3">
<ScheduleTimeline schedule={timelineById[item.id]} loading={timelineLoadingId === item.id} />
</TableCell>
</TableRow>
@@ -46,6 +46,7 @@ import { toast } from "sonner";
import { AssetInterceptRulesEditor } from "@/components/asset-intercept-rules-editor";
import { DateSelect } from "@/components/date-select";
import { RemarkEditor } from "@/components/remark-editor";
import { StatusBadge } from "@/components/status-badge";
import { TablePagination } from "@/components/table-pagination";
import { TaskLLMProfileChain } from "@/components/task-llm-profile-chain";
@@ -566,6 +567,18 @@ export default function TasksPage() {
[load],
);
const updateTaskRemark = React.useCallback(
async (task: Task, remark: string) => {
const updated = await api.updateTask(task.id, { remark });
setTasks((current) =>
current.map((entry) => entry.id === task.id ? { ...entry, remark: updated.remark } : entry),
);
lastRef.current = "";
load();
},
[load],
);
const renameTask = React.useCallback(
async (task: Task, name: string) => {
try {
@@ -942,6 +955,7 @@ export default function TasksPage() {
onSort={sortTasksBy}
/>
<TableHead>名称</TableHead>
<TableHead>备注</TableHead>
<TableHead>描述</TableHead>
<TableHead>目标</TableHead>
<SortableTaskHead
@@ -989,6 +1003,7 @@ export default function TasksPage() {
onDelete={deleteTask}
onControl={controlTask}
onRename={renameTask}
onRemark={updateTaskRemark}
onTogglePinned={toggleTaskPinned}
onArchive={queueTaskArchive}
selected={selectedIds.has(task.id)}
@@ -1164,6 +1179,7 @@ const TaskRow = React.memo(function TaskRow({
onDelete,
onControl,
onRename,
onRemark,
onTogglePinned,
onArchive,
selected,
@@ -1174,6 +1190,7 @@ const TaskRow = React.memo(function TaskRow({
onDelete: (id: string, options: DeleteTaskOptions) => Promise<void>;
onControl: (id: string, action: "pause" | "resume") => Promise<void>;
onRename: (task: Task, name: string) => Promise<void>;
onRemark: (task: Task, remark: string) => Promise<void>;
onTogglePinned: (task: Task) => Promise<void>;
onArchive: (task: Task) => Promise<void>;
selected: boolean;
@@ -1198,6 +1215,9 @@ const TaskRow = React.memo(function TaskRow({
{taskIsPinned(task) && <PinIcon className="text-primary size-4 shrink-0" aria-label="已置顶" />}
</div>
</TableCell>
<TableCell>
<RemarkEditor value={task.remark} label={`任务 #${task.id}`} onSave={(remark) => onRemark(task, remark)} />
</TableCell>
<TableCell className="text-muted-foreground max-w-40">
<Link
href={`/function/tasks/detail?id=${encodeURIComponent(task.id)}`}
+180
View File
@@ -0,0 +1,180 @@
"use client";
import * as React from "react";
import { PencilIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
type EditorProps = {
value?: string;
label: string;
onSave: (value: string) => Promise<void>;
};
export function RemarkEditor(props: EditorProps) {
return <InlineTextEditor {...props} multiline fieldName="备注" maxCharacters={2000} emptyLabel="添加备注" />;
}
export function ScheduleNameEditor(props: EditorProps) {
return <InlineTextEditor {...props} multiline={false} fieldName="名称" maxCharacters={200} emptyLabel="未命名" />;
}
function InlineTextEditor({
value = "",
label,
onSave,
multiline,
fieldName,
maxCharacters,
emptyLabel,
}: EditorProps & { multiline: boolean; fieldName: string; maxCharacters: number; emptyLabel: string }) {
const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState(value);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState("");
const inputRef = React.useRef<HTMLInputElement>(null);
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const cancelOnBlurRef = React.useRef(false);
const savingRef = React.useRef(false);
const errorID = React.useId();
React.useEffect(() => {
if (!editing) setDraft(value);
}, [editing, value]);
function refocus() {
requestAnimationFrame(() => (multiline ? textareaRef.current : inputRef.current)?.focus());
}
async function finishEditing() {
if (savingRef.current) return;
if (cancelOnBlurRef.current) {
cancelOnBlurRef.current = false;
setDraft(value);
setError("");
setEditing(false);
return;
}
const next = draft.trim();
if (next === value.trim()) {
setEditing(false);
return;
}
if (!multiline && next.length === 0) {
setError("名称不能为空");
refocus();
return;
}
if (Array.from(next).length > maxCharacters) {
setError(`${fieldName}最多 ${maxCharacters} 个字符`);
refocus();
return;
}
savingRef.current = true;
setSaving(true);
try {
await onSave(next);
toast.success(`${fieldName}已保存`);
setEditing(false);
} catch (cause) {
setError(`${fieldName}保存失败:${(cause as Error).message}`);
refocus();
} finally {
savingRef.current = false;
setSaving(false);
}
}
if (editing) {
const inputProps = {
value: draft,
autoFocus: true,
disabled: saving,
"aria-busy": saving,
"aria-label": `${label}${fieldName}`,
"aria-invalid": error.length > 0,
"aria-describedby": error ? errorID : undefined,
title: multiline ? "Enter 保存,Shift+Enter 换行,Esc 取消;留空可清除备注" : "Enter 或失焦保存,Esc 取消",
onFocus: (event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => event.currentTarget.select(),
onChange: (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setDraft(event.target.value);
setError("");
},
onBlur: () => void finishEditing(),
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (event.nativeEvent.isComposing) return;
if (event.key === "Enter" && (!multiline || !event.shiftKey)) {
event.preventDefault();
event.currentTarget.blur();
} else if (event.key === "Escape") {
event.preventDefault();
cancelOnBlurRef.current = true;
event.currentTarget.blur();
}
},
};
return (
<div className={cn("flex min-w-0 flex-col gap-1 font-medium", multiline && "w-32 min-w-28 sm:w-48")}>
{multiline ? (
<Textarea
{...inputProps}
ref={textareaRef}
rows={1}
className="max-h-40 min-h-7 resize-none px-2 py-1 [overflow-wrap:anywhere]"
/>
) : (
<Input {...inputProps} ref={inputRef} className="h-7 min-w-28 max-w-48 px-2 font-medium" />
)}
{error ? (
<p id={errorID} role="alert" className="whitespace-normal text-destructive text-xs">
{error}
</p>
) : null}
</div>
);
}
const startEditing = () => {
setDraft(value);
setError("");
setEditing(true);
};
return (
<div
className={cn(
"group/inline-editor flex min-w-0 items-center gap-1 font-medium",
multiline ? "w-32 sm:w-48" : "max-w-xs",
)}
>
<button
type="button"
className="min-w-0 truncate rounded-sm text-left hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`${label}${fieldName},点击编辑`}
title={value.length > 0 ? value : `点击编辑${fieldName}`}
onClick={startEditing}
>
{value.length > 0 ? (
<span className="block truncate">{value}</span>
) : (
<span className="text-muted-foreground">{emptyLabel}</span>
)}
</button>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="shrink-0 opacity-100 transition-opacity [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within:opacity-100 [@media(hover:hover)]:group-hover:opacity-100 [@media(hover:hover)]:group-focus-within/inline-editor:opacity-100 [@media(hover:hover)]:group-hover/inline-editor:opacity-100"
aria-label={`编辑${label}的${fieldName}`}
title={`编辑${fieldName}`}
onClick={startEditing}
>
<PencilIcon />
</Button>
</div>
);
}
+3 -1
View File
@@ -326,6 +326,8 @@ export const api = {
schedule: input.schedule ? schedulePayload(input.schedule) : undefined,
}),
schedules: () => get<{ schedules: TaskSchedule[] }>("/schedules").then((r) => arr(r.schedules)),
renameSchedule: (id: number, name: string) => patch<TaskSchedule>(`/schedules/${id}/metadata`, { name }),
updateScheduleRemark: (id: number, remark: string) => patch<TaskSchedule>(`/schedules/${id}/remark`, { remark }),
schedule: (id: number) => get<TaskSchedule>(`/schedules/${id}`),
createSchedule: (input: ScheduleInput) => post<TaskSchedule>("/schedules", schedulePayload(input)),
updateSchedule: (id: number, input: ScheduleInput) => patch<TaskSchedule>(`/schedules/${id}`, schedulePayload(input)),
@@ -335,7 +337,7 @@ export const api = {
runScheduleNow: (id: number) =>
post<{ ok: boolean; started: number; errors?: string[] }>(`/schedules/${id}/run-now`, {}),
taskCategories: () => get<{ categories: TaskCategory[] }>("/task-categories").then((r) => arr(r.categories)),
updateTask: (id: string, input: { name?: string; pinned?: boolean }) => patch<Task>(`/tasks/${id}`, input),
updateTask: (id: string, input: { name?: string; pinned?: boolean; remark?: string }) => patch<Task>(`/tasks/${id}`, input),
renameTask: (id: string, name: string) => patch<Task>(`/tasks/${id}`, { name }),
pinTask: (id: string, pinned: boolean) => patch<Task>(`/tasks/${id}`, { pinned }),
createTaskCategory: (name: string) => post<TaskCategory>("/task-categories", { name }),
+1
View File
@@ -1357,6 +1357,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
const task = mockTasks.find((item) => item.id === seg[1]);
if (!task) throw new Error("任务不存在");
if (typeof b.name === "string") task.name = b.name.trim();
if (typeof b.remark === "string") task.remark = b.remark.trim();
if (typeof b.pinned === "boolean") {
task.pinned = b.pinned;
task.pinned_at = b.pinned ? (task.pinned_at ?? new Date().toISOString()) : null;
+2
View File
@@ -7,6 +7,7 @@ export type EngineMode = "exploring" | "paused" | "stalled" | "idle";
export interface Task {
distinct_findings?: FindingDistinctStats;
id: string;
remark?: string;
name?: string; // 可选任务名称;空/缺省=未命名,展示时回退到描述
category_id?: number;
category_name?: string;
@@ -718,6 +719,7 @@ export interface AgentTrigger {
export interface TaskSchedule {
id: number;
name: string;
remark?: string;
enabled: boolean;
timezone_mode: "beijing" | "system";
timezone?: string;