mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
feat: add inline remarks for tasks and schedules
This commit is contained in:
1 parent
6c940f4403
commit
316889f76a
17 files changed
+454
-14
No files matched your search
+16
-2
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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第二行 <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: ¬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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+13
-3
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
</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>
|
||||
@@ -738,6 +740,18 @@ export default function SchedulesPage() {
|
||||
<div className="font-medium">{item.name}</div>
|
||||
<div className="text-muted-foreground text-xs">计划 #{item.id}</div>
|
||||
</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)}>
|
||||
{expandedId === item.id ? (
|
||||
@@ -844,7 +858,7 @@ export default function SchedulesPage() {
|
||||
</TableRow>
|
||||
{expandedId === item.id ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="bg-muted/10 p-3">
|
||||
<TableCell colSpan={11} 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)}`}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const MAX_REMARK_CHARS = 2000;
|
||||
|
||||
export function RemarkEditor({
|
||||
value = "",
|
||||
label,
|
||||
onSave,
|
||||
}: {
|
||||
value?: string;
|
||||
label: string;
|
||||
onSave: (remark: string) => Promise<void>;
|
||||
}) {
|
||||
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<HTMLTextAreaElement>(null);
|
||||
const cancelOnBlurRef = React.useRef(false);
|
||||
const savingRef = React.useRef(false);
|
||||
const errorID = React.useId();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!editing) setDraft(value);
|
||||
}, [editing, value]);
|
||||
|
||||
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 (Array.from(next).length > MAX_REMARK_CHARS) {
|
||||
setError(`备注最多 ${MAX_REMARK_CHARS} 个字符`);
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
return;
|
||||
}
|
||||
savingRef.current = true;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(next);
|
||||
toast.success("备注已保存");
|
||||
setEditing(false);
|
||||
} catch (cause) {
|
||||
setError(`备注保存失败:${(cause as Error).message}`);
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="flex w-32 min-w-28 flex-col gap-1 sm:w-48">
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
rows={1}
|
||||
autoFocus
|
||||
disabled={saving}
|
||||
aria-busy={saving}
|
||||
aria-label={`${label}备注`}
|
||||
aria-invalid={error.length > 0}
|
||||
aria-describedby={error ? errorID : undefined}
|
||||
className="max-h-40 min-h-7 resize-none px-2 py-1 [overflow-wrap:anywhere]"
|
||||
title="Enter 保存,Shift+Enter 换行,Esc 取消;留空可清除备注"
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
setError("");
|
||||
}}
|
||||
onBlur={() => void finishEditing()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.nativeEvent.isComposing) return;
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.blur();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
cancelOnBlurRef.current = true;
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{error ? (
|
||||
<p id={errorID} role="alert" className="whitespace-normal text-destructive text-xs">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-7 w-32 justify-start p-0 sm:w-48"
|
||||
aria-label={`编辑${label}的备注`}
|
||||
title={value.length > 0 ? value : "点击编辑备注"}
|
||||
onClick={() => {
|
||||
setDraft(value);
|
||||
setError("");
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
{value.length > 0 ? (
|
||||
<span className="truncate">{value}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">添加备注</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
+2
-1
@@ -326,6 +326,7 @@ export const api = {
|
||||
schedule: input.schedule ? schedulePayload(input.schedule) : undefined,
|
||||
}),
|
||||
schedules: () => get<{ schedules: TaskSchedule[] }>("/schedules").then((r) => arr(r.schedules)),
|
||||
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 +336,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 }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user