feat: align list editors with name hover actions

This commit is contained in:
ji ruo committed 2026-10-19 09:58:32 +08:00
1 parent bdf0e68644
commit de7a668b32
7 files changed
+188 -77

No files matched your search

+14 -1
View File
@@ -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
}
+22 -8
View File
@@ -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
+24
View File
@@ -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 {
+2 -1
View File
@@ -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)
+13 -4
View File
@@ -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() {
</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">
@@ -729,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}`}
@@ -740,7 +740,16 @@ export default function SchedulesPage() {
<TableCell>
<code className="bg-muted rounded px-1.5 py-0.5 font-mono text-xs">{item.id}</code>
</TableCell>
<TableCell className="font-medium">{item.name}</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}
+112 -63
View File
@@ -2,27 +2,43 @@
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";
const MAX_REMARK_CHARS = 2000;
type EditorProps = {
value?: string;
label: string;
onSave: (value: string) => Promise<void>;
};
export function RemarkEditor({
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,
}: {
value?: string;
label: string;
onSave: (remark: string) => Promise<void>;
}) {
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<HTMLTextAreaElement>(null);
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();
@@ -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<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="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();
}
}}
/>
<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}
@@ -107,25 +139,42 @@ export function RemarkEditor({
);
}
const startEditing = () => {
setDraft(value);
setError("");
setEditing(true);
};
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>
<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>
>
<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>
);
}
+1
View File
@@ -326,6 +326,7 @@ 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)),