fix: improve workflow settings typing performance
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-21 18:19:57 +08:00
parent 8a722ed849
commit 7a2169576d

View File

@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type ReactNode } from "react";
import { useDeferredValue, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type ReactNode } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import { motion, AnimatePresence } from "framer-motion";
@ -148,6 +148,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 WORKFLOW_BLOCKED_CANDIDATE_RENDER_LIMIT = 60;
type WorkflowStepDropPosition = "before" | "after" | "inside";
@ -324,15 +325,33 @@ function updateWorkflowStepTree(
stepKey: string,
updater: (step: AgentWorkflowStepUiConfig) => AgentWorkflowStepUiConfig
): AgentWorkflowStepUiConfig[] {
return steps.map((step) => {
let changed = false;
const nextSteps = steps.map((step) => {
if (step.key === stepKey) {
return updater(step);
const updatedStep = updater(step);
if (updatedStep !== step) {
changed = true;
}
return updatedStep;
}
if (!step.children || step.children.length === 0) {
return step;
}
const nextChildren = updateWorkflowStepTree(step.children, stepKey, updater);
if (nextChildren === step.children) {
return step;
}
changed = true;
return {
...step,
children: updateWorkflowStepTree(step.children || [], stepKey, updater),
children: nextChildren,
};
});
return changed ? nextSteps : steps;
}
function collectWorkflowOptionKeys(options: AgentWorkflowOptionUiConfig[]): string[] {
@ -386,15 +405,33 @@ function updateWorkflowOptionTree(
optionKey: string,
updater: (option: AgentWorkflowOptionUiConfig) => AgentWorkflowOptionUiConfig
): AgentWorkflowOptionUiConfig[] {
return options.map((option) => {
let changed = false;
const nextOptions = options.map((option) => {
if (option.key === optionKey) {
return updater(option);
const updatedOption = updater(option);
if (updatedOption !== option) {
changed = true;
}
return updatedOption;
}
if (!option.children || option.children.length === 0) {
return option;
}
const nextChildren = updateWorkflowOptionTree(option.children, optionKey, updater);
if (nextChildren === option.children) {
return option;
}
changed = true;
return {
...option,
children: updateWorkflowOptionTree(option.children || [], optionKey, updater),
children: nextChildren,
};
});
return changed ? nextOptions : options;
}
function removeWorkflowOptionTree(
@ -1362,6 +1399,7 @@ export function AgentWorkflowSettingsClient({
const [fourthCategoryDrafts, setFourthCategoryDrafts] = useState<Record<string, string>>({});
const [fifthCategoryDrafts, setFifthCategoryDrafts] = useState<Record<string, string>>({});
const [blockedOptionSearches, setBlockedOptionSearches] = useState<Record<string, string>>({});
const [expandedBlockedOptionPanels, setExpandedBlockedOptionPanels] = useState<Record<string, boolean>>({});
const [isImportingBrandSheet, setIsImportingBrandSheet] = useState(false);
const [draggedStepKey, setDraggedStepKey] = useState<string | null>(null);
const [stepDropTarget, setStepDropTarget] = useState<WorkflowStepDropTarget | null>(null);
@ -1621,15 +1659,25 @@ export function AgentWorkflowSettingsClient({
() => [...config.steps].sort((left, right) => left.sortOrder - right.sortOrder),
[config.steps]
);
const deferredWorkflowSteps = useDeferredValue(config.steps);
const sortedPresetLibrarySections = useMemo(
() =>
[...presetLibraryConfig.sections].sort((left, right) => left.sortOrder - right.sortOrder),
[presetLibraryConfig.sections]
);
const workflowOptionRelations = useMemo(
() => collectWorkflowOptionRelationsFromSteps(config.steps || []),
[config.steps]
() => collectWorkflowOptionRelationsFromSteps(deferredWorkflowSteps || []),
[deferredWorkflowSteps]
);
const workflowOptionRelationsByDepth = useMemo(() => {
const next = new Map<number, WorkflowOptionRelation[]>();
for (const relation of workflowOptionRelations) {
const current = next.get(relation.depth) || [];
current.push(relation);
next.set(relation.depth, current);
}
return next;
}, [workflowOptionRelations]);
const workflowOptionRelationLookup = useMemo(
() => new Map(workflowOptionRelations.map((item) => [item.key, item] as const)),
[workflowOptionRelations]
@ -2417,12 +2465,10 @@ export function AgentWorkflowSettingsClient({
stepKey: string,
updater: (step: AgentWorkflowUiConfig["steps"][number]) => AgentWorkflowUiConfig["steps"][number]
) => {
setConfig((prev) =>
normalizeConfigOrdering({
...prev,
steps: updateWorkflowStepTree(prev.steps, stepKey, updater),
})
);
setConfig((prev) => ({
...prev,
steps: updateWorkflowStepTree(prev.steps, stepKey, updater),
}));
};
const updateOption = (
@ -3373,26 +3419,36 @@ export function AgentWorkflowSettingsClient({
(left, right) => left.sortOrder - right.sortOrder
);
const canAddChild = depth < AGENT_WORKFLOW_MAX_OPTION_DEPTH;
const blockedCandidates = workflowOptionRelations.filter(
(item) =>
item.key !== option.key &&
(workflowConstraintSettings.crossLevelMutualExclusionEnabled || item.depth === depth)
);
const blockedCandidatePanelExpanded = expandedBlockedOptionPanels[refKey] ?? false;
const blockedCandidatePool = workflowConstraintSettings.crossLevelMutualExclusionEnabled
? workflowOptionRelations
: workflowOptionRelationsByDepth.get(depth) || [];
const blockedCandidates = blockedCandidatePanelExpanded
? blockedCandidatePool.filter((item) => item.key !== option.key)
: [];
const selectedBlockedItems = (option.blockedOptionKeys || [])
.map((blockedKey) => workflowOptionRelationLookup.get(blockedKey))
.filter((item): item is WorkflowOptionRelation => Boolean(item));
const isExpanded = isWorkflowOptionExpanded(option.key, depth);
const blockedSearchValue = blockedOptionSearches[refKey] || "";
const normalizedBlockedSearchValue = blockedSearchValue.trim().toLowerCase();
const filteredBlockedCandidates = blockedCandidates.filter((candidate) => {
if (!normalizedBlockedSearchValue) {
return true;
}
const filteredBlockedCandidates = blockedCandidatePanelExpanded
? blockedCandidates.filter((candidate) => {
if (!normalizedBlockedSearchValue) {
return true;
}
return [candidate.label, candidate.stepTitle, candidate.key].some((value) =>
value.toLowerCase().includes(normalizedBlockedSearchValue)
);
});
return [candidate.label, candidate.stepTitle, candidate.key].some((value) =>
value.toLowerCase().includes(normalizedBlockedSearchValue)
);
})
: [];
const visibleBlockedCandidates = filteredBlockedCandidates.slice(
0,
WORKFLOW_BLOCKED_CANDIDATE_RENDER_LIMIT
);
const hiddenBlockedCandidateCount =
filteredBlockedCandidates.length - visibleBlockedCandidates.length;
const canMoveUp = depth === 1 && indexPath[0] > 1;
const canMoveDown = depth === 1 && indexPath[0] < (step.options || []).length;
@ -3658,77 +3714,111 @@ export function AgentWorkflowSettingsClient({
</div>
)}
<div className="space-y-2 rounded-md border bg-background/70 p-2">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={blockedSearchValue}
onChange={(e) =>
setBlockedOptionSearches((prev) => ({
...prev,
[refKey]: e.target.value,
}))
}
placeholder={`${workflowConstraintSettings.crossLevelMutualExclusionEnabled ? "搜索所有层级卡片" : "搜索同级卡片"}(${blockedCandidates.length} 个候选)`}
className="h-8 pl-8 text-xs"
/>
</div>
<div className="max-h-44 space-y-2 overflow-y-auto">
{filteredBlockedCandidates.length > 0 ? (
filteredBlockedCandidates.map((candidate) => {
const active = (option.blockedOptionKeys || []).includes(candidate.key);
return (
<button
key={candidate.key}
type="button"
className={cn(
"flex w-full items-center justify-between rounded-md border px-3 py-2 text-left text-xs transition-colors",
active
? "border-amber-500/50 bg-amber-500/10 text-foreground"
: "border-border/70 bg-background hover:border-foreground/20 hover:bg-muted/60"
)}
onClick={() =>
updateOption(step.key, option.key, (currentOption) => {
const currentBlocked = new Set(
currentOption.blockedOptionKeys || []
);
if (currentBlocked.has(candidate.key)) {
currentBlocked.delete(candidate.key);
} else {
currentBlocked.add(candidate.key);
}
return {
...currentOption,
blockedOptionKeys: Array.from(currentBlocked),
};
})
}
>
<span className="min-w-0">
<span className="block truncate font-medium">
{candidate.label}
</span>
<span className="block text-[10px] text-muted-foreground">
{getWorkflowLevelLabel(candidate.depth, "卡片")} · {candidate.key}
</span>
</span>
<Badge variant={active ? "default" : "outline"}>
{active ? "已限制" : "可限制"}
</Badge>
</button>
);
})
) : (
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
{blockedCandidates.length > 0
? "没有命中搜索结果,换个关键词试试"
: workflowConstraintSettings.crossLevelMutualExclusionEnabled
? "当前工作流下没有其他可限制的卡片"
: "当前工作流下没有其他可限制的同级卡片"}
</div>
)}
</div>
</div>
<div className="space-y-2 rounded-md border bg-background/70 p-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-muted-foreground">
候选卡片列表
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() =>
setExpandedBlockedOptionPanels((prev) => ({
...prev,
[refKey]: !blockedCandidatePanelExpanded,
}))
}
>
{blockedCandidatePanelExpanded ? "收起候选" : "展开候选"}
</Button>
</div>
{blockedCandidatePanelExpanded ? (
<>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={blockedSearchValue}
onChange={(e) =>
setBlockedOptionSearches((prev) => ({
...prev,
[refKey]: e.target.value,
}))
}
placeholder={`${workflowConstraintSettings.crossLevelMutualExclusionEnabled ? "搜索所有层级卡片" : "搜索同级卡片"}(${blockedCandidates.length} 个候选)`}
className="h-8 pl-8 text-xs"
/>
</div>
<div className="max-h-44 space-y-2 overflow-y-auto">
{filteredBlockedCandidates.length > 0 ? (
<>
{visibleBlockedCandidates.map((candidate) => {
const active = (option.blockedOptionKeys || []).includes(candidate.key);
return (
<button
key={candidate.key}
type="button"
className={cn(
"flex w-full items-center justify-between rounded-md border px-3 py-2 text-left text-xs transition-colors",
active
? "border-amber-500/50 bg-amber-500/10 text-foreground"
: "border-border/70 bg-background hover:border-foreground/20 hover:bg-muted/60"
)}
onClick={() =>
updateOption(step.key, option.key, (currentOption) => {
const currentBlocked = new Set(
currentOption.blockedOptionKeys || []
);
if (currentBlocked.has(candidate.key)) {
currentBlocked.delete(candidate.key);
} else {
currentBlocked.add(candidate.key);
}
return {
...currentOption,
blockedOptionKeys: Array.from(currentBlocked),
};
})
}
>
<span className="min-w-0">
<span className="block truncate font-medium">
{candidate.label}
</span>
<span className="block text-[10px] text-muted-foreground">
{getWorkflowLevelLabel(candidate.depth, "卡片")} · {candidate.key}
</span>
</span>
<Badge variant={active ? "default" : "outline"}>
{active ? "已限制" : "可限制"}
</Badge>
</button>
);
})}
{hiddenBlockedCandidateCount > 0 ? (
<div className="rounded-md border border-dashed bg-muted/40 px-3 py-2 text-center text-[11px] text-muted-foreground">
还有 {hiddenBlockedCandidateCount} 个候选未显示,请输入关键词缩小范围
</div>
) : null}
</>
) : (
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
{blockedCandidates.length > 0
? "没有命中搜索结果,换个关键词试试"
: workflowConstraintSettings.crossLevelMutualExclusionEnabled
? "当前工作流下没有其他可限制的卡片"
: "当前工作流下没有其他可限制的同级卡片"}
</div>
)}
</div>
</>
) : (
<div className="rounded-md border border-dashed bg-muted/30 px-3 py-2 text-[11px] text-muted-foreground">
候选列表已折叠,需要配置互斥时再展开,避免大量卡片同时渲染导致编辑卡顿。
</div>
)}
</div>
</div>
</div>
</div>