From de7a668b32b0635a97b63191b5728a3450e4463a Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:58:32 +0800 Subject: [PATCH] feat: align list editors with name hover actions --- db/schedules.go | 15 +- server/remarks.go | 30 ++- server/remarks_test.go | 24 +++ server/server.go | 3 +- .../app/(main)/function/schedules/page.tsx | 17 +- web/src/components/remark-editor.tsx | 175 +++++++++++------- web/src/lib/api.ts | 1 + 7 files changed, 188 insertions(+), 77 deletions(-) diff --git a/db/schedules.go b/db/schedules.go index 43e01cf..be0871e 100644 --- a/db/schedules.go +++ b/db/schedules.go @@ -405,9 +405,22 @@ 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) { - item, err := scanSchedule(d.QueryRow(`UPDATE task_schedules SET remark=$2 WHERE id=$1 RETURNING `+scheduleCols, id, remark)) + 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 } diff --git a/server/remarks.go b/server/remarks.go index fe4619a..c33ecc6 100644 --- a/server/remarks.go +++ b/server/remarks.go @@ -6,6 +6,8 @@ import ( "net/http" "strings" "unicode/utf8" + + "github.com/Autumn-27/artex/db" ) const maxRemarkRunes = 2000 @@ -18,7 +20,7 @@ func normalizeRemark(value string) (string, error) { return value, nil } -func (s *Server) updateScheduleRemark(w http.ResponseWriter, r *http.Request) { +func (s *Server) updateScheduleMetadata(w http.ResponseWriter, r *http.Request) { id, err := parseScheduleID(r) if err != nil || id <= 0 { writeErr(w, 400, "计划编号无效") @@ -26,6 +28,7 @@ func (s *Server) updateScheduleRemark(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"` } if err := decode(r, &request); err != nil { @@ -37,16 +40,27 @@ func (s *Server) updateScheduleRemark(w http.ResponseWriter, r *http.Request) { } return } - if request.Remark == nil { - writeErr(w, 400, "需要提供 remark") + if request.Name == nil && request.Remark == nil { + writeErr(w, 400, "需要提供 name 或 remark") return } - remark, err := normalizeRemark(*request.Remark) - if err != nil { - writeErr(w, 400, err.Error()) - 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 } - item, err := s.m.pg.UpdateTaskScheduleRemark(id, remark) + 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 diff --git a/server/remarks_test.go b/server/remarks_test.go index ea8b82f..8d6362e 100644 --- a/server/remarks_test.go +++ b/server/remarks_test.go @@ -157,6 +157,30 @@ func TestRemarksRoundTripPreservesExecutionState(t *testing.T) { 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 { diff --git a/server/server.go b/server/server.go index b6aef93..01a7d12 100644 --- a/server/server.go +++ b/server/server.go @@ -717,7 +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.updateScheduleRemark) + 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) diff --git a/web/src/app/(main)/function/schedules/page.tsx b/web/src/app/(main)/function/schedules/page.tsx index d450c61..1365695 100644 --- a/web/src/app/(main)/function/schedules/page.tsx +++ b/web/src/app/(main)/function/schedules/page.tsx @@ -17,7 +17,7 @@ import { import { toast } from "sonner"; import { DateSelect } from "@/components/date-select"; -import { RemarkEditor } from "@/components/remark-editor"; +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"; @@ -703,7 +703,7 @@ export default function SchedulesPage() { ) : (
- +
@@ -729,7 +729,7 @@ export default function SchedulesPage() { {visibleItems.map((item) => ( - + {item.id} - {item.name} + + { + const updated = await api.renameSchedule(item.id, name); + setItems((current) => current.map((entry) => entry.id === item.id ? { ...entry, name: updated.name } : entry)); + }} + /> + Promise; +}; -export function RemarkEditor({ +export function RemarkEditor(props: EditorProps) { + return ; +} + +export function ScheduleNameEditor(props: EditorProps) { + return ; +} + +function InlineTextEditor({ value = "", label, onSave, -}: { - value?: string; - label: string; - onSave: (remark: string) => Promise; -}) { + 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(null); + const inputRef = React.useRef(null); + const textareaRef = React.useRef(null); const cancelOnBlurRef = React.useRef(false); const savingRef = React.useRef(false); const errorID = React.useId(); @@ -31,6 +47,10 @@ export function RemarkEditor({ 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) { @@ -45,20 +65,25 @@ export function RemarkEditor({ setEditing(false); return; } - if (Array.from(next).length > MAX_REMARK_CHARS) { - setError(`备注最多 ${MAX_REMARK_CHARS} 个字符`); - requestAnimationFrame(() => inputRef.current?.focus()); + 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("备注已保存"); + toast.success(`${fieldName}已保存`); setEditing(false); } catch (cause) { - setError(`备注保存失败:${(cause as Error).message}`); - requestAnimationFrame(() => inputRef.current?.focus()); + setError(`${fieldName}保存失败:${(cause as Error).message}`); + refocus(); } finally { savingRef.current = false; setSaving(false); @@ -66,38 +91,45 @@ export function RemarkEditor({ } 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) => event.currentTarget.select(), + onChange: (event: React.ChangeEvent) => { + setDraft(event.target.value); + setError(""); + }, + onBlur: () => void finishEditing(), + onKeyDown: (event: React.KeyboardEvent) => { + 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 ( -
-