fix: stabilize workflow settings editing
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-22 20:16:31 +08:00
parent 28a2531082
commit b438977a68

View File

@ -113,6 +113,14 @@ import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { cn } from "@/lib/utils";
@ -160,6 +168,7 @@ const WORKFLOW_STEP_DRAG_DATA_TYPE = "application/x-agent-workflow-step";
const WORKFLOW_SETTINGS_DRAFT_STORAGE_KEY = "agent-workflow-settings-draft:v1";
const WORKFLOW_SETTINGS_STALE_DRAFT_STORAGE_KEY = "agent-workflow-settings-stale-draft:v1";
const WORKFLOW_SETTINGS_DRAFT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const DELETE_WORKFLOW_STEP_PASSWORD = "123456";
type WorkflowStepDropPosition = "before" | "after" | "inside";
@ -258,15 +267,26 @@ function scheduleWorkflowIdleTask(callback: () => void, timeoutMs = 2000) {
function BufferedInput({
commitDelayMs = 450,
onBlur,
onCompositionEnd,
onCompositionStart,
onFocus,
onValueCommit,
value,
...props
}: BufferedInputProps) {
const [draftValue, setDraftValue] = useState(value);
const committedValueRef = useRef(value);
const draftValueRef = useRef(value);
const isFocusedRef = useRef(false);
const isComposingRef = useRef(false);
useEffect(() => {
if (isFocusedRef.current && draftValueRef.current !== committedValueRef.current) {
return;
}
setDraftValue(value);
draftValueRef.current = value;
committedValueRef.current = value;
}, [value]);
@ -287,7 +307,7 @@ function BufferedInput({
};
useEffect(() => {
if (draftValue === committedValueRef.current) {
if (draftValue === committedValueRef.current || isComposingRef.current) {
return;
}
@ -302,9 +322,30 @@ function BufferedInput({
<Input
{...props}
value={draftValue}
onChange={(event) => setDraftValue(event.target.value)}
onChange={(event) => {
const nextValue = event.target.value;
draftValueRef.current = nextValue;
setDraftValue(nextValue);
}}
onFocus={(event) => {
isFocusedRef.current = true;
onFocus?.(event);
}}
onCompositionStart={(event) => {
isComposingRef.current = true;
onCompositionStart?.(event);
}}
onCompositionEnd={(event) => {
isComposingRef.current = false;
const nextValue = event.currentTarget.value;
draftValueRef.current = nextValue;
setDraftValue(nextValue);
commitValue(nextValue);
onCompositionEnd?.(event);
}}
onBlur={(event) => {
commitValue(draftValue, { immediate: true });
isFocusedRef.current = false;
commitValue(draftValueRef.current, { immediate: true });
onBlur?.(event);
}}
/>
@ -314,15 +355,26 @@ function BufferedInput({
function BufferedTextarea({
commitDelayMs = 450,
onBlur,
onCompositionEnd,
onCompositionStart,
onFocus,
onValueCommit,
value,
...props
}: BufferedTextareaProps) {
const [draftValue, setDraftValue] = useState(value);
const committedValueRef = useRef(value);
const draftValueRef = useRef(value);
const isFocusedRef = useRef(false);
const isComposingRef = useRef(false);
useEffect(() => {
if (isFocusedRef.current && draftValueRef.current !== committedValueRef.current) {
return;
}
setDraftValue(value);
draftValueRef.current = value;
committedValueRef.current = value;
}, [value]);
@ -343,7 +395,7 @@ function BufferedTextarea({
};
useEffect(() => {
if (draftValue === committedValueRef.current) {
if (draftValue === committedValueRef.current || isComposingRef.current) {
return;
}
@ -358,9 +410,30 @@ function BufferedTextarea({
<Textarea
{...props}
value={draftValue}
onChange={(event) => setDraftValue(event.target.value)}
onChange={(event) => {
const nextValue = event.target.value;
draftValueRef.current = nextValue;
setDraftValue(nextValue);
}}
onFocus={(event) => {
isFocusedRef.current = true;
onFocus?.(event);
}}
onCompositionStart={(event) => {
isComposingRef.current = true;
onCompositionStart?.(event);
}}
onCompositionEnd={(event) => {
isComposingRef.current = false;
const nextValue = event.currentTarget.value;
draftValueRef.current = nextValue;
setDraftValue(nextValue);
commitValue(nextValue);
onCompositionEnd?.(event);
}}
onBlur={(event) => {
commitValue(draftValue, { immediate: true });
isFocusedRef.current = false;
commitValue(draftValueRef.current, { immediate: true });
onBlur?.(event);
}}
/>
@ -1820,6 +1893,9 @@ export function AgentWorkflowSettingsClient({
const [copiedWorkflowStep, setCopiedWorkflowStep] = useState<AgentWorkflowStepUiConfig | null>(
null
);
const [pendingDeleteStepKey, setPendingDeleteStepKey] = useState<string | null>(null);
const [deleteStepPassword, setDeleteStepPassword] = useState("");
const [deleteStepDialogOpen, setDeleteStepDialogOpen] = useState(false);
const [autoDraftUpdatedAt, setAutoDraftUpdatedAt] = useState<number | null>(null);
const [autoDraftStatus, setAutoDraftStatus] = useState<"idle" | "restored" | "saved">("idle");
const [recoverableDraftUpdatedAt, setRecoverableDraftUpdatedAt] = useState<number | null>(null);
@ -2115,6 +2191,13 @@ export function AgentWorkflowSettingsClient({
() => new Map(workflowStepAnalyses.map((item) => [item.key, item] as const)),
[workflowStepAnalyses]
);
const pendingDeleteStep = useMemo(
() =>
pendingDeleteStepKey
? findAgentWorkflowStepByKey(config.steps, pendingDeleteStepKey)
: null,
[config.steps, pendingDeleteStepKey]
);
const workflowSpecialModuleOptions = useMemo(
() =>
getAgentWorkflowSpecialModuleRootOptions(deferredWorkflowSteps || []).map((option) => ({
@ -3043,6 +3126,12 @@ export function AgentWorkflowSettingsClient({
}
};
const handleRequestRemoveStep = (stepKey: string) => {
setPendingDeleteStepKey(stepKey);
setDeleteStepPassword("");
setDeleteStepDialogOpen(true);
};
const handleRemoveStep = (stepKey: string) => {
const targetStep = findAgentWorkflowStepByKey(config.steps, stepKey);
setConfig((prev) =>
@ -3060,6 +3149,25 @@ export function AgentWorkflowSettingsClient({
});
};
const handleConfirmRemoveStep = () => {
if (deleteStepPassword.trim() !== DELETE_WORKFLOW_STEP_PASSWORD) {
toast.error("删除密码错误,请输入正确密码");
return;
}
if (!pendingDeleteStepKey) {
toast.error("没有找到要删除的模块");
return;
}
const deleteStepTitle = pendingDeleteStep?.title || pendingDeleteStepKey;
handleRemoveStep(pendingDeleteStepKey);
setDeleteStepDialogOpen(false);
setPendingDeleteStepKey(null);
setDeleteStepPassword("");
toast.success(`已删除模块「${deleteStepTitle}」`);
};
const canMoveWorkflowStep = (
sourceStepKey: string | null,
targetStepKey: string | null,
@ -5070,7 +5178,7 @@ export function AgentWorkflowSettingsClient({
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => handleRemoveStep(step.key)}
onClick={() => handleRequestRemoveStep(step.key)}
>
<Trash2 className="h-4 w-4" />
</Button>
@ -6947,6 +7055,70 @@ export function AgentWorkflowSettingsClient({
</TabsContent>
</Tabs>
</main>
<Dialog
open={deleteStepDialogOpen}
onOpenChange={(open) => {
setDeleteStepDialogOpen(open);
if (!open) {
setPendingDeleteStepKey(null);
setDeleteStepPassword("");
}
}}
>
<DialogContent className="sm:max-w-[460px]">
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
handleConfirmRemoveStep();
}}
>
<DialogHeader>
<DialogTitle>确认删除模块</DialogTitle>
<DialogDescription>
删除模块会同时移除该模块下的卡片和子模块。请输入删除密码后继续。
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="workflow-delete-step-password">删除模块</Label>
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
{pendingDeleteStep?.title || pendingDeleteStepKey || "未选择模块"}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="workflow-delete-step-password">删除密码</Label>
<Input
id="workflow-delete-step-password"
type="password"
value={deleteStepPassword}
onChange={(event) => setDeleteStepPassword(event.target.value)}
placeholder="请输入删除密码"
autoComplete="current-password"
autoFocus
/>
<p className="text-xs text-muted-foreground">
当前固定删除密码为 123456,输错不会删除任何数据。
</p>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setDeleteStepDialogOpen(false)}
>
取消
</Button>
<Button type="submit" variant="destructive">
确认删除
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}