ai-chatbot/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx

8312 lines
338 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import Link from "next/link";
import {
startTransition,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
type ComponentProps,
type DragEvent,
type ReactNode,
} from "react";
import useSWR from "swr";
import { toast } from "sonner";
import { motion, AnimatePresence } from "framer-motion";
import {
ArrowDown,
ArrowLeft,
ArrowUp,
ChevronDown,
ChevronRight,
ClipboardPaste,
Copy,
Download,
GripVertical,
Image as ImageIcon,
Layers,
Loader2,
MonitorPlay,
Package,
Plus,
RefreshCw,
RotateCcw,
Save,
Search,
Settings2,
Sparkles,
Trash2,
Upload,
UserCircle,
Video,
} from "lucide-react";
import {
AGENT_WORKFLOW_PROMPT_FRAGMENT_MAX_LENGTH,
defaultAgentWorkflowConstraintSettings,
defaultAgentWorkflowPageFeatureSettings,
AGENT_WORKFLOW_MAX_OPTION_DEPTH,
AGENT_WORKFLOW_MAX_TREE_DEPTH,
countAgentWorkflowOptions,
countAgentWorkflowOptionTree,
countAgentWorkflowSteps,
defaultAgentWorkflowCategoryLabels,
defaultAgentWorkflowUiConfig,
findAgentWorkflowOptionByKey,
findAgentWorkflowStepByKey,
flattenAgentWorkflowOptionTree,
getAgentWorkflowSpecialModuleRootOptions,
getEnabledAgentWorkflowOptionTree,
getAgentWorkflowCategoryLabels,
getAgentWorkflowBusinessModeSettings,
getAgentWorkflowConstraintSettings,
getAgentWorkflowOfficialCategoryEntries,
getAgentWorkflowOfficialCategoryKeyLabels,
getAgentWorkflowOfficialSecondCategoryKey,
getAgentWorkflowOfficialThirdCategoryKey,
getAgentWorkflowMissingPreviewOptions,
getAgentWorkflowPageFeatureSettings,
normalizeAgentWorkflowBusinessModeSettings,
normalizeAgentWorkflowOptionOrdering,
flattenAgentWorkflowSteps,
normalizeAgentWorkflowStepOrdering,
normalizeAgentWorkflowUiConfig,
} from "@/lib/agents/workflow-ui-config";
import {
defaultAgentWorkflowBrandModelConfig,
normalizeAgentWorkflowBrandModelConfig,
} from "@/lib/agents/workflow-brand-model-config";
import {
AGENT_PRESET_LIBRARY_DEFAULT_SECTION_ID,
AGENT_PRESET_LIBRARY_MAX_DEPTH,
defaultAgentPresetLibraryConfig,
normalizeAgentPresetLibraryConfig,
} from "@/lib/agents/preset-library-config";
import {
defaultAgentWorkflowShowcaseConfig,
normalizeAgentWorkflowShowcaseConfig,
} from "@/lib/agents/workflow-showcase-config";
import {
defaultAgentWorkflowMaterialApiConfig,
normalizeAgentWorkflowMaterialApiConfig,
} from "@/lib/agents/workflow-material-config";
import {
defaultAgentWorkflowGoodsInfoApiConfig,
normalizeAgentWorkflowGoodsInfoApiConfig,
} from "@/lib/agents/workflow-goods-info-config";
import {
defaultAgentPsdTextColorConfig,
normalizeAgentPsdTextColorConfig,
resolveAgentPsdTextColorConfigForBrand,
} from "@/lib/agents/psd-text-color-config";
import {
defaultAgentPsdTemplateSecondaryCategoryConfig,
type AgentPsdTemplateSecondaryCategoryConfig,
normalizeAgentPsdTemplateSecondaryCategoryConfig,
} from "@/lib/agents/psd-template-secondary-category-config";
import {
AGENT_WORKFLOW_BRAND_IMPORT_ACCEPT,
downloadAgentWorkflowBrandImportTemplate,
parseAgentWorkflowBrandImportFile,
} from "@/lib/agents/workflow-brand-import";
import { normalizeHexColor } from "@/lib/agents/psd-text-layout";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
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";
import type {
AgentPresetLibraryCategoryNode,
AgentPresetLibraryConfig,
AgentPsdTextColorBrandConfig,
AgentPresetLibrarySection,
AgentWorkflowBusinessModeConfig,
AgentWorkflowBusinessModeKey,
AgentWorkflowBrandModelConfig,
AgentWorkflowBrandStoreEntry,
AgentWorkflowCategoryLabels,
AgentWorkflowFourthCategoryEntry,
AgentWorkflowGoodsInfoApiConfig,
AgentWorkflowMaterialApiConfig,
AgentWorkflowProductDetailChannel,
AgentWorkflowOptionUiConfig,
AgentPsdTextColorConfig,
AgentWorkflowStoreEntry,
AgentWorkflowThirdCategoryEntry,
AgentWorkflowShowcaseConfig,
AgentWorkflowStepUiConfig,
AgentWorkflowUiConfig,
} from "@/types/agent";
const fetcher = (url: string) => fetch(url, { cache: "no-store" }).then((res) => res.json());
// Animation variants
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.05 }
}
};
const itemVariants = {
hidden: { opacity: 0, y: 10 },
visible: { opacity: 1, y: 0 }
};
const CHINESE_LEVEL_NUMERALS = ["一", "二", "三", "四", "五", "六", "七", "八"];
const WORKFLOW_LEVEL_LABELS = CHINESE_LEVEL_NUMERALS.map((item) => `${item}级模块`);
const WORKFLOW_OPTION_LEVEL_LABELS = CHINESE_LEVEL_NUMERALS.map((item) => `${item}级卡片`);
const PRESET_LIBRARY_LEVEL_LABELS = ["一级分类", "二级分类", "三级分类"];
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 AGENT_WORKFLOW_SETTINGS_ENDPOINT =
"/api/site-settings?scope=agent-workflow-admin";
const AGENT_WORKFLOW_SNAPSHOTS_ENDPOINT =
"/api/admin/system-settings/snapshots?scope=agent-workflow&limit=12";
const AGENT_WORKFLOW_SAVE_TIMEOUT_MS = 30_000;
const DELETE_WORKFLOW_STEP_PASSWORD = "123456";
const BUSINESS_MODE_DEFAULT_MODEL_EMPTY_VALUE = "__agent_workflow_mode_default_model_empty__";
type WorkflowStepDropPosition = "before" | "after" | "inside";
interface WorkflowStepDropTarget {
stepKey: string | null;
position: WorkflowStepDropPosition;
}
interface AgentWorkflowSettingsDraftState {
agentWorkflowDefaultModelId: string | null;
presetLibraryConfig: AgentPresetLibraryConfig;
config: AgentWorkflowUiConfig;
materialApiConfig: AgentWorkflowMaterialApiConfig;
goodsInfoApiConfig: AgentWorkflowGoodsInfoApiConfig;
brandModelConfig: AgentWorkflowBrandModelConfig;
showcaseConfig: AgentWorkflowShowcaseConfig;
psdTextColorConfig: AgentPsdTextColorConfig;
psdTemplateSecondaryCategoryConfig: AgentPsdTemplateSecondaryCategoryConfig;
}
interface AgentWorkflowSettingsDraft {
version: 1 | 2;
updatedAt: number;
baseSignature?: string;
baseSignatures?: Partial<Record<keyof AgentWorkflowSettingsPayload, string>>;
state: AgentWorkflowSettingsDraftState;
}
interface WorkflowOptionContentSummary {
total: number;
withPrompt: number;
withDescription: number;
withPreview: number;
disabled: number;
withMutualExclusion: number;
}
interface WorkflowStepAnalysis {
key: string;
title: string;
path: string;
depth: number;
childStepCount: number;
directRootOptionCount: number;
optionStats: WorkflowOptionContentSummary;
subtreeStepCount: number;
subtreeOptionCount: number;
hasOwnContent: boolean;
hasSubtreeContent: boolean;
enabled: boolean;
}
interface WorkflowOptionGapAnalysis {
key: string;
label: string;
path: string;
stepKey: string;
stepPath: string;
depth: number;
missingFields: string[];
disabled: boolean;
childCount: number;
}
type BufferedInputProps = Omit<
ComponentProps<typeof Input>,
"defaultValue" | "onChange" | "value"
> & {
commitDelayMs?: number;
onValueCommit: (value: string) => void;
value: string;
};
type BufferedTextareaProps = Omit<
ComponentProps<typeof Textarea>,
"defaultValue" | "onChange" | "value"
> & {
commitDelayMs?: number;
onValueCommit: (value: string) => void;
value: string;
};
function scheduleWorkflowIdleTask(callback: () => void, timeoutMs = 2000) {
if (typeof window === "undefined") {
return () => {};
}
if ("requestIdleCallback" in window && "cancelIdleCallback" in window) {
const idleId = window.requestIdleCallback(callback, { timeout: timeoutMs });
return () => window.cancelIdleCallback(idleId);
}
const timer = globalThis.setTimeout(callback, timeoutMs);
return () => globalThis.clearTimeout(timer);
}
async function fetchAgentWorkflowSettingsWithTimeout(
input: RequestInfo | URL,
init: RequestInit = {},
timeoutMs = AGENT_WORKFLOW_SAVE_TIMEOUT_MS
) {
const controller = new AbortController();
const timeoutId = globalThis.setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(input, {
...init,
signal: controller.signal,
});
} finally {
globalThis.clearTimeout(timeoutId);
}
}
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]);
const commitValue = (nextValue: string, options?: { immediate?: boolean }) => {
if (nextValue === committedValueRef.current) {
return;
}
committedValueRef.current = nextValue;
if (options?.immediate) {
onValueCommit(nextValue);
return;
}
startTransition(() => {
onValueCommit(nextValue);
});
};
useEffect(() => {
if (draftValue === committedValueRef.current || isComposingRef.current) {
return;
}
const timer = window.setTimeout(() => {
commitValue(draftValue);
}, commitDelayMs);
return () => window.clearTimeout(timer);
}, [commitDelayMs, draftValue]);
return (
<Input
{...props}
value={draftValue}
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) => {
isFocusedRef.current = false;
commitValue(draftValueRef.current, { immediate: true });
onBlur?.(event);
}}
/>
);
}
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]);
const commitValue = (nextValue: string, options?: { immediate?: boolean }) => {
if (nextValue === committedValueRef.current) {
return;
}
committedValueRef.current = nextValue;
if (options?.immediate) {
onValueCommit(nextValue);
return;
}
startTransition(() => {
onValueCommit(nextValue);
});
};
useEffect(() => {
if (draftValue === committedValueRef.current || isComposingRef.current) {
return;
}
const timer = window.setTimeout(() => {
commitValue(draftValue);
}, commitDelayMs);
return () => window.clearTimeout(timer);
}, [commitDelayMs, draftValue]);
return (
<Textarea
{...props}
value={draftValue}
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) => {
isFocusedRef.current = false;
commitValue(draftValueRef.current, { immediate: true });
onBlur?.(event);
}}
/>
);
}
function stableStringifyWorkflowDraftValue(value: unknown): string {
if (value === null || typeof value !== "object") {
return JSON.stringify(value) ?? "null";
}
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringifyWorkflowDraftValue(item)).join(",")}]`;
}
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, item]) => item !== undefined)
.sort(([left], [right]) => left.localeCompare(right));
return `{${entries
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringifyWorkflowDraftValue(item)}`)
.join(",")}}`;
}
function createAgentWorkflowValueSignature(value: unknown) {
const serialized = stableStringifyWorkflowDraftValue(value);
let hashA = 0xdeadbeef ^ serialized.length;
let hashB = 0x41c6ce57 ^ serialized.length;
for (let index = 0; index < serialized.length; index += 1) {
const code = serialized.charCodeAt(index);
hashA = Math.imul(hashA ^ code, 2654435761);
hashB = Math.imul(hashB ^ code, 1597334677);
}
hashA =
Math.imul(hashA ^ (hashA >>> 16), 2246822507) ^
Math.imul(hashB ^ (hashB >>> 13), 3266489909);
hashB =
Math.imul(hashB ^ (hashB >>> 16), 2246822507) ^
Math.imul(hashA ^ (hashA >>> 13), 3266489909);
return `${serialized.length}:${(hashB >>> 0).toString(16)}${(hashA >>> 0).toString(16)}`;
}
function createAgentWorkflowSettingsStateSignature(state: AgentWorkflowSettingsDraftState) {
return createAgentWorkflowValueSignature(state);
}
function createAgentWorkflowSettingsSectionSignatures(state: AgentWorkflowSettingsDraftState) {
return {
agentWorkflowDefaultModelId: createAgentWorkflowValueSignature(
state.agentWorkflowDefaultModelId
),
agentPresetLibraryConfig: createAgentWorkflowValueSignature(state.presetLibraryConfig),
agentWorkflowUiConfig: createAgentWorkflowValueSignature({
...state.config,
steps: normalizeAgentWorkflowStepOrdering(state.config.steps),
}),
agentWorkflowMaterialApiConfig: createAgentWorkflowValueSignature(state.materialApiConfig),
agentWorkflowGoodsInfoApiConfig: createAgentWorkflowValueSignature(state.goodsInfoApiConfig),
agentWorkflowBrandModelConfig: createAgentWorkflowValueSignature(state.brandModelConfig),
agentWorkflowShowcaseConfig: createAgentWorkflowValueSignature(state.showcaseConfig),
agentPsdTextColorConfig: createAgentWorkflowValueSignature(state.psdTextColorConfig),
agentPsdTemplateSecondaryCategoryConfig: createAgentWorkflowValueSignature(
state.psdTemplateSecondaryCategoryConfig
),
} satisfies Record<keyof AgentWorkflowSettingsPayload, string>;
}
function getWorkflowLevelLabel(depth: number, kind: "模块" | "卡片") {
const labels =
kind === "模块" ? WORKFLOW_LEVEL_LABELS : WORKFLOW_OPTION_LEVEL_LABELS;
return (
labels[Math.max(0, Math.min(depth - 1, labels.length - 1))] ||
`第 ${depth} 级${kind}`
);
}
function normalizeConfigOrdering(config: AgentWorkflowUiConfig): AgentWorkflowUiConfig {
return {
...config,
steps: normalizeAgentWorkflowStepOrdering(config.steps),
};
}
function createWorkflowSlug(value: string, fallback: string) {
const normalized = value
.trim()
.replace(/\s+/g, "_")
.replace(/[^\w-]/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "")
.toLowerCase();
return normalized || fallback;
}
function ensureUniqueWorkflowKey(baseKey: string, existingKeys: string[]) {
const normalizedBase = createWorkflowSlug(baseKey, "item");
const keySet = new Set(existingKeys);
if (!keySet.has(normalizedBase)) {
return normalizedBase;
}
let suffix = 2;
let candidate = `${normalizedBase}_${suffix}`;
while (keySet.has(candidate)) {
suffix += 1;
candidate = `${normalizedBase}_${suffix}`;
}
return candidate;
}
function createWorkflowOptionDraft(
stepKey: string,
optionIndex: number,
existingOptionKeys: string[]
): AgentWorkflowOptionUiConfig {
const optionKey = ensureUniqueWorkflowKey(
`${stepKey}_option_${optionIndex + 1}`,
existingOptionKeys
);
return {
key: optionKey,
buttonName: `选项${optionIndex + 1}`,
description: "",
previewImageUrl: "",
previewImageSourceUrl: "",
enabled: true,
sortOrder: optionIndex,
promptFragment: `请按选项${optionIndex + 1}执行。`,
blockedOptionKeys: [],
specialModuleEnabled: false,
specialModuleExclusionEnabled: false,
specialModuleAllowedOptionKeys: [],
children: [],
};
}
function createWorkflowStepDraft(
depth: number,
stepIndex: number,
existingStepKeys: string[]
): AgentWorkflowStepUiConfig {
const levelLabel = getWorkflowLevelLabel(depth, "模块");
const stepKey = ensureUniqueWorkflowKey(`step_${depth}_${stepIndex + 1}`, existingStepKeys);
return {
key: stepKey,
title: `新${levelLabel}${stepIndex + 1}`,
description: "",
enabled: true,
sortOrder: stepIndex,
specialModuleEnabled: false,
options: [createWorkflowOptionDraft(stepKey, 0, [])],
children: [],
};
}
function updateWorkflowStepTree(
steps: AgentWorkflowStepUiConfig[],
stepKey: string,
updater: (step: AgentWorkflowStepUiConfig) => AgentWorkflowStepUiConfig
): AgentWorkflowStepUiConfig[] {
let changed = false;
const nextSteps = steps.map((step) => {
if (step.key === stepKey) {
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: nextChildren,
};
});
return changed ? nextSteps : steps;
}
function collectWorkflowOptionKeys(options: AgentWorkflowOptionUiConfig[]): string[] {
return flattenAgentWorkflowOptionTree(options).map(({ option }) => option.key);
}
function hasWorkflowOptionPrompt(option: AgentWorkflowOptionUiConfig) {
return Boolean(String(option.promptFragment || "").trim());
}
function hasWorkflowOptionDescription(option: AgentWorkflowOptionUiConfig) {
return Boolean(String(option.description || "").trim());
}
function hasWorkflowOptionPreview(option: AgentWorkflowOptionUiConfig) {
return Boolean(
String(option.previewImageUrl || "").trim() ||
String(option.previewImageSourceUrl || "").trim()
);
}
function hasWorkflowOptionConfiguredContent(option: AgentWorkflowOptionUiConfig) {
return (
hasWorkflowOptionPrompt(option) ||
hasWorkflowOptionDescription(option) ||
hasWorkflowOptionPreview(option)
);
}
function getWorkflowOptionMissingFields(option: AgentWorkflowOptionUiConfig): string[] {
const missingFields: string[] = [];
if (!String(option.buttonName || "").trim()) {
missingFields.push("名称");
}
if (!hasWorkflowOptionPrompt(option)) {
missingFields.push("Prompt");
}
if (!hasWorkflowOptionDescription(option)) {
missingFields.push("说明");
}
if (!hasWorkflowOptionPreview(option)) {
missingFields.push("预览图");
}
return missingFields;
}
function createEmptyWorkflowOptionContentSummary(): WorkflowOptionContentSummary {
return {
total: 0,
withPrompt: 0,
withDescription: 0,
withPreview: 0,
disabled: 0,
withMutualExclusion: 0,
};
}
function analyzeWorkflowOptionContent(
options: AgentWorkflowOptionUiConfig[]
): WorkflowOptionContentSummary {
return flattenAgentWorkflowOptionTree(options).reduce<WorkflowOptionContentSummary>(
(summary, { option }) => ({
total: summary.total + 1,
withPrompt: summary.withPrompt + (hasWorkflowOptionPrompt(option) ? 1 : 0),
withDescription:
summary.withDescription + (hasWorkflowOptionDescription(option) ? 1 : 0),
withPreview: summary.withPreview + (hasWorkflowOptionPreview(option) ? 1 : 0),
disabled: summary.disabled + (option.enabled === false ? 1 : 0),
withMutualExclusion:
summary.withMutualExclusion +
((option.blockedOptionKeys || []).length > 0 || option.specialModuleExclusionEnabled
? 1
: 0),
}),
createEmptyWorkflowOptionContentSummary()
);
}
function buildWorkflowStepAnalysisBranch(
step: AgentWorkflowStepUiConfig,
depth: number,
parentPath: string[]
): {
items: WorkflowStepAnalysis[];
subtreeStepCount: number;
subtreeOptionCount: number;
hasSubtreeContent: boolean;
} {
const title = step.title || step.key;
const pathParts = [...parentPath, title];
const childBranches = [...(step.children || [])]
.sort((left, right) => left.sortOrder - right.sortOrder)
.map((childStep) => buildWorkflowStepAnalysisBranch(childStep, depth + 1, pathParts));
const optionStats = analyzeWorkflowOptionContent(step.options || []);
const childStepCount = (step.children || []).length;
const subtreeStepCount =
1 + childBranches.reduce((total, child) => total + child.subtreeStepCount, 0);
const subtreeOptionCount =
optionStats.total +
childBranches.reduce((total, child) => total + child.subtreeOptionCount, 0);
const hasOwnContent =
optionStats.withPrompt > 0 || optionStats.withDescription > 0 || optionStats.withPreview > 0;
const hasSubtreeContent =
hasOwnContent || childBranches.some((child) => child.hasSubtreeContent);
const current: WorkflowStepAnalysis = {
key: step.key,
title,
path: pathParts.join(" / "),
depth,
childStepCount,
directRootOptionCount: (step.options || []).length,
optionStats,
subtreeStepCount,
subtreeOptionCount,
hasOwnContent,
hasSubtreeContent,
enabled: step.enabled !== false,
};
return {
items: [current, ...childBranches.flatMap((child) => child.items)],
subtreeStepCount,
subtreeOptionCount,
hasSubtreeContent,
};
}
function buildWorkflowStepAnalyses(
steps: AgentWorkflowStepUiConfig[]
): WorkflowStepAnalysis[] {
return [...steps]
.sort((left, right) => left.sortOrder - right.sortOrder)
.flatMap((step) => buildWorkflowStepAnalysisBranch(step, 1, []).items);
}
function buildWorkflowOptionGapDetailsFromOptions(
options: AgentWorkflowOptionUiConfig[],
stepKey: string,
stepPath: string,
ancestors: string[] = []
): WorkflowOptionGapAnalysis[] {
return [...options]
.sort((left, right) => left.sortOrder - right.sortOrder)
.flatMap((option) => {
const label = option.buttonName || option.key;
const pathParts = [...ancestors, label];
const missingFields = getWorkflowOptionMissingFields(option);
const current =
missingFields.length > 0
? [
{
key: option.key,
label,
path: pathParts.join(" / "),
stepKey,
stepPath,
depth: pathParts.length,
missingFields,
disabled: option.enabled === false,
childCount: (option.children || []).length,
},
]
: [];
return [
...current,
...buildWorkflowOptionGapDetailsFromOptions(
option.children || [],
stepKey,
stepPath,
pathParts
),
];
});
}
function buildWorkflowOptionGapDetails(
steps: AgentWorkflowStepUiConfig[],
parentPath: string[] = []
): WorkflowOptionGapAnalysis[] {
return [...steps]
.sort((left, right) => left.sortOrder - right.sortOrder)
.flatMap((step) => {
const stepTitle = step.title || step.key;
const stepPathParts = [...parentPath, stepTitle];
const stepPath = stepPathParts.join(" / ");
return [
...buildWorkflowOptionGapDetailsFromOptions(step.options || [], step.key, stepPath),
...buildWorkflowOptionGapDetails(step.children || [], stepPathParts),
];
});
}
function collectWorkflowOptionRelations(
options: AgentWorkflowOptionUiConfig[],
ancestors: string[] = []
): Array<{
key: string;
label: string;
depth: number;
childCount: number;
hasPrompt: boolean;
hasDescription: boolean;
hasPreview: boolean;
hasContent: boolean;
disabled: boolean;
blockedCount: number;
specialModuleEnabled: boolean;
specialModuleExclusionEnabled: boolean;
}> {
return options.flatMap((option) => {
const currentPath = [...ancestors, option.buttonName || option.key];
return [
{
key: option.key,
label: currentPath.join(" / "),
depth: currentPath.length,
childCount: (option.children || []).length,
hasPrompt: hasWorkflowOptionPrompt(option),
hasDescription: hasWorkflowOptionDescription(option),
hasPreview: hasWorkflowOptionPreview(option),
hasContent: hasWorkflowOptionConfiguredContent(option),
disabled: option.enabled === false,
blockedCount: (option.blockedOptionKeys || []).length,
specialModuleEnabled: Boolean(option.specialModuleEnabled),
specialModuleExclusionEnabled: Boolean(option.specialModuleExclusionEnabled),
},
...collectWorkflowOptionRelations(option.children || [], currentPath),
];
});
}
function collectWorkflowOptionRelationsFromSteps(
steps: AgentWorkflowStepUiConfig[]
): Array<{
key: string;
label: string;
depth: number;
childCount: number;
hasPrompt: boolean;
hasDescription: boolean;
hasPreview: boolean;
hasContent: boolean;
disabled: boolean;
blockedCount: number;
specialModuleEnabled: boolean;
specialModuleExclusionEnabled: boolean;
stepKey: string;
stepTitle: string;
}> {
return flattenAgentWorkflowSteps(steps).flatMap(({ step }) =>
collectWorkflowOptionRelations(step.options || []).map((item) => ({
...item,
label: `${step.title} / ${item.label}`,
stepKey: step.key,
stepTitle: step.title,
}))
);
}
type WorkflowOptionRelation = ReturnType<typeof collectWorkflowOptionRelationsFromSteps>[number];
function updateWorkflowOptionTree(
options: AgentWorkflowOptionUiConfig[],
optionKey: string,
updater: (option: AgentWorkflowOptionUiConfig) => AgentWorkflowOptionUiConfig
): AgentWorkflowOptionUiConfig[] {
let changed = false;
const nextOptions = options.map((option) => {
if (option.key === optionKey) {
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: nextChildren,
};
});
return changed ? nextOptions : options;
}
function removeWorkflowOptionTree(
options: AgentWorkflowOptionUiConfig[],
optionKey: string
): AgentWorkflowOptionUiConfig[] {
return options
.filter((option) => option.key !== optionKey)
.map((option) => ({
...option,
children: removeWorkflowOptionTree(option.children || [], optionKey),
}));
}
function removeWorkflowStepTree(
steps: AgentWorkflowStepUiConfig[],
stepKey: string
): AgentWorkflowStepUiConfig[] {
return steps
.filter((step) => step.key !== stepKey)
.map((step) => ({
...step,
children: removeWorkflowStepTree(step.children || [], stepKey),
}));
}
function moveArrayItem<T>(items: T[], fromIndex: number, toIndex: number): T[] {
if (
fromIndex === toIndex ||
fromIndex < 0 ||
toIndex < 0 ||
fromIndex >= items.length ||
toIndex >= items.length
) {
return items;
}
const nextItems = [...items];
const [movedItem] = nextItems.splice(fromIndex, 1);
nextItems.splice(toIndex, 0, movedItem);
return nextItems;
}
function collectWorkflowStepKeys(steps: AgentWorkflowStepUiConfig[]): string[] {
return flattenAgentWorkflowSteps(steps).map(({ step }) => step.key);
}
function collectDescendantStepKeys(step: AgentWorkflowStepUiConfig): string[] {
return [step.key, ...collectWorkflowStepKeys(step.children || [])];
}
function collectWorkflowOptionKeysFromSteps(steps: AgentWorkflowStepUiConfig[]): string[] {
return flattenAgentWorkflowSteps(steps).flatMap(({ step }) =>
collectWorkflowOptionKeys(step.options || [])
);
}
function isWorkflowStepTargetInsideSource(
sourceStep: AgentWorkflowStepUiConfig,
targetStepKey: string | null
) {
if (!targetStepKey) {
return false;
}
return collectDescendantStepKeys(sourceStep).includes(targetStepKey);
}
function getWorkflowStepSubtreeDepth(step: AgentWorkflowStepUiConfig): number {
const childDepths = (step.children || []).map(getWorkflowStepSubtreeDepth);
return 1 + (childDepths.length > 0 ? Math.max(...childDepths) : 0);
}
function getWorkflowStepInsertionDepth(
steps: AgentWorkflowStepUiConfig[],
targetStepKey: string | null,
position: WorkflowStepDropPosition
): number | null {
if (!targetStepKey) {
return 1;
}
const targetMeta = flattenAgentWorkflowSteps(steps).find(
({ step }) => step.key === targetStepKey
);
if (!targetMeta) {
return null;
}
return position === "inside" ? targetMeta.depth + 1 : targetMeta.depth;
}
function extractWorkflowStepFromTree(
steps: AgentWorkflowStepUiConfig[],
stepKey: string
): {
steps: AgentWorkflowStepUiConfig[];
extractedStep: AgentWorkflowStepUiConfig | null;
} {
let extractedStep: AgentWorkflowStepUiConfig | null = null;
const nextSteps = steps
.filter((step) => {
if (step.key === stepKey) {
extractedStep = step;
return false;
}
return true;
})
.map((step) => {
if (extractedStep) {
return step;
}
const childResult = extractWorkflowStepFromTree(step.children || [], stepKey);
if (childResult.extractedStep) {
extractedStep = childResult.extractedStep;
return {
...step,
children: childResult.steps,
};
}
return step;
});
return {
steps: nextSteps,
extractedStep,
};
}
function insertWorkflowStepIntoTree(
steps: AgentWorkflowStepUiConfig[],
targetStepKey: string | null,
position: WorkflowStepDropPosition,
stepToInsert: AgentWorkflowStepUiConfig
): {
steps: AgentWorkflowStepUiConfig[];
inserted: boolean;
} {
if (!targetStepKey) {
return {
steps: [...steps, stepToInsert],
inserted: true,
};
}
let inserted = false;
const nextSteps = steps.flatMap((step) => {
if (step.key === targetStepKey) {
inserted = true;
if (position === "before") {
return [stepToInsert, step];
}
if (position === "after") {
return [step, stepToInsert];
}
return [
{
...step,
children: [...(step.children || []), stepToInsert],
},
];
}
const childResult = insertWorkflowStepIntoTree(
step.children || [],
targetStepKey,
position,
stepToInsert
);
if (childResult.inserted) {
inserted = true;
return [
{
...step,
children: childResult.steps,
},
];
}
return [step];
});
return {
steps: nextSteps,
inserted,
};
}
function cloneWorkflowOptionSnapshot(
option: AgentWorkflowOptionUiConfig
): AgentWorkflowOptionUiConfig {
return {
...option,
blockedOptionKeys: [...(option.blockedOptionKeys || [])],
specialModuleAllowedOptionKeys: [...(option.specialModuleAllowedOptionKeys || [])],
children: (option.children || []).map(cloneWorkflowOptionSnapshot),
};
}
function cloneWorkflowStepSnapshot(
step: AgentWorkflowStepUiConfig
): AgentWorkflowStepUiConfig {
return {
...step,
options: (step.options || []).map(cloneWorkflowOptionSnapshot),
children: (step.children || []).map(cloneWorkflowStepSnapshot),
};
}
function remapWorkflowOptionBlockedKeys(
options: AgentWorkflowOptionUiConfig[],
keyMap: Map<string, string>
): AgentWorkflowOptionUiConfig[] {
return options.map((option) => ({
...option,
blockedOptionKeys: (option.blockedOptionKeys || []).map((blockedKey) =>
keyMap.get(blockedKey) || blockedKey
),
specialModuleAllowedOptionKeys: (option.specialModuleAllowedOptionKeys || []).map(
(allowedKey) => keyMap.get(allowedKey) || allowedKey
),
children: remapWorkflowOptionBlockedKeys(option.children || [], keyMap),
}));
}
function remapWorkflowStepBlockedKeys(
step: AgentWorkflowStepUiConfig,
keyMap: Map<string, string>
): AgentWorkflowStepUiConfig {
return {
...step,
options: remapWorkflowOptionBlockedKeys(step.options || [], keyMap),
children: (step.children || []).map((childStep) =>
remapWorkflowStepBlockedKeys(childStep, keyMap)
),
};
}
function cloneWorkflowOptionsForPaste(
options: AgentWorkflowOptionUiConfig[],
stepKey: string,
usedOptionKeys: Set<string>,
keyMap: Map<string, string>
): AgentWorkflowOptionUiConfig[] {
return options.map((option, optionIndex) => {
const nextKey = ensureUniqueWorkflowKey(
`${stepKey}_${option.key || `option_${optionIndex + 1}`}_copy`,
Array.from(usedOptionKeys)
);
usedOptionKeys.add(nextKey);
keyMap.set(option.key, nextKey);
return {
...option,
key: nextKey,
sortOrder: option.sortOrder ?? optionIndex,
children: cloneWorkflowOptionsForPaste(option.children || [], stepKey, usedOptionKeys, keyMap),
};
});
}
function cloneWorkflowStepForPaste(
step: AgentWorkflowStepUiConfig,
usedStepKeys: Set<string>,
usedOptionKeys: Set<string>
): AgentWorkflowStepUiConfig {
const optionKeyMap = new Map<string, string>();
const cloneStep = (
currentStep: AgentWorkflowStepUiConfig,
isRootCopy: boolean
): AgentWorkflowStepUiConfig => {
const nextStepKey = ensureUniqueWorkflowKey(
`${currentStep.key}_copy`,
Array.from(usedStepKeys)
);
usedStepKeys.add(nextStepKey);
return {
...currentStep,
key: nextStepKey,
title: isRootCopy ? `${currentStep.title || "未命名模块"} 副本` : currentStep.title,
sortOrder: 0,
options: cloneWorkflowOptionsForPaste(
currentStep.options || [],
nextStepKey,
usedOptionKeys,
optionKeyMap
),
children: (currentStep.children || []).map((childStep) => cloneStep(childStep, false)),
};
};
return remapWorkflowStepBlockedKeys(cloneStep(step, true), optionKeyMap);
}
function readAgentWorkflowSettingsDraft(): AgentWorkflowSettingsDraft | null {
if (typeof window === "undefined") {
return null;
}
try {
const rawDraft = window.localStorage.getItem(WORKFLOW_SETTINGS_DRAFT_STORAGE_KEY);
if (!rawDraft) {
return null;
}
const parsed = JSON.parse(rawDraft) as Partial<AgentWorkflowSettingsDraft>;
const isLegacyDraft = parsed?.version === 1;
const isVersionedDraft =
parsed?.version === 2 &&
typeof parsed.baseSignature === "string" &&
parsed.baseSignature.length > 0;
if (
(!isLegacyDraft && !isVersionedDraft) ||
typeof parsed.updatedAt !== "number" ||
!parsed.state ||
typeof parsed.state !== "object"
) {
removeAgentWorkflowSettingsDraft();
return null;
}
if (Date.now() - parsed.updatedAt > WORKFLOW_SETTINGS_DRAFT_MAX_AGE_MS) {
removeAgentWorkflowSettingsDraft();
return null;
}
return parsed as AgentWorkflowSettingsDraft;
} catch {
return null;
}
}
function writeAgentWorkflowSettingsDraft(draft: AgentWorkflowSettingsDraft) {
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem(WORKFLOW_SETTINGS_DRAFT_STORAGE_KEY, JSON.stringify(draft));
}
function preserveAgentWorkflowSettingsDraftForRecovery(draft: AgentWorkflowSettingsDraft) {
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem(
WORKFLOW_SETTINGS_STALE_DRAFT_STORAGE_KEY,
JSON.stringify({
...draft,
preservedAt: Date.now(),
})
);
}
function readPreservedAgentWorkflowSettingsDraftForRecovery(): AgentWorkflowSettingsDraft | null {
if (typeof window === "undefined") {
return null;
}
try {
const rawDraft = window.localStorage.getItem(WORKFLOW_SETTINGS_STALE_DRAFT_STORAGE_KEY);
if (!rawDraft) {
return null;
}
const parsed = JSON.parse(rawDraft) as Partial<AgentWorkflowSettingsDraft>;
if (
(parsed?.version !== 1 && parsed?.version !== 2) ||
typeof parsed.updatedAt !== "number" ||
!parsed.state ||
typeof parsed.state !== "object"
) {
return null;
}
return parsed as AgentWorkflowSettingsDraft;
} catch {
return null;
}
}
function removeAgentWorkflowSettingsDraft() {
if (typeof window === "undefined") {
return;
}
window.localStorage.removeItem(WORKFLOW_SETTINGS_DRAFT_STORAGE_KEY);
}
function createPresetLibraryId(value: string, fallback: string) {
const normalized = value
.trim()
.replace(/\s+/g, "_")
.replace(/[^\w-]/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "")
.toLowerCase();
return normalized || fallback;
}
function ensureUniquePresetLibraryId(baseId: string, existingIds: string[]) {
const normalizedBase = createPresetLibraryId(baseId, "item");
const idSet = new Set(existingIds);
if (!idSet.has(normalizedBase)) {
return normalizedBase;
}
let suffix = 2;
let candidate = `${normalizedBase}_${suffix}`;
while (idSet.has(candidate)) {
suffix += 1;
candidate = `${normalizedBase}_${suffix}`;
}
return candidate;
}
function collectPresetLibraryNodeIds(
nodes: AgentPresetLibraryCategoryNode[]
): string[] {
return nodes.flatMap((node) => [node.id, ...collectPresetLibraryNodeIds(node.children || [])]);
}
function countPresetLibraryNodes(nodes: AgentPresetLibraryCategoryNode[]): number {
return nodes.reduce(
(total, node) => total + 1 + countPresetLibraryNodes(node.children || []),
0
);
}
function updatePresetLibraryNodeTree(
nodes: AgentPresetLibraryCategoryNode[],
nodeId: string,
updater: (node: AgentPresetLibraryCategoryNode) => AgentPresetLibraryCategoryNode
): AgentPresetLibraryCategoryNode[] {
return nodes.map((node) => {
if (node.id === nodeId) {
return updater(node);
}
return {
...node,
children: updatePresetLibraryNodeTree(node.children || [], nodeId, updater),
};
});
}
function removePresetLibraryNodeTree(
nodes: AgentPresetLibraryCategoryNode[],
nodeId: string
): AgentPresetLibraryCategoryNode[] {
return nodes
.filter((node) => node.id !== nodeId)
.map((node) => ({
...node,
children: removePresetLibraryNodeTree(node.children || [], nodeId),
}));
}
function findPresetLibraryNodeById(
nodes: AgentPresetLibraryCategoryNode[],
nodeId: string
): AgentPresetLibraryCategoryNode | null {
for (const node of nodes) {
if (node.id === nodeId) {
return node;
}
const foundChild = findPresetLibraryNodeById(node.children || [], nodeId);
if (foundChild) {
return foundChild;
}
}
return null;
}
function createPresetLibraryNodeDraft(
depth: number,
nodeIndex: number,
existingIds: string[]
): AgentPresetLibraryCategoryNode {
const levelLabel =
PRESET_LIBRARY_LEVEL_LABELS[Math.max(0, Math.min(depth - 1, 2))] || "分类";
return {
id: ensureUniquePresetLibraryId(`category_${depth}_${nodeIndex + 1}`, existingIds),
name: `新${levelLabel}${nodeIndex + 1}`,
enabled: true,
sortOrder: nodeIndex,
children: [],
};
}
function createPresetLibrarySectionDraft(
sectionIndex: number,
existingIds: string[]
): AgentPresetLibrarySection {
const fallbackId =
sectionIndex === 0 ? AGENT_PRESET_LIBRARY_DEFAULT_SECTION_ID : `section_${sectionIndex + 1}`;
return {
id: ensureUniquePresetLibraryId(fallbackId, existingIds),
label: `分类页${sectionIndex + 1}`,
description: "",
enabled: true,
sortOrder: sectionIndex,
categories: [],
};
}
function buildWorkflowInputOptionsFromBrandEntries(
entries: AgentWorkflowBrandStoreEntry[],
priceBands: string[] = [],
categoryLabels: Partial<AgentWorkflowCategoryLabels> = defaultAgentWorkflowCategoryLabels
) {
const resolvedCategoryLabels = {
...defaultAgentWorkflowCategoryLabels,
...categoryLabels,
};
type FourthCategoryBuilder = {
categoryName: string;
fifthCategoryMap: Map<string, string>;
sortOrder: number;
};
type ThirdCategoryBuilder = {
categoryName: string;
fourthCategoryMap: Map<string, FourthCategoryBuilder>;
sortOrder: number;
};
type StoreBuilder = {
storeName: string;
thirdCategoryMap: Map<string, ThirdCategoryBuilder>;
sortOrder: number;
};
type BrandBuilder = {
brandName: string;
storeMap: Map<string, StoreBuilder>;
sortOrder: number;
};
const brandMap = new Map<string, BrandBuilder>();
const ensureBrandBuilder = (brandName: string, sortOrder: number) => {
const normalizedBrandName = String(brandName || "").trim();
if (!normalizedBrandName) {
return null;
}
const key = normalizedBrandName.toLowerCase();
const existing = brandMap.get(key);
if (existing) {
return existing;
}
const nextBuilder: BrandBuilder = {
brandName: normalizedBrandName,
storeMap: new Map<string, StoreBuilder>(),
sortOrder,
};
brandMap.set(key, nextBuilder);
return nextBuilder;
};
const ensureStoreBuilder = (
brandBuilder: BrandBuilder,
storeName: string,
sortOrder: number
) => {
const normalizedStoreName = String(storeName || "").trim();
if (!normalizedStoreName) {
return null;
}
const key = normalizedStoreName.toLowerCase();
const existing = brandBuilder.storeMap.get(key);
if (existing) {
return existing;
}
const nextBuilder: StoreBuilder = {
storeName: normalizedStoreName,
thirdCategoryMap: new Map<string, ThirdCategoryBuilder>(),
sortOrder,
};
brandBuilder.storeMap.set(key, nextBuilder);
return nextBuilder;
};
const ensureThirdCategoryBuilder = (
storeBuilder: StoreBuilder,
categoryName: string,
sortOrder: number
) => {
const normalizedCategoryName = String(categoryName || "").trim();
if (!normalizedCategoryName) {
return null;
}
const key = normalizedCategoryName.toLowerCase();
const existing = storeBuilder.thirdCategoryMap.get(key);
if (existing) {
return existing;
}
const nextBuilder: ThirdCategoryBuilder = {
categoryName: normalizedCategoryName,
fourthCategoryMap: new Map<string, FourthCategoryBuilder>(),
sortOrder,
};
storeBuilder.thirdCategoryMap.set(key, nextBuilder);
return nextBuilder;
};
const ensureFourthCategoryBuilder = (
thirdCategoryBuilder: ThirdCategoryBuilder,
categoryName: string,
sortOrder: number
) => {
const normalizedCategoryName = String(categoryName || "").trim();
if (!normalizedCategoryName) {
return null;
}
const key = normalizedCategoryName.toLowerCase();
const existing = thirdCategoryBuilder.fourthCategoryMap.get(key);
if (existing) {
return existing;
}
const nextBuilder: FourthCategoryBuilder = {
categoryName: normalizedCategoryName,
fifthCategoryMap: new Map<string, string>(),
sortOrder,
};
thirdCategoryBuilder.fourthCategoryMap.set(key, nextBuilder);
return nextBuilder;
};
entries.forEach((entry, brandIndex) => {
const brandBuilder = ensureBrandBuilder(entry.brandName, brandIndex);
if (!brandBuilder) {
return;
}
const legacyStoreNames = Array.from(
new Set(
(entry.stores || [])
.map((storeName) => String(storeName || "").trim())
.filter(Boolean)
)
);
legacyStoreNames.forEach((storeName, storeIndex) => {
ensureStoreBuilder(brandBuilder, storeName, storeIndex);
});
(entry.storeEntries || []).forEach((storeEntry, storeIndex) => {
const storeBuilder = ensureStoreBuilder(brandBuilder, storeEntry.storeName, storeIndex);
if (!storeBuilder) {
return;
}
const legacyThirdCategoryNames = Array.from(
new Set(
(storeEntry.thirdCategories || [])
.map((thirdCategory) => String(thirdCategory || "").trim())
.filter(Boolean)
)
);
legacyThirdCategoryNames.forEach((thirdCategory, thirdIndex) => {
ensureThirdCategoryBuilder(storeBuilder, thirdCategory, thirdIndex);
});
(storeEntry.thirdCategoryEntries || []).forEach((thirdEntry, thirdIndex) => {
const thirdCategoryBuilder = ensureThirdCategoryBuilder(
storeBuilder,
thirdEntry.categoryName,
thirdIndex
);
if (!thirdCategoryBuilder) {
return;
}
const legacyFourthCategoryNames = Array.from(
new Set(
(thirdEntry.fourthCategories || [])
.map((fourthCategory) => String(fourthCategory || "").trim())
.filter(Boolean)
)
);
legacyFourthCategoryNames.forEach((fourthCategory, fourthIndex) => {
ensureFourthCategoryBuilder(thirdCategoryBuilder, fourthCategory, fourthIndex);
});
(thirdEntry.fourthCategoryEntries || []).forEach((fourthEntry, fourthIndex) => {
const fourthCategoryBuilder = ensureFourthCategoryBuilder(
thirdCategoryBuilder,
fourthEntry.categoryName,
fourthIndex
);
if (!fourthCategoryBuilder) {
return;
}
(fourthEntry.fifthCategories || []).forEach((fifthCategory) => {
const normalizedFifthCategory = String(fifthCategory || "").trim();
if (!normalizedFifthCategory) {
return;
}
const key = normalizedFifthCategory.toLowerCase();
if (!fourthCategoryBuilder.fifthCategoryMap.has(key)) {
fourthCategoryBuilder.fifthCategoryMap.set(key, normalizedFifthCategory);
}
});
});
});
});
});
const normalizedEntries = Array.from(brandMap.values())
.sort((left, right) => left.sortOrder - right.sortOrder)
.map((brandEntry, brandIndex) => {
const storeEntries = Array.from(brandEntry.storeMap.values())
.sort((left, right) => left.sortOrder - right.sortOrder)
.map((storeEntry, storeIndex) => {
const thirdCategoryEntries = Array.from(storeEntry.thirdCategoryMap.values())
.sort((left, right) => left.sortOrder - right.sortOrder)
.map((thirdEntry, thirdIndex) => {
const fourthCategoryEntries = Array.from(thirdEntry.fourthCategoryMap.values())
.sort((left, right) => left.sortOrder - right.sortOrder)
.map((fourthEntry, fourthIndex) => ({
categoryName: fourthEntry.categoryName,
fifthCategories: Array.from(fourthEntry.fifthCategoryMap.values()),
sortOrder: fourthIndex,
}));
return {
categoryName: thirdEntry.categoryName,
fourthCategories: fourthCategoryEntries.map((fourthEntry) => fourthEntry.categoryName),
fourthCategoryEntries,
sortOrder: thirdIndex,
};
});
return {
storeName: storeEntry.storeName,
thirdCategories: thirdCategoryEntries.map((thirdEntry) => thirdEntry.categoryName),
thirdCategoryEntries,
sortOrder: storeIndex,
};
});
return {
brandName: brandEntry.brandName,
storeEntries,
sortOrder: brandIndex,
};
});
const normalizedPriceBands = Array.from(
new Set(
priceBands
.map((item) => item.trim())
.filter(Boolean)
)
);
return {
brandStoreEntries: normalizedEntries.map((entry) => ({
...entry,
stores: entry.storeEntries.map((storeEntry) => storeEntry.storeName),
})),
brands: normalizedEntries.map((entry) => entry.brandName),
stores: Array.from(
new Set(
normalizedEntries.flatMap((entry) =>
entry.storeEntries.map((storeEntry) => storeEntry.storeName)
)
)
),
thirdCategories: Array.from(
new Set(
normalizedEntries.flatMap((entry) =>
entry.storeEntries.flatMap((storeEntry) => storeEntry.thirdCategories || [])
)
)
),
fourthCategories: Array.from(
new Set(
normalizedEntries.flatMap((entry) =>
entry.storeEntries.flatMap((storeEntry) =>
(storeEntry.thirdCategoryEntries || []).flatMap(
(thirdEntry) => thirdEntry.fourthCategories || []
)
)
)
)
),
fifthCategories: Array.from(
new Set(
normalizedEntries.flatMap((entry) =>
entry.storeEntries.flatMap((storeEntry) =>
(storeEntry.thirdCategoryEntries || []).flatMap((thirdEntry) =>
(thirdEntry.fourthCategoryEntries || []).flatMap(
(fourthEntry) => fourthEntry.fifthCategories || []
)
)
)
)
)
),
categoryLabels: resolvedCategoryLabels,
priceBands: normalizedPriceBands,
};
}
function createStoreDraftKey(brandName: string, storeName: string) {
return `${brandName}::${storeName}`;
}
function createThirdCategoryDraftKey(brandName: string, storeName: string, thirdCategory: string) {
return `${brandName}::${storeName}::${thirdCategory}`;
}
function createFourthCategoryDraftKey(
brandName: string,
storeName: string,
thirdCategory: string,
fourthCategory: string
) {
return `${brandName}::${storeName}::${thirdCategory}::${fourthCategory}`;
}
interface AgentWorkflowSettingsClientProps {
backHref?: string;
backLabel?: string;
}
interface AgentWorkflowSettingsPayload {
agentWorkflowDefaultModelId?: string | null;
agentPresetLibraryConfig?: AgentPresetLibraryConfig;
agentWorkflowUiConfig?: AgentWorkflowUiConfig;
agentWorkflowMaterialApiConfig?: AgentWorkflowMaterialApiConfig;
agentWorkflowGoodsInfoApiConfig?: AgentWorkflowGoodsInfoApiConfig;
agentWorkflowBrandModelConfig?: AgentWorkflowBrandModelConfig;
agentWorkflowShowcaseConfig?: AgentWorkflowShowcaseConfig;
agentPsdTextColorConfig?: AgentPsdTextColorConfig;
agentPsdTemplateSecondaryCategoryConfig?: AgentPsdTemplateSecondaryCategoryConfig;
}
interface AgentWorkflowDestructiveChangePayload {
totalBeforeOptionCount: number;
totalAfterOptionCount: number;
removedOptionCount: number;
removedEnabledOptionCount: number;
promptClearedCount: number;
previewClearedCount: number;
removedOptionSamples: string[];
promptClearedSamples: string[];
previewClearedSamples: string[];
approvalToken: string;
}
interface AgentWorkflowSnapshotItem {
id: string;
scope: string;
label: string | null;
summary?: {
keyCount?: number;
nonNullKeyCount?: number;
byteLength?: number;
siteFieldCount?: number;
} | null;
createdAt: string;
createdByName?: string | null;
createdByEmail?: string | null;
}
function formatSnapshotByteLength(value?: number | null) {
const byteLength = Number(value || 0);
if (!Number.isFinite(byteLength) || byteLength <= 0) {
return "0 KB";
}
return `${(byteLength / 1024).toFixed(byteLength >= 1024 * 1024 ? 1 : 0)} KB`;
}
function formatAgentWorkflowDestructiveChangeConfirmText(
payload: AgentWorkflowDestructiveChangePayload
) {
const lines = [
"这次保存会对风格化工作流做大规模删改,系统已拦截。",
"",
`删除节点:${payload.removedOptionCount}(其中启用中 ${payload.removedEnabledOptionCount})`,
`清空提示词:${payload.promptClearedCount}`,
`清空预览图:${payload.previewClearedCount}`,
"",
];
const sampleGroups: Array<[string, string[]]> = [
["将删除的分支示例", payload.removedOptionSamples],
["将清空提示词的示例", payload.promptClearedSamples],
["将清空预览图的示例", payload.previewClearedSamples],
];
for (const [label, samples] of sampleGroups) {
if (samples.length === 0) {
continue;
}
lines.push(`${label}:`);
for (const sample of samples.slice(0, 5)) {
lines.push(`- ${sample}`);
}
lines.push("");
}
lines.push("如果这是你确认要执行的结构性删除,请点击“确定”再次保存。");
return lines.join("\n");
}
interface AdminAiModelOption {
id: string;
modelId?: string | null;
displayName: string;
supportsImageGen?: boolean;
isEnabled?: boolean;
isHidden?: boolean;
isSubModel?: boolean;
}
function createAgentWorkflowSettingsStateFromPayload(
payload?: AgentWorkflowSettingsPayload | null
): AgentWorkflowSettingsDraftState {
return {
agentWorkflowDefaultModelId:
String(payload?.agentWorkflowDefaultModelId || "").trim() || null,
presetLibraryConfig: normalizeAgentPresetLibraryConfig(payload?.agentPresetLibraryConfig),
config: normalizeConfigOrdering(normalizeAgentWorkflowUiConfig(payload?.agentWorkflowUiConfig)),
materialApiConfig: normalizeAgentWorkflowMaterialApiConfig(
payload?.agentWorkflowMaterialApiConfig
),
goodsInfoApiConfig: normalizeAgentWorkflowGoodsInfoApiConfig(
payload?.agentWorkflowGoodsInfoApiConfig
),
showcaseConfig: normalizeAgentWorkflowShowcaseConfig(payload?.agentWorkflowShowcaseConfig),
psdTextColorConfig: normalizeAgentPsdTextColorConfig(payload?.agentPsdTextColorConfig),
psdTemplateSecondaryCategoryConfig: normalizeAgentPsdTemplateSecondaryCategoryConfig(
payload?.agentPsdTemplateSecondaryCategoryConfig
),
brandModelConfig: normalizeAgentWorkflowBrandModelConfig(
payload?.agentWorkflowBrandModelConfig
),
};
}
type AgentWorkflowSettingsField = keyof AgentWorkflowSettingsPayload;
const AGENT_WORKFLOW_SETTINGS_FIELD_LABELS: Record<AgentWorkflowSettingsField, string> = {
agentWorkflowDefaultModelId: "风格化专用默认模型",
agentPresetLibraryConfig: "预设库",
agentWorkflowUiConfig: "工作流步骤",
agentWorkflowMaterialApiConfig: "第三方图片素材",
agentWorkflowGoodsInfoApiConfig: "商品信息接口",
agentWorkflowBrandModelConfig: "品牌模特",
agentWorkflowShowcaseConfig: "页面展示",
agentPsdTextColorConfig: "PSD 文字颜色",
agentPsdTemplateSecondaryCategoryConfig: "PSD 二级分类",
};
function getAgentWorkflowSettingsPayloadEntries(state: AgentWorkflowSettingsDraftState) {
return [
{
field: "agentWorkflowDefaultModelId",
value: state.agentWorkflowDefaultModelId,
},
{
field: "agentPresetLibraryConfig",
value: state.presetLibraryConfig,
},
{
field: "agentWorkflowUiConfig",
value: state.config,
},
{
field: "agentWorkflowMaterialApiConfig",
value: state.materialApiConfig,
},
{
field: "agentWorkflowGoodsInfoApiConfig",
value: state.goodsInfoApiConfig,
},
{
field: "agentWorkflowBrandModelConfig",
value: state.brandModelConfig,
},
{
field: "agentWorkflowShowcaseConfig",
value: state.showcaseConfig,
},
{
field: "agentPsdTextColorConfig",
value: state.psdTextColorConfig,
},
{
field: "agentPsdTemplateSecondaryCategoryConfig",
value: state.psdTemplateSecondaryCategoryConfig,
},
] satisfies Array<{ field: AgentWorkflowSettingsField; value: unknown }>;
}
export function AgentWorkflowSettingsClient({
backHref = "/settings",
backLabel = "返回设置",
}: AgentWorkflowSettingsClientProps = {}) {
const { data, isLoading, mutate } = useSWR<AgentWorkflowSettingsPayload>(
AGENT_WORKFLOW_SETTINGS_ENDPOINT,
fetcher,
{ refreshInterval: 60_000 }
);
const { data: aiModelsData } = useSWR<{ models: AdminAiModelOption[] }>(
"/api/admin/ai-models",
fetcher,
{ revalidateOnFocus: false }
);
const {
data: snapshotsData,
isLoading: isLoadingSnapshots,
mutate: mutateSnapshots,
} = useSWR<{ snapshots: AgentWorkflowSnapshotItem[] }>(
AGENT_WORKFLOW_SNAPSHOTS_ENDPOINT,
fetcher,
{ revalidateOnFocus: false }
);
const [agentWorkflowDefaultModelId, setAgentWorkflowDefaultModelId] = useState<string | null>(null);
const [presetLibraryConfig, setPresetLibraryConfig] = useState<AgentPresetLibraryConfig>(
defaultAgentPresetLibraryConfig
);
const [config, setConfig] = useState<AgentWorkflowUiConfig>(defaultAgentWorkflowUiConfig);
const [materialApiConfig, setMaterialApiConfig] = useState<AgentWorkflowMaterialApiConfig>(
defaultAgentWorkflowMaterialApiConfig
);
const [goodsInfoApiConfig, setGoodsInfoApiConfig] = useState<AgentWorkflowGoodsInfoApiConfig>(
defaultAgentWorkflowGoodsInfoApiConfig
);
const [showcaseConfig, setShowcaseConfig] = useState<AgentWorkflowShowcaseConfig>(
defaultAgentWorkflowShowcaseConfig
);
const [psdTextColorConfig, setPsdTextColorConfig] = useState<AgentPsdTextColorConfig>(
defaultAgentPsdTextColorConfig
);
const [psdTemplateSecondaryCategoryConfig, setPsdTemplateSecondaryCategoryConfig] =
useState<AgentPsdTemplateSecondaryCategoryConfig>(
defaultAgentPsdTemplateSecondaryCategoryConfig
);
const [brandModelConfig, setBrandModelConfig] = useState<AgentWorkflowBrandModelConfig>(
defaultAgentWorkflowBrandModelConfig
);
const [isSaving, setIsSaving] = useState(false);
const [uploadingKey, setUploadingKey] = useState<string | null>(null);
const [expandedSteps, setExpandedSteps] = useState<Set<string>>(new Set());
const [expandedWorkflowOptions, setExpandedWorkflowOptions] = useState<Record<string, boolean>>(
{}
);
const [expandedPresetLibraryNodes, setExpandedPresetLibraryNodes] = useState<
Record<string, boolean>
>({});
const [activeTab, setActiveTab] = useState("showcase");
const [brandDraft, setBrandDraft] = useState("");
const [priceBandDraft, setPriceBandDraft] = useState("");
const [selectedPsdTextColorBrand, setSelectedPsdTextColorBrand] = useState("");
const [storeDrafts, setStoreDrafts] = useState<Record<string, string>>({});
const [thirdCategoryDrafts, setThirdCategoryDrafts] = useState<Record<string, string>>({});
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);
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);
const [snapshotDialogOpen, setSnapshotDialogOpen] = useState(false);
const [isCreatingSnapshot, setIsCreatingSnapshot] = useState(false);
const [restoringSnapshotId, setRestoringSnapshotId] = useState<string | null>(null);
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
const brandImportInputRef = useRef<HTMLInputElement | null>(null);
const hasAppliedInitialSettingsRef = useRef(false);
const hasHydratedSettingsRef = useRef(false);
const isApplyingSettingsRef = useRef(false);
const serverDraftBaseSignatureRef = useRef<string | null>(null);
const serverDraftBaseSignaturesRef = useRef<
Record<keyof AgentWorkflowSettingsPayload, string> | null
>(null);
const hasWarnedRemoteWorkflowUpdateRef = useRef(false);
const draftCancelRef = useRef<(() => void) | null>(null);
useEffect(() => {
if (!data) return;
if (hasAppliedInitialSettingsRef.current) return;
hasAppliedInitialSettingsRef.current = true;
hasHydratedSettingsRef.current = false;
isApplyingSettingsRef.current = true;
const serverState = createAgentWorkflowSettingsStateFromPayload(data);
const serverSignature = createAgentWorkflowSettingsStateSignature(serverState);
const serverSignatures = createAgentWorkflowSettingsSectionSignatures(serverState);
serverDraftBaseSignatureRef.current = serverSignature;
serverDraftBaseSignaturesRef.current = serverSignatures;
hasWarnedRemoteWorkflowUpdateRef.current = false;
const draft = readAgentWorkflowSettingsDraft();
let restoredDraft: AgentWorkflowSettingsDraft | null = null;
let nextState: AgentWorkflowSettingsDraftState = serverState;
if (draft) {
if (draft.version === 2 && draft.baseSignature === serverSignature) {
restoredDraft = draft;
nextState = {
agentWorkflowDefaultModelId:
String(draft.state.agentWorkflowDefaultModelId || "").trim() || null,
presetLibraryConfig: normalizeAgentPresetLibraryConfig(draft.state.presetLibraryConfig),
config: normalizeConfigOrdering(normalizeAgentWorkflowUiConfig(draft.state.config)),
materialApiConfig: normalizeAgentWorkflowMaterialApiConfig(
draft.state.materialApiConfig
),
goodsInfoApiConfig: normalizeAgentWorkflowGoodsInfoApiConfig(
draft.state.goodsInfoApiConfig
),
showcaseConfig: normalizeAgentWorkflowShowcaseConfig(draft.state.showcaseConfig),
psdTextColorConfig: normalizeAgentPsdTextColorConfig(draft.state.psdTextColorConfig),
psdTemplateSecondaryCategoryConfig: normalizeAgentPsdTemplateSecondaryCategoryConfig(
draft.state.psdTemplateSecondaryCategoryConfig
),
brandModelConfig: normalizeAgentWorkflowBrandModelConfig(draft.state.brandModelConfig),
};
} else {
preserveAgentWorkflowSettingsDraftForRecovery(draft);
setRecoverableDraftUpdatedAt(draft.updatedAt);
removeAgentWorkflowSettingsDraft();
toast.warning(
draft.version === 1
? "检测到旧版本地草稿,已转存为恢复副本,不会自动覆盖服务器配置"
: "服务器配置已有更新,已转存旧草稿为恢复副本,避免覆盖其他管理员保存的数据"
);
}
}
setAgentWorkflowDefaultModelId(nextState.agentWorkflowDefaultModelId);
setPresetLibraryConfig(nextState.presetLibraryConfig);
setConfig(nextState.config);
setMaterialApiConfig(nextState.materialApiConfig);
setGoodsInfoApiConfig(nextState.goodsInfoApiConfig);
setShowcaseConfig(nextState.showcaseConfig);
setPsdTextColorConfig(nextState.psdTextColorConfig);
setPsdTemplateSecondaryCategoryConfig(nextState.psdTemplateSecondaryCategoryConfig);
setBrandModelConfig(nextState.brandModelConfig);
setExpandedWorkflowOptions({});
setExpandedPresetLibraryNodes({});
if (nextState.config.steps[0]) {
setExpandedSteps(new Set([nextState.config.steps[0].key]));
}
if (restoredDraft) {
setAutoDraftUpdatedAt(restoredDraft.updatedAt);
setAutoDraftStatus("restored");
toast.success("已恢复本地自动保存草稿,确认无误后点击保存配置写入服务器");
} else {
setAutoDraftUpdatedAt(null);
setAutoDraftStatus("idle");
}
const preservedDraft = readPreservedAgentWorkflowSettingsDraftForRecovery();
if (preservedDraft) {
setRecoverableDraftUpdatedAt(preservedDraft.updatedAt);
}
const hydrationTimer = window.setTimeout(() => {
isApplyingSettingsRef.current = false;
hasHydratedSettingsRef.current = true;
}, 0);
return () => window.clearTimeout(hydrationTimer);
}, [data]);
useEffect(() => {
if (!data || !hasHydratedSettingsRef.current) {
return;
}
const latestServerState = createAgentWorkflowSettingsStateFromPayload(data);
const latestServerSignature =
createAgentWorkflowSettingsStateSignature(latestServerState);
const baseSignature = serverDraftBaseSignatureRef.current;
if (
baseSignature &&
latestServerSignature !== baseSignature &&
!hasWarnedRemoteWorkflowUpdateRef.current
) {
hasWarnedRemoteWorkflowUpdateRef.current = true;
toast.warning(
"服务器工作流配置已有新版本。当前页面不会自动覆盖你的编辑,保存时只会提交你改过的部分。"
);
}
}, [data]);
const brandStoreEntries = useMemo(
() =>
config.inputOptions?.brandStoreEntries ||
defaultAgentWorkflowUiConfig.inputOptions?.brandStoreEntries ||
[],
[config.inputOptions?.brandStoreEntries]
);
const priceBandOptions = useMemo(
() =>
config.inputOptions?.priceBands ||
defaultAgentWorkflowUiConfig.inputOptions?.priceBands ||
[],
[config.inputOptions?.priceBands]
);
const categoryLabels = useMemo(
() => getAgentWorkflowCategoryLabels(config),
[config]
);
const workflowConstraintSettings = useMemo(
() => getAgentWorkflowConstraintSettings(config),
[config]
);
const workflowPageFeatureSettings = useMemo(
() => getAgentWorkflowPageFeatureSettings(config),
[config]
);
const workflowBusinessModeSettings = useMemo(
() => getAgentWorkflowBusinessModeSettings(config),
[config]
);
const officialCategoryEntries = useMemo(
() => getAgentWorkflowOfficialCategoryEntries(config),
[config]
);
const officialCategoryKeyLabelMap = useMemo(
() => getAgentWorkflowOfficialCategoryKeyLabels(config),
[config]
);
const officialCategoryChoices = useMemo(
() =>
officialCategoryEntries.flatMap((secondEntry) => {
const secondKey = getAgentWorkflowOfficialSecondCategoryKey(secondEntry.categoryName);
return [
{
key: secondKey,
label: `二级:${secondEntry.categoryName}`,
},
...(secondEntry.thirdCategories || []).map((thirdCategory) => ({
key: getAgentWorkflowOfficialThirdCategoryKey({
secondCategory: secondEntry.categoryName,
thirdCategory,
}),
label: `三级:${secondEntry.categoryName} / ${thirdCategory}`,
})),
].filter((item) => item.key);
}),
[officialCategoryEntries]
);
const workflowImageModelOptions = useMemo(
() =>
(aiModelsData?.models || [])
.filter(
(model) =>
model.supportsImageGen &&
model.isEnabled !== false &&
model.isSubModel !== true
)
.sort((left, right) =>
String(left.displayName || left.modelId || left.id).localeCompare(
String(right.displayName || right.modelId || right.id),
"zh-CN"
)
),
[aiModelsData?.models]
);
const workflowImageModelLabelById = useMemo(() => {
const entries: Array<[string, string]> = workflowImageModelOptions.map((model) => [
model.id,
`${model.displayName}${model.isHidden ? " · 前端隐藏" : ""}${model.modelId ? `(${model.modelId})` : ""}`,
]);
return new Map<string, string>(entries);
}, [workflowImageModelOptions]);
const firstCategoryLabel = categoryLabels.firstCategory;
const secondCategoryLabel = categoryLabels.secondCategory;
const thirdCategoryLabel = categoryLabels.thirdCategory;
const fourthCategoryLabel = categoryLabels.fourthCategory;
const fifthCategoryLabel = categoryLabels.fifthCategory;
const configuredBrands = useMemo(
() => brandStoreEntries.map((entry) => entry.brandName),
[brandStoreEntries]
);
const brandModelBrands = useMemo(() => {
return Array.from(
new Set([
...configuredBrands,
...brandModelConfig.entries.map((entry) => entry.brandName),
])
).sort((left, right) => left.localeCompare(right, "zh-CN"));
}, [brandModelConfig.entries, configuredBrands]);
const psdTextColorBrandOptions = useMemo(
() =>
Array.from(
new Set([
...configuredBrands,
...(psdTextColorConfig.brandConfigs || []).map((entry) => entry.brandName),
])
).sort((left, right) => left.localeCompare(right, "zh-CN")),
[configuredBrands, psdTextColorConfig.brandConfigs]
);
const selectedBrandPsdTextColorConfig = useMemo(
() => resolveAgentPsdTextColorConfigForBrand(psdTextColorConfig, selectedPsdTextColorBrand),
[psdTextColorConfig, selectedPsdTextColorBrand]
);
useEffect(() => {
if (psdTextColorBrandOptions.length === 0) {
if (selectedPsdTextColorBrand) {
setSelectedPsdTextColorBrand("");
}
return;
}
const hasCurrentSelection = psdTextColorBrandOptions.some(
(brandName) => brandName === selectedPsdTextColorBrand
);
if (!hasCurrentSelection) {
setSelectedPsdTextColorBrand(psdTextColorBrandOptions[0] || "");
}
}, [psdTextColorBrandOptions, selectedPsdTextColorBrand]);
const autoDraftState = useMemo<AgentWorkflowSettingsDraftState>(
() => ({
agentWorkflowDefaultModelId,
presetLibraryConfig,
config,
materialApiConfig,
goodsInfoApiConfig,
brandModelConfig,
showcaseConfig,
psdTextColorConfig,
psdTemplateSecondaryCategoryConfig,
}),
[
agentWorkflowDefaultModelId,
presetLibraryConfig,
config,
materialApiConfig,
goodsInfoApiConfig,
brandModelConfig,
showcaseConfig,
psdTextColorConfig,
psdTemplateSecondaryCategoryConfig,
]
);
useEffect(() => {
if (!hasHydratedSettingsRef.current || isApplyingSettingsRef.current) {
return;
}
const draftTimer = window.setTimeout(() => {
const updatedAt = Date.now();
const cancelIdleDraft = scheduleWorkflowIdleTask(() => {
try {
writeAgentWorkflowSettingsDraft({
version: 2,
updatedAt,
baseSignature:
serverDraftBaseSignatureRef.current ||
createAgentWorkflowSettingsStateSignature(autoDraftState),
baseSignatures:
serverDraftBaseSignaturesRef.current ||
createAgentWorkflowSettingsSectionSignatures(autoDraftState),
state: autoDraftState,
});
startTransition(() => {
setAutoDraftUpdatedAt(updatedAt);
setAutoDraftStatus("saved");
});
} catch {
toast.error("本地自动保存失败,请及时手动保存配置");
}
});
draftCancelRef.current = cancelIdleDraft;
}, 1200);
return () => {
window.clearTimeout(draftTimer);
draftCancelRef.current?.();
draftCancelRef.current = null;
};
}, [autoDraftState]);
const sortedSteps = useMemo(
() => [...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(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]
);
const workflowStepAnalyses = useMemo(
() => buildWorkflowStepAnalyses(deferredWorkflowSteps || []),
[deferredWorkflowSteps]
);
const workflowStepAnalysisLookup = useMemo(
() => 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) => ({
option,
stepKey: option.key,
stepTitle: option.buttonName || option.key,
path: option.buttonName || option.key,
})),
[deferredWorkflowSteps]
);
const workflowOptionGapDetails = useMemo(
() =>
buildWorkflowOptionGapDetails(deferredWorkflowSteps || []).sort(
(left, right) =>
right.missingFields.length - left.missingFields.length ||
right.depth - left.depth ||
left.stepPath.localeCompare(right.stepPath, "zh-CN") ||
left.path.localeCompare(right.path, "zh-CN")
),
[deferredWorkflowSteps]
);
const workflowDashboardMetrics = useMemo(() => {
const levelCounts = new Map<number, number>();
let totalCards = 0;
let promptCards = 0;
let disabledCards = 0;
let mutualExclusionCards = 0;
for (const item of workflowStepAnalyses) {
levelCounts.set(item.depth, (levelCounts.get(item.depth) || 0) + 1);
totalCards += item.optionStats.total;
promptCards += item.optionStats.withPrompt;
disabledCards += item.optionStats.disabled;
mutualExclusionCards += item.optionStats.withMutualExclusion;
}
const emptyOwnContentSteps = workflowStepAnalyses.filter((item) => !item.hasOwnContent);
const emptyBranchSteps = workflowStepAnalyses.filter((item) => !item.hasSubtreeContent);
const leafEmptySteps = workflowStepAnalyses.filter(
(item) => item.childStepCount === 0 && !item.hasOwnContent
);
const denseSteps = [...workflowStepAnalyses]
.sort(
(left, right) =>
right.subtreeOptionCount +
right.subtreeStepCount -
(left.subtreeOptionCount + left.subtreeStepCount)
)
.slice(0, 5);
const deepestSteps = [...workflowStepAnalyses]
.sort((left, right) => right.depth - left.depth)
.slice(0, 5);
return {
totalSteps: workflowStepAnalyses.length,
totalCards,
promptCards,
disabledCards,
mutualExclusionCards,
missingCardDetails: workflowOptionGapDetails,
missingPromptCards: workflowOptionGapDetails.filter((item) =>
item.missingFields.includes("Prompt")
).length,
fullyEmptyCards: workflowOptionGapDetails.filter(
(item) =>
item.missingFields.includes("Prompt") &&
item.missingFields.includes("说明") &&
item.missingFields.includes("预览图")
).length,
maxDepth: workflowStepAnalyses.reduce((max, item) => Math.max(max, item.depth), 0),
promptCompletionRate: totalCards > 0 ? Math.round((promptCards / totalCards) * 100) : 0,
levelCounts: Array.from(levelCounts.entries()).sort(([left], [right]) => left - right),
emptyOwnContentSteps,
emptyBranchSteps,
leafEmptySteps,
denseSteps,
deepestSteps,
};
}, [workflowOptionGapDetails, workflowStepAnalyses]);
const canPasteCopiedWorkflowStepAsRoot = Boolean(
copiedWorkflowStep &&
getWorkflowStepSubtreeDepth(copiedWorkflowStep) <= AGENT_WORKFLOW_MAX_TREE_DEPTH
);
const updateInputOptions = (nextEntries: AgentWorkflowBrandStoreEntry[]) => {
setConfig((prev) => ({
...prev,
inputOptions: buildWorkflowInputOptionsFromBrandEntries(
nextEntries,
prev.inputOptions?.priceBands || [],
prev.inputOptions?.categoryLabels || categoryLabels
),
}));
};
const normalizeInputOptionValue = (value: string) =>
value
.replace(/\s+/g, " ")
.trim();
const handleCategoryLabelChange = (
key: keyof AgentWorkflowCategoryLabels,
value: string
) => {
const nextValue = normalizeInputOptionValue(value).slice(0, 24);
setConfig((prev) => ({
...prev,
inputOptions: buildWorkflowInputOptionsFromBrandEntries(
prev.inputOptions?.brandStoreEntries || [],
prev.inputOptions?.priceBands || [],
{
...(prev.inputOptions?.categoryLabels || categoryLabels),
[key]: nextValue || defaultAgentWorkflowCategoryLabels[key],
}
),
}));
};
const handleAddBrandEntry = () => {
const nextValue = normalizeInputOptionValue(brandDraft);
if (!nextValue) {
toast.error(`请输入${firstCategoryLabel}名称`);
return;
}
if (brandStoreEntries.some((item) => item.brandName.toLowerCase() === nextValue.toLowerCase())) {
toast.error(`该${firstCategoryLabel}已存在`);
return;
}
updateInputOptions([
...brandStoreEntries,
{
brandName: nextValue,
stores: [],
storeEntries: [],
sortOrder: brandStoreEntries.length,
},
]);
setBrandDraft("");
};
const handleRemoveBrandEntry = (brandName: string) => {
updateInputOptions(brandStoreEntries.filter((entry) => entry.brandName !== brandName));
setStoreDrafts((prev) => {
const next = { ...prev };
delete next[brandName];
return next;
});
setThirdCategoryDrafts((prev) => {
const next = { ...prev };
Object.keys(next).forEach((key) => {
if (key.startsWith(`${brandName}::`)) {
delete next[key];
}
});
return next;
});
setFourthCategoryDrafts((prev) => {
const next = { ...prev };
Object.keys(next).forEach((key) => {
if (key.startsWith(`${brandName}::`)) {
delete next[key];
}
});
return next;
});
setFifthCategoryDrafts((prev) => {
const next = { ...prev };
Object.keys(next).forEach((key) => {
if (key.startsWith(`${brandName}::`)) {
delete next[key];
}
});
return next;
});
};
const handleAddStoreToBrand = (brandName: string) => {
const draft = storeDrafts[brandName] || "";
const nextValue = normalizeInputOptionValue(draft);
if (!nextValue) {
toast.error(`请输入${secondCategoryLabel}名称`);
return;
}
const targetEntry = brandStoreEntries.find((entry) => entry.brandName === brandName);
if (
targetEntry?.storeEntries?.some(
(storeEntry) => storeEntry.storeName.toLowerCase() === nextValue.toLowerCase()
) ||
targetEntry?.stores.some((store) => store.toLowerCase() === nextValue.toLowerCase())
) {
toast.error(`该${secondCategoryLabel}已存在`);
return;
}
updateInputOptions(
brandStoreEntries.map((entry) => {
if (entry.brandName !== brandName) return entry;
return {
...entry,
stores: [...entry.stores, nextValue],
storeEntries: [
...(entry.storeEntries || []).map((storeEntry, storeIndex) => ({
...storeEntry,
sortOrder: storeIndex,
})),
{
storeName: nextValue,
thirdCategories: [],
thirdCategoryEntries: [],
sortOrder: (entry.storeEntries || []).length,
},
],
};
})
);
setStoreDrafts((prev) => ({
...prev,
[brandName]: "",
}));
};
const handleRemoveStoreFromBrand = (brandName: string, storeName: string) => {
updateInputOptions(
brandStoreEntries.map((entry) =>
entry.brandName === brandName
? {
...entry,
stores: entry.stores.filter((store) => store !== storeName),
storeEntries: (entry.storeEntries || []).filter(
(storeEntry) => storeEntry.storeName !== storeName
),
}
: entry
)
);
setThirdCategoryDrafts((prev) => {
const next = { ...prev };
Object.keys(next).forEach((key) => {
if (key.startsWith(`${brandName}::${storeName}`)) {
delete next[key];
}
});
return next;
});
setFourthCategoryDrafts((prev) => {
const next = { ...prev };
Object.keys(next).forEach((key) => {
if (key.startsWith(`${brandName}::${storeName}`)) {
delete next[key];
}
});
return next;
});
setFifthCategoryDrafts((prev) => {
const next = { ...prev };
Object.keys(next).forEach((key) => {
if (key.startsWith(`${brandName}::${storeName}`)) {
delete next[key];
}
});
return next;
});
};
const handleClearBrandTree = () => {
if (brandStoreEntries.length === 0) {
toast.error("当前没有可清空的层级树数据");
return;
}
const confirmed = window.confirm(
`确认清空当前所有${firstCategoryLabel}/${secondCategoryLabel}及分类层级数据吗?此操作不会影响价格带和其它系统设置。`
);
if (!confirmed) {
return;
}
updateInputOptions([]);
setBrandDraft("");
setStoreDrafts({});
setThirdCategoryDrafts({});
setFourthCategoryDrafts({});
setFifthCategoryDrafts({});
toast.success("层级树数据已清空,可重新导入表格");
};
const handleAddThirdCategoryToStore = (brandName: string, storeName: string) => {
const draftKey = createStoreDraftKey(brandName, storeName);
const nextValue = normalizeInputOptionValue(thirdCategoryDrafts[draftKey] || "");
if (!nextValue) {
toast.error(`请输入${thirdCategoryLabel}名称`);
return;
}
const targetEntry = brandStoreEntries.find((entry) => entry.brandName === brandName);
const targetStoreEntry = targetEntry?.storeEntries?.find(
(storeEntry) => storeEntry.storeName === storeName
);
if (
(targetStoreEntry?.thirdCategoryEntries || []).some(
(thirdEntry) => thirdEntry.categoryName.toLowerCase() === nextValue.toLowerCase()
) ||
targetStoreEntry?.thirdCategories.some(
(thirdCategory) => thirdCategory.toLowerCase() === nextValue.toLowerCase()
)
) {
toast.error(`该${thirdCategoryLabel}已存在`);
return;
}
updateInputOptions(
brandStoreEntries.map((entry) => {
if (entry.brandName !== brandName) return entry;
return {
...entry,
storeEntries: (entry.storeEntries || []).map((storeEntry) =>
storeEntry.storeName === storeName
? {
...storeEntry,
thirdCategories: [...(storeEntry.thirdCategories || []), nextValue],
thirdCategoryEntries: [
...((storeEntry.thirdCategoryEntries || []).map((thirdEntry, thirdIndex) => ({
...thirdEntry,
sortOrder: thirdIndex,
})) || []),
{
categoryName: nextValue,
fourthCategories: [],
fourthCategoryEntries: [],
sortOrder: (storeEntry.thirdCategoryEntries || []).length,
},
],
}
: storeEntry
),
};
})
);
setThirdCategoryDrafts((prev) => ({
...prev,
[draftKey]: "",
}));
};
const handleRemoveThirdCategoryFromStore = (
brandName: string,
storeName: string,
thirdCategory: string
) => {
updateInputOptions(
brandStoreEntries.map((entry) => {
if (entry.brandName !== brandName) return entry;
return {
...entry,
storeEntries: (entry.storeEntries || []).map((storeEntry) =>
storeEntry.storeName === storeName
? {
...storeEntry,
thirdCategories: (storeEntry.thirdCategories || []).filter(
(item) => item !== thirdCategory
),
thirdCategoryEntries: (storeEntry.thirdCategoryEntries || []).filter(
(item) => item.categoryName !== thirdCategory
),
}
: storeEntry
),
};
})
);
setFourthCategoryDrafts((prev) => {
const next = { ...prev };
Object.keys(next).forEach((key) => {
if (key.startsWith(`${brandName}::${storeName}::${thirdCategory}`)) {
delete next[key];
}
});
return next;
});
setFifthCategoryDrafts((prev) => {
const next = { ...prev };
Object.keys(next).forEach((key) => {
if (key.startsWith(`${brandName}::${storeName}::${thirdCategory}`)) {
delete next[key];
}
});
return next;
});
};
const handleAddFourthCategoryToThirdCategory = (
brandName: string,
storeName: string,
thirdCategory: string
) => {
const draftKey = createThirdCategoryDraftKey(brandName, storeName, thirdCategory);
const nextValue = normalizeInputOptionValue(fourthCategoryDrafts[draftKey] || "");
if (!nextValue) {
toast.error(`请输入${fourthCategoryLabel}名称`);
return;
}
const targetEntry = brandStoreEntries.find((entry) => entry.brandName === brandName);
const targetStoreEntry = targetEntry?.storeEntries?.find(
(storeEntry) => storeEntry.storeName === storeName
);
const targetThirdEntry = targetStoreEntry?.thirdCategoryEntries?.find(
(thirdEntry) => thirdEntry.categoryName === thirdCategory
);
if (
(targetThirdEntry?.fourthCategoryEntries || []).some(
(fourthEntry) => fourthEntry.categoryName.toLowerCase() === nextValue.toLowerCase()
) ||
(targetThirdEntry?.fourthCategories || []).some(
(fourthCategory) => fourthCategory.toLowerCase() === nextValue.toLowerCase()
)
) {
toast.error(`该${fourthCategoryLabel}已存在`);
return;
}
updateInputOptions(
brandStoreEntries.map((entry) => {
if (entry.brandName !== brandName) return entry;
return {
...entry,
storeEntries: (entry.storeEntries || []).map((storeEntry) =>
storeEntry.storeName === storeName
? {
...storeEntry,
thirdCategoryEntries: (storeEntry.thirdCategoryEntries || []).map((thirdEntry) =>
thirdEntry.categoryName === thirdCategory
? {
...thirdEntry,
fourthCategories: [...(thirdEntry.fourthCategories || []), nextValue],
fourthCategoryEntries: [
...((thirdEntry.fourthCategoryEntries || []).map((fourthEntry, fourthIndex) => ({
...fourthEntry,
sortOrder: fourthIndex,
})) || []),
{
categoryName: nextValue,
fifthCategories: [],
sortOrder: (thirdEntry.fourthCategoryEntries || []).length,
},
],
}
: thirdEntry
),
}
: storeEntry
),
};
})
);
setFourthCategoryDrafts((prev) => ({
...prev,
[draftKey]: "",
}));
};
const handleRemoveFourthCategoryFromThirdCategory = (
brandName: string,
storeName: string,
thirdCategory: string,
fourthCategory: string
) => {
updateInputOptions(
brandStoreEntries.map((entry) => {
if (entry.brandName !== brandName) return entry;
return {
...entry,
storeEntries: (entry.storeEntries || []).map((storeEntry) =>
storeEntry.storeName === storeName
? {
...storeEntry,
thirdCategoryEntries: (storeEntry.thirdCategoryEntries || []).map((thirdEntry) =>
thirdEntry.categoryName === thirdCategory
? {
...thirdEntry,
fourthCategories: (thirdEntry.fourthCategories || []).filter(
(item) => item !== fourthCategory
),
fourthCategoryEntries: (thirdEntry.fourthCategoryEntries || []).filter(
(item) => item.categoryName !== fourthCategory
),
}
: thirdEntry
),
}
: storeEntry
),
};
})
);
setFifthCategoryDrafts((prev) => {
const next = { ...prev };
Object.keys(next).forEach((key) => {
if (key.startsWith(`${brandName}::${storeName}::${thirdCategory}::${fourthCategory}`)) {
delete next[key];
}
});
return next;
});
};
const handleAddFifthCategoryToFourthCategory = (
brandName: string,
storeName: string,
thirdCategory: string,
fourthCategory: string
) => {
const draftKey = createFourthCategoryDraftKey(
brandName,
storeName,
thirdCategory,
fourthCategory
);
const nextValue = normalizeInputOptionValue(fifthCategoryDrafts[draftKey] || "");
if (!nextValue) {
toast.error(`请输入${fifthCategoryLabel}名称`);
return;
}
const targetEntry = brandStoreEntries.find((entry) => entry.brandName === brandName);
const targetStoreEntry = targetEntry?.storeEntries?.find(
(storeEntry) => storeEntry.storeName === storeName
);
const targetThirdEntry = targetStoreEntry?.thirdCategoryEntries?.find(
(thirdEntry) => thirdEntry.categoryName === thirdCategory
);
const targetFourthEntry = targetThirdEntry?.fourthCategoryEntries?.find(
(fourthEntry) => fourthEntry.categoryName === fourthCategory
);
if (
targetFourthEntry?.fifthCategories.some(
(fifthCategory) => fifthCategory.toLowerCase() === nextValue.toLowerCase()
)
) {
toast.error(`该${fifthCategoryLabel}已存在`);
return;
}
updateInputOptions(
brandStoreEntries.map((entry) => {
if (entry.brandName !== brandName) return entry;
return {
...entry,
storeEntries: (entry.storeEntries || []).map((storeEntry) =>
storeEntry.storeName === storeName
? {
...storeEntry,
thirdCategoryEntries: (storeEntry.thirdCategoryEntries || []).map((thirdEntry) =>
thirdEntry.categoryName === thirdCategory
? {
...thirdEntry,
fourthCategoryEntries: (thirdEntry.fourthCategoryEntries || []).map(
(fourthEntry) =>
fourthEntry.categoryName === fourthCategory
? {
...fourthEntry,
fifthCategories: [...(fourthEntry.fifthCategories || []), nextValue],
}
: fourthEntry
),
}
: thirdEntry
),
}
: storeEntry
),
};
})
);
setFifthCategoryDrafts((prev) => ({
...prev,
[draftKey]: "",
}));
};
const handleRemoveFifthCategoryFromFourthCategory = (
brandName: string,
storeName: string,
thirdCategory: string,
fourthCategory: string,
fifthCategory: string
) => {
updateInputOptions(
brandStoreEntries.map((entry) => {
if (entry.brandName !== brandName) return entry;
return {
...entry,
storeEntries: (entry.storeEntries || []).map((storeEntry) =>
storeEntry.storeName === storeName
? {
...storeEntry,
thirdCategoryEntries: (storeEntry.thirdCategoryEntries || []).map((thirdEntry) =>
thirdEntry.categoryName === thirdCategory
? {
...thirdEntry,
fourthCategoryEntries: (thirdEntry.fourthCategoryEntries || []).map(
(fourthEntry) =>
fourthEntry.categoryName === fourthCategory
? {
...fourthEntry,
fifthCategories: (fourthEntry.fifthCategories || []).filter(
(item) => item !== fifthCategory
),
}
: fourthEntry
),
}
: thirdEntry
),
}
: storeEntry
),
};
})
);
};
const handleAddPriceBand = () => {
const nextValue = normalizeInputOptionValue(priceBandDraft);
if (!nextValue) {
toast.error("请输入价格带区间");
return;
}
if (priceBandOptions.some((item) => item.toLowerCase() === nextValue.toLowerCase())) {
toast.error("该价格带已存在");
return;
}
setConfig((prev) => ({
...prev,
inputOptions: buildWorkflowInputOptionsFromBrandEntries(
prev.inputOptions?.brandStoreEntries || [],
[...(prev.inputOptions?.priceBands || []), nextValue],
prev.inputOptions?.categoryLabels || categoryLabels
),
}));
setPriceBandDraft("");
};
const handleRemovePriceBand = (priceBand: string) => {
setConfig((prev) => ({
...prev,
inputOptions: buildWorkflowInputOptionsFromBrandEntries(
prev.inputOptions?.brandStoreEntries || [],
(prev.inputOptions?.priceBands || []).filter((item) => item !== priceBand),
prev.inputOptions?.categoryLabels || categoryLabels
),
}));
};
const enabledStepsCount = useMemo(
() => flattenAgentWorkflowSteps(sortedSteps).filter(({ step }) => step.enabled).length,
[sortedSteps]
);
const totalOptionsCount = useMemo(
() =>
flattenAgentWorkflowSteps(sortedSteps).reduce(
(acc, { step }) => acc + step.options.filter((option) => option.enabled).length,
0
),
[sortedSteps]
);
const enabledPresetLibrarySectionCount = useMemo(
() => sortedPresetLibrarySections.filter((section) => section.enabled).length,
[sortedPresetLibrarySections]
);
const totalPresetLibraryNodeCount = useMemo(
() =>
sortedPresetLibrarySections.reduce(
(total, section) => total + countPresetLibraryNodes(section.categories || []),
0
),
[sortedPresetLibrarySections]
);
const updatePresetLibrarySection = (
sectionId: string,
updater: (section: AgentPresetLibrarySection) => AgentPresetLibrarySection
) => {
setPresetLibraryConfig((prev) => ({
sections: prev.sections
.map((section) => (section.id === sectionId ? updater(section) : section))
.sort((left, right) => left.sortOrder - right.sortOrder)
.map((section, index) => ({
...section,
sortOrder: index,
})),
}));
};
const handleAddPresetLibrarySection = () => {
setPresetLibraryConfig((prev) => {
const nextSection = createPresetLibrarySectionDraft(
prev.sections.length,
prev.sections.map((section) => section.id)
);
return {
sections: [...prev.sections, nextSection].map((section, index) => ({
...section,
sortOrder: index,
})),
};
});
setActiveTab("categories");
};
const handleRemovePresetLibrarySection = (sectionId: string) => {
setPresetLibraryConfig((prev) => ({
sections: prev.sections
.filter((section) => section.id !== sectionId)
.map((section, index) => ({
...section,
sortOrder: index,
})),
}));
};
const handleAddPresetLibraryNode = (sectionId: string, parentNodeId?: string) => {
setPresetLibraryConfig((prev) => {
const targetSection = prev.sections.find((section) => section.id === sectionId);
if (!targetSection) return prev;
const existingNodeIds = collectPresetLibraryNodeIds(targetSection.categories || []);
const nextSections = prev.sections.map((section) => {
if (section.id !== sectionId) return section;
if (!parentNodeId) {
return {
...section,
categories: [
...(section.categories || []),
createPresetLibraryNodeDraft(1, (section.categories || []).length, existingNodeIds),
].map((node, index) => ({
...node,
sortOrder: index,
})),
};
}
const resolveDepth = (
nodes: AgentPresetLibraryCategoryNode[],
depth: number
): number | null => {
for (const node of nodes) {
if (node.id === parentNodeId) return depth;
const childDepth = resolveDepth(node.children || [], depth + 1);
if (childDepth) return childDepth;
}
return null;
};
const parentNode = findPresetLibraryNodeById(section.categories || [], parentNodeId);
const parentDepth = resolveDepth(section.categories || [], 1);
if (!parentNode || !parentDepth || parentDepth >= AGENT_PRESET_LIBRARY_MAX_DEPTH) {
return section;
}
return {
...section,
categories: updatePresetLibraryNodeTree(
section.categories || [],
parentNodeId,
(node) => ({
...node,
children: [
...(node.children || []),
createPresetLibraryNodeDraft(
parentDepth + 1,
(node.children || []).length,
existingNodeIds
),
].map((child, index) => ({
...child,
sortOrder: index,
})),
})
),
};
});
return { sections: nextSections };
});
setActiveTab("categories");
};
const handleRemovePresetLibraryNode = (sectionId: string, nodeId: string) => {
setPresetLibraryConfig((prev) => ({
sections: prev.sections.map((section) => {
if (section.id !== sectionId) return section;
return {
...section,
categories: removePresetLibraryNodeTree(section.categories || [], nodeId).map(
(node, index) => ({
...node,
sortOrder: index,
})
),
};
}),
}));
};
const updatePresetLibraryNode = (
sectionId: string,
nodeId: string,
updater: (node: AgentPresetLibraryCategoryNode) => AgentPresetLibraryCategoryNode
) => {
setPresetLibraryConfig((prev) => ({
sections: prev.sections.map((section) => {
if (section.id !== sectionId) return section;
return {
...section,
categories: updatePresetLibraryNodeTree(section.categories || [], nodeId, updater),
};
}),
}));
};
const toggleStepExpansion = (stepKey: string) => {
setExpandedSteps((prev) => {
const next = new Set(prev);
if (next.has(stepKey)) {
next.delete(stepKey);
} else {
next.add(stepKey);
}
return next;
});
};
const isWorkflowOptionExpanded = (optionKey: string, depth: number) =>
expandedWorkflowOptions[optionKey] ?? depth === 1;
const toggleWorkflowOptionExpansion = (optionKey: string, depth: number) => {
setExpandedWorkflowOptions((prev) => ({
...prev,
[optionKey]: !(prev[optionKey] ?? depth === 1),
}));
};
const isPresetLibraryNodeExpanded = (nodeId: string, depth: number) =>
expandedPresetLibraryNodes[nodeId] ?? depth === 1;
const togglePresetLibraryNodeExpansion = (nodeId: string, depth: number) => {
setExpandedPresetLibraryNodes((prev) => ({
...prev,
[nodeId]: !(prev[nodeId] ?? depth === 1),
}));
};
const updateStep = (
stepKey: string,
updater: (step: AgentWorkflowUiConfig["steps"][number]) => AgentWorkflowUiConfig["steps"][number]
) => {
setConfig((prev) => ({
...prev,
steps: updateWorkflowStepTree(prev.steps, stepKey, updater),
}));
};
const updateOption = (
stepKey: string,
optionKey: string,
updater: (
option: AgentWorkflowUiConfig["steps"][number]["options"][number]
) => AgentWorkflowUiConfig["steps"][number]["options"][number]
) => {
updateStep(stepKey, (step) => ({
...step,
options: updateWorkflowOptionTree(step.options, optionKey, updater),
}));
};
const handleAddStep = () => {
setConfig((prev) => {
const existingStepKeys = collectWorkflowStepKeys(prev.steps);
const newStep = createWorkflowStepDraft(1, prev.steps.length, existingStepKeys);
setExpandedSteps((prevExpanded) => new Set([...prevExpanded, newStep.key]));
return normalizeConfigOrdering({
...prev,
steps: [...prev.steps, newStep],
});
});
setActiveTab("workflow");
};
const handleAddChildStep = (parentStepKey: string, parentDepth: number) => {
if (parentDepth >= AGENT_WORKFLOW_MAX_TREE_DEPTH) {
toast.error(`模块层级最多支持 ${AGENT_WORKFLOW_MAX_TREE_DEPTH} 级`);
return;
}
let newStepKey = "";
setConfig((prev) => {
const parentStep = findAgentWorkflowStepByKey(prev.steps, parentStepKey);
if (!parentStep) {
return prev;
}
const existingStepKeys = collectWorkflowStepKeys(prev.steps);
const newStep = createWorkflowStepDraft(
parentDepth + 1,
(parentStep.children || []).length,
existingStepKeys
);
newStepKey = newStep.key;
return normalizeConfigOrdering({
...prev,
steps: updateWorkflowStepTree(prev.steps, parentStepKey, (step) => ({
...step,
children: [...(step.children || []), newStep],
})),
});
});
if (newStepKey) {
setExpandedSteps((prevExpanded) => new Set([...prevExpanded, parentStepKey, newStepKey]));
}
};
const handleRequestRemoveStep = (stepKey: string) => {
setPendingDeleteStepKey(stepKey);
setDeleteStepPassword("");
setDeleteStepDialogOpen(true);
};
const handleRemoveStep = (stepKey: string) => {
const targetStep = findAgentWorkflowStepByKey(config.steps, stepKey);
setConfig((prev) =>
normalizeConfigOrdering({
...prev,
steps: removeWorkflowStepTree(prev.steps, stepKey),
})
);
setExpandedSteps((prev) => {
const next = new Set(prev);
for (const key of targetStep ? collectDescendantStepKeys(targetStep) : [stepKey]) {
next.delete(key);
}
return next;
});
};
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,
position: WorkflowStepDropPosition
) => {
if (!sourceStepKey) {
return false;
}
const sourceStep = findAgentWorkflowStepByKey(config.steps, sourceStepKey);
if (!sourceStep || sourceStepKey === targetStepKey) {
return false;
}
if (isWorkflowStepTargetInsideSource(sourceStep, targetStepKey)) {
return false;
}
const insertionDepth = getWorkflowStepInsertionDepth(config.steps, targetStepKey, position);
if (!insertionDepth) {
return false;
}
return insertionDepth + getWorkflowStepSubtreeDepth(sourceStep) - 1 <= AGENT_WORKFLOW_MAX_TREE_DEPTH;
};
const handleMoveWorkflowStep = (
sourceStepKey: string,
targetStepKey: string | null,
position: WorkflowStepDropPosition
) => {
let moved = false;
let failureMessage = "无法移动到这个位置";
setConfig((prev) => {
const sourceStep = findAgentWorkflowStepByKey(prev.steps, sourceStepKey);
if (!sourceStep) {
failureMessage = "找不到要移动的模块";
return prev;
}
if (sourceStepKey === targetStepKey || isWorkflowStepTargetInsideSource(sourceStep, targetStepKey)) {
failureMessage = "不能把模块移动到自己或自己的子模块下面";
return prev;
}
const insertionDepth = getWorkflowStepInsertionDepth(prev.steps, targetStepKey, position);
if (!insertionDepth) {
failureMessage = "找不到目标模块";
return prev;
}
if (
insertionDepth + getWorkflowStepSubtreeDepth(sourceStep) - 1 >
AGENT_WORKFLOW_MAX_TREE_DEPTH
) {
failureMessage = `模块层级最多支持 ${AGENT_WORKFLOW_MAX_TREE_DEPTH} 级`;
return prev;
}
const removalResult = extractWorkflowStepFromTree(prev.steps, sourceStepKey);
if (!removalResult.extractedStep) {
return prev;
}
const insertionResult = insertWorkflowStepIntoTree(
removalResult.steps,
targetStepKey,
position,
removalResult.extractedStep
);
if (!insertionResult.inserted) {
failureMessage = "没有找到可放置的目标位置";
return prev;
}
moved = true;
return normalizeConfigOrdering({
...prev,
steps: insertionResult.steps,
});
});
if (!moved) {
toast.error(failureMessage);
return;
}
setExpandedSteps((prev) => {
const next = new Set(prev);
next.add(sourceStepKey);
if (targetStepKey && position === "inside") {
next.add(targetStepKey);
}
return next;
});
};
const handlePromoteWorkflowStep = (stepKey: string, parentStepKey: string | null) => {
if (!parentStepKey) {
toast.error("一级模块不能继续升一级");
return;
}
handleMoveWorkflowStep(stepKey, parentStepKey, "after");
};
const handleIndentWorkflowStep = (stepKey: string, previousSiblingStepKey: string | null) => {
if (!previousSiblingStepKey) {
toast.error("当前模块前面没有可作为父级的同级模块");
return;
}
handleMoveWorkflowStep(stepKey, previousSiblingStepKey, "inside");
};
const handleWorkflowStepDragStart = (
event: DragEvent<HTMLElement>,
stepKey: string
) => {
setDraggedStepKey(stepKey);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(WORKFLOW_STEP_DRAG_DATA_TYPE, stepKey);
};
const handleWorkflowStepDragOver = (
event: DragEvent<HTMLElement>,
targetStepKey: string | null,
position: WorkflowStepDropPosition
) => {
const sourceStepKey = draggedStepKey;
if (!sourceStepKey || !canMoveWorkflowStep(sourceStepKey, targetStepKey, position)) {
return;
}
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "move";
setStepDropTarget({ stepKey: targetStepKey, position });
};
const handleWorkflowStepDrop = (
event: DragEvent<HTMLElement>,
targetStepKey: string | null,
position: WorkflowStepDropPosition
) => {
event.preventDefault();
event.stopPropagation();
const sourceStepKey =
draggedStepKey || event.dataTransfer.getData(WORKFLOW_STEP_DRAG_DATA_TYPE);
setStepDropTarget(null);
setDraggedStepKey(null);
if (!sourceStepKey) {
return;
}
handleMoveWorkflowStep(sourceStepKey, targetStepKey, position);
};
const handleWorkflowStepDragEnd = () => {
setDraggedStepKey(null);
setStepDropTarget(null);
};
const handleCopyWorkflowStep = (stepKey: string) => {
const targetStep = findAgentWorkflowStepByKey(config.steps, stepKey);
if (!targetStep) {
toast.error("找不到要复制的模块");
return;
}
setCopiedWorkflowStep(cloneWorkflowStepSnapshot(targetStep));
toast.success(`已复制模块「${targetStep.title || targetStep.key}」及其子模块`);
};
const handlePasteWorkflowStep = (targetStepKey: string | null) => {
if (!copiedWorkflowStep) {
toast.error("请先复制一个模块");
return;
}
let pastedStepKey = "";
let failureMessage = "无法粘贴到这个位置";
setConfig((prev) => {
const insertionDepth = getWorkflowStepInsertionDepth(prev.steps, targetStepKey, "inside");
if (!insertionDepth) {
failureMessage = "找不到目标模块";
return prev;
}
if (
insertionDepth + getWorkflowStepSubtreeDepth(copiedWorkflowStep) - 1 >
AGENT_WORKFLOW_MAX_TREE_DEPTH
) {
failureMessage = `粘贴后会超过 ${AGENT_WORKFLOW_MAX_TREE_DEPTH} 级模块限制`;
return prev;
}
const pastedStep = cloneWorkflowStepForPaste(
copiedWorkflowStep,
new Set(collectWorkflowStepKeys(prev.steps)),
new Set(collectWorkflowOptionKeysFromSteps(prev.steps))
);
pastedStepKey = pastedStep.key;
const insertionResult = insertWorkflowStepIntoTree(
prev.steps,
targetStepKey,
"inside",
pastedStep
);
if (!insertionResult.inserted) {
return prev;
}
return normalizeConfigOrdering({
...prev,
steps: insertionResult.steps,
});
});
if (!pastedStepKey) {
toast.error(failureMessage);
return;
}
setExpandedSteps((prev) => {
const next = new Set(prev);
next.add(pastedStepKey);
if (targetStepKey) {
next.add(targetStepKey);
}
return next;
});
toast.success("已粘贴模块副本");
};
const handleAddOption = (stepKey: string, parentOptionKey?: string) => {
updateStep(stepKey, (step) => {
const existingOptionKeys = collectWorkflowOptionKeys(step.options || []);
if (!parentOptionKey) {
return {
...step,
options: normalizeAgentWorkflowOptionOrdering([
...step.options,
createWorkflowOptionDraft(step.key, step.options.length, existingOptionKeys),
]),
};
}
const parentOption = findAgentWorkflowOptionByKey(step.options || [], parentOptionKey);
const parentDepth = flattenAgentWorkflowOptionTree(step.options || []).find(
({ option }) => option.key === parentOptionKey
)?.depth;
if (!parentOption || !parentDepth || parentDepth >= AGENT_WORKFLOW_MAX_OPTION_DEPTH) {
return step;
}
return {
...step,
options: updateWorkflowOptionTree(step.options || [], parentOptionKey, (option) => ({
...option,
children: normalizeAgentWorkflowOptionOrdering([
...(option.children || []),
createWorkflowOptionDraft(
step.key,
(option.children || []).length,
existingOptionKeys
),
]),
})),
};
});
};
const handleRemoveOption = (stepKey: string, optionKey: string) => {
updateStep(stepKey, (step) => ({
...step,
options: normalizeAgentWorkflowOptionOrdering(
removeWorkflowOptionTree(step.options, optionKey)
),
}));
};
const handleMoveRootOption = (stepKey: string, optionKey: string, direction: "up" | "down") => {
updateStep(stepKey, (step) => {
const sortedRootOptions = [...(step.options || [])].sort(
(left, right) => left.sortOrder - right.sortOrder
);
const currentIndex = sortedRootOptions.findIndex((option) => option.key === optionKey);
if (currentIndex < 0) {
return step;
}
const targetIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
if (targetIndex < 0 || targetIndex >= sortedRootOptions.length) {
return step;
}
return {
...step,
options: moveArrayItem(sortedRootOptions, currentIndex, targetIndex).map(
(option, index) => ({
...option,
sortOrder: index,
})
),
};
});
};
const handleMoveTopLevelStep = (stepKey: string, direction: "up" | "down") => {
setConfig((prev) => {
const sortedRootSteps = [...prev.steps].sort((left, right) => left.sortOrder - right.sortOrder);
const currentIndex = sortedRootSteps.findIndex((step) => step.key === stepKey);
if (currentIndex < 0) {
return prev;
}
const targetIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
if (targetIndex < 0 || targetIndex >= sortedRootSteps.length) {
return prev;
}
return normalizeConfigOrdering({
...prev,
steps: moveArrayItem(sortedRootSteps, currentIndex, targetIndex).map((step, index) => ({
...step,
sortOrder: index,
})),
});
});
};
const handleUploadPreview = async (stepKey: string, optionKey: string, file: File) => {
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
return;
}
const refKey = `${stepKey}:${optionKey}`;
try {
setUploadingKey(refKey);
const formData = new FormData();
formData.append("file", file);
formData.append("purpose", "agent-workflow-preview");
const response = await fetchUploadWithRetry(formData);
const result = await response.json();
if (!response.ok || !result?.url) {
throw new Error(result?.error || "上传失败");
}
updateOption(stepKey, optionKey, (option) => ({
...option,
previewImageUrl: result.previewUrl || result.thumbnailUrl || result.url,
previewImageSourceUrl:
result.previewImageSourceUrl || result.originalUrl || result.previewUrl || result.url,
}));
toast.success("预览图上传成功");
} catch (error) {
toast.error(error instanceof Error ? error.message : "预览图上传失败");
} finally {
setUploadingKey(null);
}
};
const updateBusinessMode = (
modeKey: AgentWorkflowBusinessModeKey,
updater: (current: AgentWorkflowBusinessModeConfig) => AgentWorkflowBusinessModeConfig
) => {
setConfig((prev) => {
const currentSettings = normalizeAgentWorkflowBusinessModeSettings(
prev.businessModeSettings
);
return {
...prev,
businessModeSettings: {
modes: currentSettings.modes.map((mode) =>
mode.key === modeKey ? updater(mode) : mode
),
},
};
});
};
const handleUploadBusinessModeSvg = async (
modeKey: AgentWorkflowBusinessModeKey,
file: File
) => {
const isSvg =
file.type === "image/svg+xml" || file.name.toLowerCase().endsWith(".svg");
if (!isSvg) {
toast.error("请上传 SVG 图标");
return;
}
const refKey = `business-mode:${modeKey}`;
try {
setUploadingKey(refKey);
const formData = new FormData();
formData.append("file", file);
const response = await fetchUploadWithRetry(formData);
const result = await response.json();
if (!response.ok || !result?.url) {
throw new Error(result?.error || "上传失败");
}
updateBusinessMode(modeKey, (mode) => ({
...mode,
customSvgUrl: result.url,
}));
toast.success("SVG 图标上传成功");
} catch (error) {
toast.error(error instanceof Error ? error.message : "SVG 图标上传失败");
} finally {
setUploadingKey(null);
}
};
const updateSelectedBrandPsdTextColorConfig = (
updater: (current: AgentPsdTextColorBrandConfig | AgentPsdTextColorConfig) => {
enabled: boolean;
colors: [string, string];
}
) => {
if (!selectedPsdTextColorBrand) {
setPsdTextColorConfig((prev) => {
const next = updater(prev);
return {
...prev,
enabled: next.enabled,
colors: next.colors,
};
});
return;
}
setPsdTextColorConfig((prev) => {
const nextBrandConfigs = [...(prev.brandConfigs || [])];
const matchedIndex = nextBrandConfigs.findIndex(
(entry) =>
entry.brandName.trim().toLowerCase() ===
selectedPsdTextColorBrand.trim().toLowerCase()
);
const currentBrandConfig =
matchedIndex >= 0
? nextBrandConfigs[matchedIndex]
: resolveAgentPsdTextColorConfigForBrand(prev, selectedPsdTextColorBrand);
const next = updater(currentBrandConfig);
const nextEntry: AgentPsdTextColorBrandConfig = {
brandName: selectedPsdTextColorBrand,
enabled: next.enabled,
colors: next.colors,
};
if (matchedIndex >= 0) {
nextBrandConfigs[matchedIndex] = nextEntry;
} else {
nextBrandConfigs.push(nextEntry);
}
return {
...prev,
brandConfigs: nextBrandConfigs.sort((left, right) =>
left.brandName.localeCompare(right.brandName, "zh-CN")
),
};
});
};
const updatePsdTextColor = (index: 0 | 1, value: string) => {
const normalized = normalizeHexColor(
value,
selectedBrandPsdTextColorConfig.colors[index] || "#111111"
);
updateSelectedBrandPsdTextColorConfig((current) => {
const nextColors = [...current.colors] as [string, string];
nextColors[index] = normalized;
return {
enabled: current.enabled,
colors: nextColors,
};
});
};
const handleRestorePreservedDraft = () => {
const draft =
readPreservedAgentWorkflowSettingsDraftForRecovery() ||
readAgentWorkflowSettingsDraft();
if (!draft) {
setRecoverableDraftUpdatedAt(null);
toast.error(
"当前浏览器没有找到可恢复旧草稿。可能是旧版本已删除、已清理缓存,或不是原来编辑的浏览器。"
);
return;
}
const nextState: AgentWorkflowSettingsDraftState = {
agentWorkflowDefaultModelId:
String(draft.state.agentWorkflowDefaultModelId || "").trim() || null,
presetLibraryConfig: normalizeAgentPresetLibraryConfig(draft.state.presetLibraryConfig),
config: normalizeConfigOrdering(normalizeAgentWorkflowUiConfig(draft.state.config)),
materialApiConfig: normalizeAgentWorkflowMaterialApiConfig(
draft.state.materialApiConfig
),
goodsInfoApiConfig: normalizeAgentWorkflowGoodsInfoApiConfig(
draft.state.goodsInfoApiConfig
),
showcaseConfig: normalizeAgentWorkflowShowcaseConfig(draft.state.showcaseConfig),
psdTextColorConfig: normalizeAgentPsdTextColorConfig(draft.state.psdTextColorConfig),
psdTemplateSecondaryCategoryConfig: normalizeAgentPsdTemplateSecondaryCategoryConfig(
draft.state.psdTemplateSecondaryCategoryConfig
),
brandModelConfig: normalizeAgentWorkflowBrandModelConfig(draft.state.brandModelConfig),
};
setAgentWorkflowDefaultModelId(nextState.agentWorkflowDefaultModelId);
setPresetLibraryConfig(nextState.presetLibraryConfig);
setConfig(nextState.config);
setMaterialApiConfig(nextState.materialApiConfig);
setGoodsInfoApiConfig(nextState.goodsInfoApiConfig);
setShowcaseConfig(nextState.showcaseConfig);
setPsdTextColorConfig(nextState.psdTextColorConfig);
setPsdTemplateSecondaryCategoryConfig(nextState.psdTemplateSecondaryCategoryConfig);
setBrandModelConfig(nextState.brandModelConfig);
if (nextState.config.steps[0]) {
setExpandedSteps(new Set([nextState.config.steps[0].key]));
}
setAutoDraftUpdatedAt(Date.now());
setAutoDraftStatus("restored");
toast.success("已把旧草稿恢复到当前页面,确认无误后点击保存配置写入服务器");
};
const handleSave = async () => {
try {
setIsSaving(true);
const latestSettingsResponse = await fetchAgentWorkflowSettingsWithTimeout(
AGENT_WORKFLOW_SETTINGS_ENDPOINT,
{
cache: "no-store",
}
);
if (!latestSettingsResponse.ok) {
throw new Error("保存前校验服务器配置失败,请刷新页面后重试");
}
const latestSettings =
(await latestSettingsResponse.json()) as AgentWorkflowSettingsPayload;
const latestServerState = createAgentWorkflowSettingsStateFromPayload(latestSettings);
const latestServerSignatures =
createAgentWorkflowSettingsSectionSignatures(latestServerState);
const baseSignatures =
serverDraftBaseSignaturesRef.current ||
createAgentWorkflowSettingsSectionSignatures(latestServerState);
const currentSignatures =
createAgentWorkflowSettingsSectionSignatures(autoDraftState);
const changedEntries = getAgentWorkflowSettingsPayloadEntries(autoDraftState).filter(
({ field }) => currentSignatures[field] !== baseSignatures[field]
);
if (changedEntries.length === 0) {
removeAgentWorkflowSettingsDraft();
setAutoDraftUpdatedAt(null);
setAutoDraftStatus("idle");
toast.success("没有检测到需要保存的修改");
return;
}
const conflictedFields = changedEntries
.filter(({ field }) => latestServerSignatures[field] !== baseSignatures[field])
.map(({ field }) => AGENT_WORKFLOW_SETTINGS_FIELD_LABELS[field]);
if (conflictedFields.length > 0) {
toast.error(
`以下配置已被其他管理员更新,本次保存已阻止:${conflictedFields.join("、")}。请刷新页面确认后再保存。`
);
return;
}
const changedPayload: Partial<AgentWorkflowSettingsPayload> & {
agentWorkflowSettingsBaseSignatures: Partial<
Record<AgentWorkflowSettingsField, string>
>;
} = {
agentWorkflowSettingsBaseSignatures: {},
};
for (const { field, value } of changedEntries) {
changedPayload[field] = value as never;
changedPayload.agentWorkflowSettingsBaseSignatures[field] = baseSignatures[field];
}
const submitChangedPayload = async (payload: Record<string, unknown>) =>
fetchAgentWorkflowSettingsWithTimeout("/api/site-settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
let response = await submitChangedPayload(changedPayload as Record<string, unknown>);
let responsePayload = (await response.json().catch(() => null)) as
| {
error?: string;
details?: Array<{ message?: string; path?: Array<string | number> }>;
requiresDestructiveChangeApproval?: boolean;
destructiveChange?: AgentWorkflowDestructiveChangePayload;
}
| null;
if (
!response.ok &&
responsePayload?.requiresDestructiveChangeApproval &&
responsePayload?.destructiveChange?.approvalToken
) {
const shouldContinue = window.confirm(
formatAgentWorkflowDestructiveChangeConfirmText(
responsePayload.destructiveChange
)
);
if (!shouldContinue) {
toast.info("已取消本次高风险保存");
return;
}
response = await submitChangedPayload({
...changedPayload,
agentWorkflowDestructiveChangeApproval: {
token: responsePayload.destructiveChange.approvalToken,
},
});
responsePayload = (await response.json().catch(() => null)) as
| {
error?: string;
details?: Array<{ message?: string; path?: Array<string | number> }>;
}
| null;
}
if (!response.ok) {
let message = "保存失败";
if (Array.isArray(responsePayload?.details) && responsePayload.details.length > 0) {
const firstIssue = responsePayload.details[0] as {
message?: string;
path?: Array<string | number>;
};
const pathText =
Array.isArray(firstIssue.path) && firstIssue.path.length > 0
? firstIssue.path
.filter((segment) => segment !== undefined && segment !== null && segment !== "")
.join(" / ")
: "";
const reasonText = String(firstIssue.message || "").trim();
message = [responsePayload?.error, pathText, reasonText]
.filter(Boolean)
.join(":");
} else if (
typeof responsePayload?.error === "string" &&
responsePayload.error.trim()
) {
message = responsePayload.error.trim();
}
throw new Error(message);
}
const updatedSettings = await mutate();
const savedWorkflowConfig = normalizeAgentWorkflowUiConfig(
updatedSettings?.agentWorkflowUiConfig
);
const expectedStepCount = countAgentWorkflowSteps(config.steps || []);
const savedStepCount = countAgentWorkflowSteps(savedWorkflowConfig.steps || []);
const savedWorkflowConfigWasChanged = changedEntries.some(
({ field }) => field === "agentWorkflowUiConfig"
);
if (savedWorkflowConfigWasChanged && savedStepCount !== expectedStepCount) {
toast.error(
`服务端回读到的模块数量不一致(本地 ${expectedStepCount} / 服务端 ${savedStepCount}),已保留本地自动草稿,请先不要刷新页面。`
);
return;
}
const savedMissingPreviewOptions = savedWorkflowConfigWasChanged
? getAgentWorkflowMissingPreviewOptions(savedWorkflowConfig.steps || [], {
enabledOnly: true,
})
: [];
const updatedServerState = createAgentWorkflowSettingsStateFromPayload(
updatedSettings || latestSettings
);
serverDraftBaseSignatureRef.current =
createAgentWorkflowSettingsStateSignature(updatedServerState);
serverDraftBaseSignaturesRef.current =
createAgentWorkflowSettingsSectionSignatures(updatedServerState);
hasWarnedRemoteWorkflowUpdateRef.current = false;
removeAgentWorkflowSettingsDraft();
setAutoDraftUpdatedAt(null);
setAutoDraftStatus("idle");
toast.success(
`已保存 ${changedEntries
.map(({ field }) => AGENT_WORKFLOW_SETTINGS_FIELD_LABELS[field])
.join("、")}`
);
if (savedMissingPreviewOptions.length > 0) {
toast.warning(
`仍有 ${savedMissingPreviewOptions.length} 个启用卡片缺少预览图,前台会自动隐藏这些无图卡片。`
);
}
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
toast.error("保存请求超时,请检查网络或稍后重试");
} else {
toast.error(error instanceof Error ? error.message : "保存失败");
}
} finally {
setIsSaving(false);
}
};
const handleCreateSnapshot = async () => {
try {
setIsCreatingSnapshot(true);
const response = await fetch("/api/admin/system-settings/snapshots", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "create",
scope: "agent-workflow",
}),
});
const payload = (await response.json().catch(() => null)) as
| { error?: string; snapshot?: AgentWorkflowSnapshotItem | null }
| null;
if (!response.ok) {
throw new Error(payload?.error || "创建快照失败");
}
await mutateSnapshots();
toast.success("已创建当前工作流快照");
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建快照失败");
} finally {
setIsCreatingSnapshot(false);
}
};
const handleRestoreSnapshot = async (snapshot: AgentWorkflowSnapshotItem) => {
const shouldContinue = window.confirm(
[
"将把风格化工作流恢复到这个快照版本。",
"恢复前系统会自动再做一份当前快照,便于继续回退。",
"",
`快照标签:${snapshot.label || "未命名快照"}`,
`创建时间:${new Date(snapshot.createdAt).toLocaleString("zh-CN")}`,
].join("\n")
);
if (!shouldContinue) {
return;
}
try {
setRestoringSnapshotId(snapshot.id);
const response = await fetch("/api/admin/system-settings/snapshots", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "restore",
snapshotId: snapshot.id,
reason: `restore-snapshot:${snapshot.id}`,
}),
});
const payload = (await response.json().catch(() => null)) as
| { error?: string; backupSnapshotId?: string | null }
| null;
if (!response.ok) {
throw new Error(payload?.error || "恢复快照失败");
}
await Promise.all([mutate(), mutateSnapshots()]);
toast.success(
payload?.backupSnapshotId
? `已恢复快照,并自动创建恢复前备份 ${payload.backupSnapshotId.slice(0, 8)}`
: "已恢复到所选快照"
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "恢复快照失败");
} finally {
setRestoringSnapshotId(null);
}
};
const handleDownloadBrandTemplate = async () => {
try {
await downloadAgentWorkflowBrandImportTemplate(categoryLabels);
toast.success("层级导入模板已开始下载");
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载模板失败");
}
};
const handleBrandSheetImport = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) {
return;
}
try {
setIsImportingBrandSheet(true);
const { entries, rowCount } = await parseAgentWorkflowBrandImportFile(file, categoryLabels);
updateInputOptions([...brandStoreEntries, ...entries]);
toast.success(`已从表格导入 ${rowCount} 行,层级树已自动合并去重`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "导入表格失败");
} finally {
setIsImportingBrandSheet(false);
}
};
const renderPresetLibraryNodeCard = (
sectionId: string,
node: AgentPresetLibraryCategoryNode,
depth: number,
indexPath: number[]
): React.ReactNode => {
const levelLabel =
PRESET_LIBRARY_LEVEL_LABELS[Math.max(0, Math.min(depth - 1, PRESET_LIBRARY_LEVEL_LABELS.length - 1))] ||
`第 ${depth} 级分类`;
const canAddChild = depth < AGENT_PRESET_LIBRARY_MAX_DEPTH;
const sortedChildren = [...(node.children || [])].sort(
(left, right) => left.sortOrder - right.sortOrder
);
const isExpanded = isPresetLibraryNodeExpanded(node.id, depth);
return (
<div key={node.id} className={cn("space-y-3", depth > 1 && "pl-4 sm:pl-6")}>
<Collapsible
open={isExpanded}
onOpenChange={() => togglePresetLibraryNodeExpansion(node.id, depth)}
>
<Card className={cn(!node.enabled && "opacity-60", depth > 1 && "border-l-4 border-l-sky-500/30")}>
<CardHeader className="py-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 items-center gap-3">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-md hover:bg-muted"
aria-label={isExpanded ? "收起分类配置" : "展开分类配置"}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
</CollapsibleTrigger>
<div className="flex h-9 min-w-9 items-center justify-center rounded-md bg-sky-500/10 px-2 text-xs font-medium text-sky-600">
{indexPath.join(".")}
</div>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Input
value={node.name}
onChange={(e) =>
updatePresetLibraryNode(sectionId, node.id, (currentNode) => ({
...currentNode,
name: e.target.value,
}))
}
className="h-8 min-w-[200px] max-w-[320px] border-0 bg-transparent px-0 text-base font-medium shadow-none focus-visible:ring-0"
placeholder="分类名称"
/>
<Badge variant="outline">{levelLabel}</Badge>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{node.id}
</code>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{sortedChildren.length} 子分类</Badge>
{canAddChild ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleAddPresetLibraryNode(sectionId, node.id)}
>
<Plus className="mr-2 h-4 w-4" />
新增子级
</Button>
) : (
<Badge variant="outline">已达三级</Badge>
)}
<Switch
checked={node.enabled}
onCheckedChange={(checked) =>
updatePresetLibraryNode(sectionId, node.id, (currentNode) => ({
...currentNode,
enabled: checked,
}))
}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => handleRemovePresetLibraryNode(sectionId, node.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CollapsibleContent>
<CardContent className="border-t bg-muted/20 pt-4">
{sortedChildren.length > 0 ? (
<div className="space-y-4 border-l border-dashed border-border/80 pl-3 sm:pl-4">
{sortedChildren.map((childNode, childIndex) =>
renderPresetLibraryNodeCard(sectionId, childNode, depth + 1, [
...indexPath,
childIndex + 1,
])
)}
</div>
) : (
<div className="rounded-lg border border-dashed bg-background/60 px-4 py-6 text-center text-sm text-muted-foreground">
暂无子分类
</div>
)}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
</div>
);
};
const renderPresetLibrarySectionCard = (
section: AgentPresetLibrarySection,
index: number
) => {
const sortedCategories = [...(section.categories || [])].sort(
(left, right) => left.sortOrder - right.sortOrder
);
return (
<motion.div key={section.id} variants={itemVariants}>
<Card className={!section.enabled ? "opacity-70" : undefined}>
<CardHeader className="pb-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-sky-500/10 text-sm font-semibold text-sky-600">
{index + 1}
</div>
<div>
<CardTitle className="text-base">顶部分类页</CardTitle>
<CardDescription>
“我的模式”固定保留,这里维护其余可显示的生图模式分类页与三级分类树。
</CardDescription>
</div>
</div>
<div className="grid gap-3 md:grid-cols-[minmax(220px,280px)_1fr]">
<div className="space-y-2">
<Label className="text-xs">分类页名称</Label>
<Input
value={section.label}
onChange={(e) =>
updatePresetLibrarySection(section.id, (currentSection) => ({
...currentSection,
label: e.target.value,
}))
}
placeholder="例如:案例广场"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">说明文案</Label>
<Input
value={section.description || ""}
onChange={(e) =>
updatePresetLibrarySection(section.id, (currentSection) => ({
...currentSection,
description: e.target.value,
}))
}
placeholder="用于后台区分该分类页的用途"
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{sortedCategories.length} 个一级分类</Badge>
<Badge variant="secondary">
共 {countPresetLibraryNodes(section.categories || [])} 个树节点
</Badge>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{section.id}
</code>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleAddPresetLibraryNode(section.id)}
>
<Plus className="mr-2 h-4 w-4" />
新增一级分类
</Button>
<Switch
checked={section.enabled}
onCheckedChange={(checked) =>
updatePresetLibrarySection(section.id, (currentSection) => ({
...currentSection,
enabled: checked,
}))
}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => handleRemovePresetLibrarySection(section.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="border-t bg-muted/20 pt-4">
{sortedCategories.length > 0 ? (
<div className="space-y-4">
{sortedCategories.map((node, nodeIndex) =>
renderPresetLibraryNodeCard(section.id, node, 1, [nodeIndex + 1])
)}
</div>
) : (
<div className="rounded-lg border border-dashed bg-background/60 px-4 py-10 text-center text-sm text-muted-foreground">
当前分类页还没有任何分类树节点,先新增一级分类即可继续建立二级、三级关系。
</div>
)}
</CardContent>
</Card>
</motion.div>
);
};
const renderWorkflowOptionCard = (
step: AgentWorkflowStepUiConfig,
option: AgentWorkflowOptionUiConfig,
depth: number,
indexPath: number[]
): ReactNode => {
const refKey = `${step.key}:${option.key}`;
const isUploading = uploadingKey === refKey;
const levelLabel = getWorkflowLevelLabel(depth, "卡片");
const sortedChildren = [...(option.children || [])].sort(
(left, right) => left.sortOrder - right.sortOrder
);
const canAddChild = depth < AGENT_WORKFLOW_MAX_OPTION_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 availableSpecialModuleOptions = workflowSpecialModuleOptions.filter(
(item) => item.option.key !== option.key && (item.option.children || []).length > 0
);
const specialAllowedKeys = new Set(option.specialModuleAllowedOptionKeys || []);
const availableOfficialCategoryKeys = new Set(option.availableOfficialCategoryKeys || []);
const isExpanded = isWorkflowOptionExpanded(option.key, depth);
const blockedSearchValue = blockedOptionSearches[refKey] || "";
const normalizedBlockedSearchValue = blockedSearchValue.trim().toLowerCase();
const filteredBlockedCandidates = blockedCandidatePanelExpanded
? blockedCandidates.filter((candidate) => {
if (!normalizedBlockedSearchValue) {
return true;
}
const candidateStepAnalysis = workflowStepAnalysisLookup.get(candidate.stepKey);
return [
candidate.label,
candidate.stepTitle,
candidate.key,
candidateStepAnalysis?.path || "",
candidateStepAnalysis?.hasOwnContent ? "模块有内容" : "模块空内容",
candidate.hasContent ? "卡片有内容" : "卡片空内容",
candidate.hasPrompt ? "有prompt" : "空prompt",
].some((value) => value.toLowerCase().includes(normalizedBlockedSearchValue));
})
: [];
const canMoveUp = depth === 1 && indexPath[0] > 1;
const canMoveDown = depth === 1 && indexPath[0] < (step.options || []).length;
const toggleSpecialModuleAllowedOption = (allowedOptionKey: string) => {
updateOption(step.key, option.key, (currentOption) => {
const nextAllowedKeys = new Set(currentOption.specialModuleAllowedOptionKeys || []);
if (nextAllowedKeys.has(allowedOptionKey)) {
nextAllowedKeys.delete(allowedOptionKey);
} else {
nextAllowedKeys.add(allowedOptionKey);
}
return {
...currentOption,
specialModuleAllowedOptionKeys: Array.from(nextAllowedKeys),
};
});
};
const setSpecialModuleAllowedBranch = (
candidateOption: AgentWorkflowOptionUiConfig,
allowed: boolean
) => {
updateOption(step.key, option.key, (currentOption) => {
const nextAllowedKeys = new Set(currentOption.specialModuleAllowedOptionKeys || []);
const branchKeys = flattenAgentWorkflowOptionTree([candidateOption]).map(
({ option: branchOption }) => branchOption.key
);
branchKeys.forEach((branchKey) => nextAllowedKeys.delete(branchKey));
if (allowed) {
nextAllowedKeys.add(candidateOption.key);
}
return {
...currentOption,
specialModuleAllowedOptionKeys: Array.from(nextAllowedKeys),
};
});
};
const renderSpecialModuleAllowedOption = (
candidateOption: AgentWorkflowOptionUiConfig,
candidateDepth: number,
inheritedAllowed = false
): ReactNode => {
const directlyAllowed = specialAllowedKeys.has(candidateOption.key);
const allowed = inheritedAllowed || directlyAllowed;
const sortedCandidateChildren = [...(candidateOption.children || [])].sort(
(left, right) => left.sortOrder - right.sortOrder
);
return (
<div key={candidateOption.key} className={cn("space-y-1", candidateDepth > 1 && "pl-3")}>
<button
type="button"
className={cn(
"flex w-full items-center justify-between gap-3 rounded-md border px-3 py-2 text-left text-xs transition-colors",
allowed
? "border-emerald-500/50 bg-emerald-500/10 text-foreground"
: "border-border/70 bg-background hover:border-foreground/20 hover:bg-muted/60"
)}
onClick={() => toggleSpecialModuleAllowedOption(candidateOption.key)}
>
<span className="min-w-0">
<span className="block break-words font-medium leading-5">
{candidateOption.buttonName || candidateOption.key}
</span>
<span className="block text-[10px] text-muted-foreground">
{getWorkflowLevelLabel(candidateDepth, "卡片")} · {candidateOption.key}
</span>
</span>
<Badge variant={allowed ? "default" : "outline"} className="shrink-0">
{directlyAllowed ? "已放行" : inheritedAllowed ? "随父级放行" : "默认排斥"}
</Badge>
</button>
{sortedCandidateChildren.length > 0 ? (
<div className="space-y-1 border-l border-dashed pl-2">
{sortedCandidateChildren.map((childOption) =>
renderSpecialModuleAllowedOption(childOption, candidateDepth + 1, allowed)
)}
</div>
) : null}
</div>
);
};
return (
<div key={option.key} className={cn("space-y-4", depth > 1 && "pl-4 sm:pl-6")}>
<Collapsible
open={isExpanded}
onOpenChange={() => toggleWorkflowOptionExpansion(option.key, depth)}
>
<div
className={cn(
"rounded-lg border bg-card p-4",
!option.enabled && "opacity-60",
depth > 1 && "border-l-4 border-l-sky-500/30"
)}
>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex-1">
<div className="mb-2 flex flex-wrap items-center gap-2">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-md hover:bg-muted"
aria-label={isExpanded ? "收起卡片配置" : "展开卡片配置"}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
</CollapsibleTrigger>
<span className="flex h-8 min-w-8 items-center justify-center rounded-md bg-sky-500/10 px-2 text-xs font-medium text-sky-600">
{indexPath.join(".")}
</span>
<Badge variant="outline">{levelLabel}</Badge>
<Badge variant="secondary">{sortedChildren.length} 子卡片</Badge>
{option.specialModuleEnabled ? <Badge variant="default">特殊模块</Badge> : null}
{option.specialModuleExclusionEnabled ? (
<Badge variant="secondary">特殊模块快捷排斥</Badge>
) : null}
{!option.enabled ? <Badge variant="secondary">前台显示开发中</Badge> : null}
<span className="max-w-[240px] truncate text-sm font-medium">
{option.buttonName || "未命名卡片"}
</span>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{option.key}
</code>
</div>
{depth > 1 ? (
<div className="text-xs text-muted-foreground">
{isExpanded ? "已展开当前子卡片配置" : "当前子卡片已折叠,点击左侧箭头展开详细设置"}
</div>
) : null}
</div>
<div className="flex flex-wrap items-center gap-1">
{depth === 1 ? (
<>
<Button
type="button"
variant="outline"
size="icon"
className="h-7 w-7"
disabled={!canMoveUp}
onClick={() => handleMoveRootOption(step.key, option.key, "up")}
>
<ArrowUp className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="outline"
size="icon"
className="h-7 w-7"
disabled={!canMoveDown}
onClick={() => handleMoveRootOption(step.key, option.key, "down")}
>
<ArrowDown className="h-3.5 w-3.5" />
</Button>
</>
) : null}
{canAddChild ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleAddOption(step.key, option.key)}
>
<Plus className="mr-2 h-4 w-4" />
新增子卡片
</Button>
) : (
<Badge variant="outline">{`已达 ${AGENT_WORKFLOW_MAX_OPTION_DEPTH} 级`}</Badge>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => handleRemoveOption(step.key, option.key)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<CollapsibleContent>
<div className="mt-4 grid grid-cols-[100px_1fr] gap-4">
<div className="space-y-2">
<div className="aspect-square overflow-hidden rounded-lg border bg-muted">
{option.previewImageUrl ? (
<img
src={option.previewImageUrl}
alt={option.buttonName}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full flex-col items-center justify-center gap-1 text-muted-foreground">
<ImageIcon className="h-6 w-6 opacity-40" />
<span className="text-[10px]">无图片</span>
</div>
)}
</div>
<input
ref={(el) => {
fileInputRefs.current[refKey] = el;
}}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
void handleUploadPreview(step.key, option.key, file);
}
e.target.value = "";
}}
/>
<Button
type="button"
variant="outline"
size="sm"
className="w-full text-xs"
disabled={isUploading}
onClick={() => fileInputRefs.current[refKey]?.click()}
>
{isUploading ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Upload className="mr-1 h-3 w-3" />
)}
上传
</Button>
</div>
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-xs">按钮名称</Label>
<BufferedInput
value={option.buttonName}
onValueCommit={(value) =>
updateOption(step.key, option.key, (currentOption) => ({
...currentOption,
buttonName: value,
}))
}
placeholder="选项名称"
className="h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">说明文案</Label>
<BufferedInput
value={option.description || ""}
onValueCommit={(value) =>
updateOption(step.key, option.key, (currentOption) => ({
...currentOption,
description: value,
}))
}
placeholder="简短描述"
className="h-8"
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<Label className="text-xs">Prompt 片段</Label>
<span className="text-[10px] text-muted-foreground">
{(option.promptFragment || "").length} /{" "}
{AGENT_WORKFLOW_PROMPT_FRAGMENT_MAX_LENGTH}
</span>
</div>
<BufferedTextarea
value={option.promptFragment || ""}
maxLength={AGENT_WORKFLOW_PROMPT_FRAGMENT_MAX_LENGTH}
onValueCommit={(value) =>
updateOption(step.key, option.key, (currentOption) => ({
...currentOption,
promptFragment: value,
}))
}
placeholder="注入的提示词片段"
rows={2}
className="resize-none text-xs"
/>
</div>
<div className="flex items-start justify-between gap-4 rounded-lg border border-dashed bg-muted/20 p-3">
<div className="space-y-1">
<Label className="text-xs">前台禁用状态</Label>
<p className="text-[11px] leading-5 text-muted-foreground">
关闭后前端依然会展示这张卡片,但会标记为“开发中”且不可点击。
</p>
</div>
<Switch
checked={option.enabled}
onCheckedChange={(checked) =>
updateOption(step.key, option.key, (currentOption) => ({
...currentOption,
enabled: checked,
}))
}
className="mt-1 scale-90"
/>
</div>
<div className="space-y-3 rounded-lg border border-dashed bg-muted/20 p-3">
<div className="space-y-1">
<Label className="text-xs">品类可用范围</Label>
<p className="text-[11px] leading-5 text-muted-foreground">
可不选;不选代表所有官方品类都可用。选择后,前台用户选中对应二级或三级品类时,当前层级只会展示匹配的卡片。
</p>
</div>
{availableOfficialCategoryKeys.size > 0 ? (
<div className="flex flex-wrap gap-2">
{Array.from(availableOfficialCategoryKeys).map((categoryKey) => (
<span
key={categoryKey}
className="inline-flex items-center gap-2 rounded-full border bg-background px-3 py-1.5 text-xs"
>
<span className="max-w-[260px] truncate">
{officialCategoryKeyLabelMap.get(categoryKey) || categoryKey}
</span>
<button
type="button"
className="rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
onClick={() =>
updateOption(step.key, option.key, (currentOption) => ({
...currentOption,
availableOfficialCategoryKeys: (
currentOption.availableOfficialCategoryKeys || []
).filter((item) => item !== categoryKey),
}))
}
aria-label={`移除品类可用范围 ${categoryKey}`}
>
<Trash2 className="h-3 w-3" />
</button>
</span>
))}
</div>
) : (
<div className="rounded-md border border-dashed bg-background/70 px-3 py-2 text-xs text-muted-foreground">
当前不限制品类,所有官方二级/三级品类都可见。
</div>
)}
<div className="max-h-48 space-y-1 overflow-y-auto rounded-md border bg-background/70 p-2">
{officialCategoryChoices.length > 0 ? (
officialCategoryChoices.map((choice) => {
const active = availableOfficialCategoryKeys.has(choice.key);
return (
<button
key={choice.key}
type="button"
className={cn(
"flex w-full items-center justify-between gap-3 rounded-md border px-3 py-2 text-left text-xs transition-colors",
active
? "border-sky-500/50 bg-sky-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 nextKeys = new Set(
currentOption.availableOfficialCategoryKeys || []
);
if (nextKeys.has(choice.key)) {
nextKeys.delete(choice.key);
} else {
nextKeys.add(choice.key);
}
return {
...currentOption,
availableOfficialCategoryKeys: Array.from(nextKeys),
};
})
}
>
<span className="break-words leading-5">{choice.label}</span>
<Badge variant={active ? "default" : "outline"}>
{active ? "已限定" : "可选择"}
</Badge>
</button>
);
})
) : (
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
暂无官方品类,请先在基础下拉选项里配置二级/三级品类。
</div>
)}
</div>
</div>
{depth === 1 ? (
<div className="flex items-start justify-between gap-4 rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3">
<div className="space-y-1">
<Label className="text-xs">特殊模块</Label>
<p className="text-[11px] leading-5 text-muted-foreground">
开启后,这张一级卡片会出现在互斥配置的“特殊模块快捷排斥”里。其它卡片可只勾选少量放行子卡片,未放行的子卡片会自动排斥。
</p>
</div>
<Switch
checked={Boolean(option.specialModuleEnabled)}
onCheckedChange={(checked) =>
updateOption(step.key, option.key, (currentOption) => ({
...currentOption,
specialModuleEnabled: checked,
}))
}
className="mt-1 scale-90"
/>
</div>
) : null}
<div className="space-y-2 rounded-lg border border-dashed bg-muted/20 p-3">
<div className="space-y-1">
<Label className="text-xs">互斥卡片限制</Label>
<p className="text-[11px] leading-5 text-muted-foreground">
{workflowConstraintSettings.crossLevelMutualExclusionEnabled
? "当前已开启跨级互斥,可直接限制任意层级卡片;生效时会自动覆盖目标卡片下的全部子级。"
: "当前仅展示同级卡片供你配置互斥。一级只与所有一级卡片互斥,二级只与所有二级卡片互斥;生效时会自动覆盖目标卡片下的全部子级。"}
</p>
</div>
{selectedBlockedItems.length > 0 ? (
<div className="flex flex-wrap gap-2">
{selectedBlockedItems.map((blockedItem) => (
<span
key={blockedItem.key}
className="inline-flex items-center gap-2 rounded-full border bg-background px-3 py-1.5 text-xs"
>
<span className="text-muted-foreground">
{getWorkflowLevelLabel(blockedItem.depth, "卡片")}
</span>
<span className="max-w-[220px] truncate">{blockedItem.label}</span>
<button
type="button"
className="rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
onClick={() =>
updateOption(step.key, option.key, (currentOption) => ({
...currentOption,
blockedOptionKeys: (currentOption.blockedOptionKeys || []).filter(
(item) => item !== blockedItem.key
),
}))
}
aria-label={`移除互斥卡片 ${blockedItem.label}`}
>
<Trash2 className="h-3 w-3" />
</button>
</span>
))}
</div>
) : (
<div className="rounded-md border border-dashed bg-background/70 px-3 py-2 text-xs text-muted-foreground">
当前还没有配置互斥卡片
</div>
)}
{availableSpecialModuleOptions.length > 0 ? (
<div className="space-y-3 rounded-md border border-emerald-500/20 bg-emerald-500/5 p-3">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label className="text-xs">特殊模块快捷排斥</Label>
<p className="text-[11px] leading-5 text-muted-foreground">
开启后,下面特殊模块中未勾选放行的子卡片会自动进入排斥;勾选父级会放行整支子树。
</p>
</div>
<Switch
checked={Boolean(option.specialModuleExclusionEnabled)}
onCheckedChange={(checked) =>
updateOption(step.key, option.key, (currentOption) => ({
...currentOption,
specialModuleExclusionEnabled: checked,
}))
}
className="mt-1 scale-90"
/>
</div>
{option.specialModuleExclusionEnabled ? (
<div className="max-h-72 space-y-3 overflow-y-auto rounded-md border bg-background/70 p-2">
{availableSpecialModuleOptions.map((specialModule) => {
const rootAllowed = specialAllowedKeys.has(specialModule.option.key);
return (
<div key={specialModule.option.key} className="space-y-2 rounded-lg border p-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="break-words text-xs font-medium leading-5">
{specialModule.path}
</div>
<div className="text-[10px] text-muted-foreground">
特殊模块 · {specialModule.option.key}
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1">
<Badge variant={rootAllowed ? "default" : "outline"}>
{rootAllowed
? "整组已放行"
: `${(specialModule.option.children || []).length} 个子卡片`}
</Badge>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 px-2 text-[11px]"
disabled={rootAllowed}
onClick={() => setSpecialModuleAllowedBranch(specialModule.option, true)}
>
整组放行
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-[11px]"
onClick={() => setSpecialModuleAllowedBranch(specialModule.option, false)}
>
恢复默认排斥
</Button>
</div>
</div>
<div className="space-y-1">
{(specialModule.option.children || []).map((childOption) =>
renderSpecialModuleAllowedOption(childOption, 2, rootAllowed)
)}
</div>
</div>
);
})}
</div>
) : (
<div className="rounded-md border border-dashed bg-background/70 px-3 py-2 text-[11px] text-muted-foreground">
当前未启用特殊模块快捷排斥;启用后只需要勾选“不排斥”的少量子卡片。
</div>
)}
</div>
) : null}
<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">
候选卡片列表
{blockedCandidatePanelExpanded
? ` · 已展示 ${filteredBlockedCandidates.length}/${blockedCandidates.length}`
: ""}
</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-72 space-y-2 overflow-y-auto">
{filteredBlockedCandidates.length > 0 ? (
<>
{filteredBlockedCandidates.map((candidate) => {
const active = (option.blockedOptionKeys || []).includes(candidate.key);
const candidateStepAnalysis = workflowStepAnalysisLookup.get(
candidate.stepKey
);
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 flex-1 space-y-1 pr-3">
<span className="block whitespace-normal break-words font-medium leading-5">
{candidate.label}
</span>
<span className="block whitespace-normal break-words text-[10px] leading-4 text-muted-foreground">
所属模块:
{candidateStepAnalysis?.path || candidate.stepTitle} ·{" "}
{getWorkflowLevelLabel(candidate.depth, "卡片")} ·{" "}
{candidate.key}
</span>
</span>
<span className="flex shrink-0 flex-wrap justify-end gap-1">
<Badge variant={active ? "default" : "outline"}>
{active ? "已限制" : "可限制"}
</Badge>
<Badge
variant={
candidateStepAnalysis?.hasOwnContent
? "secondary"
: "outline"
}
>
{candidateStepAnalysis?.hasOwnContent
? "模块有内容"
: "模块空内容"}
</Badge>
<Badge variant={candidate.hasContent ? "secondary" : "outline"}>
{candidate.hasContent ? "卡片有内容" : "卡片空内容"}
</Badge>
</span>
</button>
);
})}
</>
) : (
<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>
<div className="mt-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">子卡片配置</div>
<div className="text-xs text-muted-foreground">
{`当前卡片下可继续建立最多 ${AGENT_WORKFLOW_MAX_OPTION_DEPTH} 级卡片母子关系。`}
</div>
</div>
</div>
{sortedChildren.length > 0 ? (
<div className="space-y-4 border-l border-dashed border-border/80 pl-3 sm:pl-4">
{sortedChildren.map((childOption, childIndex) =>
renderWorkflowOptionCard(step, childOption, depth + 1, [
...indexPath,
childIndex + 1,
])
)}
</div>
) : (
<div className="rounded-lg border border-dashed bg-background/60 px-4 py-6 text-center text-sm text-muted-foreground">
暂无子卡片
</div>
)}
</div>
</CollapsibleContent>
</div>
</Collapsible>
</div>
);
};
const renderWorkflowDashboard = () => {
const completionRate = workflowDashboardMetrics.promptCompletionRate;
const priorityEmptySteps =
workflowDashboardMetrics.emptyBranchSteps.length > 0
? workflowDashboardMetrics.emptyBranchSteps
: workflowDashboardMetrics.leafEmptySteps.length > 0
? workflowDashboardMetrics.leafEmptySteps
: workflowDashboardMetrics.emptyOwnContentSteps;
const dashboardCards = [
{
label: "模块总数",
value: workflowDashboardMetrics.totalSteps,
hint: `最大 ${workflowDashboardMetrics.maxDepth || 0} 级`,
},
{
label: "卡片总数",
value: workflowDashboardMetrics.totalCards,
hint: `${workflowDashboardMetrics.promptCards} 张已有 Prompt`,
},
{
label: "空缺卡片",
value: workflowDashboardMetrics.missingCardDetails.length,
hint: `${workflowDashboardMetrics.fullyEmptyCards} 张完全空内容`,
},
{
label: "空内容模块",
value: workflowDashboardMetrics.emptyOwnContentSteps.length,
hint: `${workflowDashboardMetrics.emptyBranchSteps.length} 个整支为空`,
},
{
label: "互斥覆盖",
value: workflowDashboardMetrics.mutualExclusionCards,
hint: `${workflowDashboardMetrics.disabledCards} 张禁用/开发中`,
},
];
return (
<motion.div variants={itemVariants}>
<Card className="overflow-hidden border-emerald-500/20 bg-gradient-to-br from-emerald-500/5 via-background to-sky-500/5">
<CardHeader className="pb-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-base">
<Sparkles className="h-4 w-4 text-emerald-600" />
工作流配置体检仪表盘
</CardTitle>
<CardDescription>
自动分析模块层级、卡片内容、Prompt 完成度和空模块,帮助你从蜘蛛网里找到下一步要补的地方。
</CardDescription>
</div>
<Badge variant={workflowDashboardMetrics.emptyBranchSteps.length > 0 ? "outline" : "secondary"}>
{workflowDashboardMetrics.emptyBranchSteps.length > 0
? `${workflowDashboardMetrics.emptyBranchSteps.length} 个分支完全空`
: "没有完全空分支"}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid gap-3 md:grid-cols-5">
{dashboardCards.map((item) => (
<div key={item.label} className="rounded-xl border bg-background/80 p-4 shadow-sm">
<div className="text-xs text-muted-foreground">{item.label}</div>
<div className="mt-2 text-2xl font-semibold">{item.value}</div>
<div className="mt-1 text-xs text-muted-foreground">{item.hint}</div>
</div>
))}
</div>
<div className="grid gap-4 lg:grid-cols-[1.05fr_1fr]">
<div className="space-y-4 rounded-xl border bg-background/80 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">Prompt 完成率</div>
<div className="text-xs text-muted-foreground">
以所有层级卡片是否填写 Prompt 片段统计。
</div>
</div>
<div className="text-2xl font-semibold text-emerald-600">{completionRate}%</div>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-emerald-500 transition-all"
style={{ width: `${completionRate}%` }}
/>
</div>
<div className="flex flex-wrap gap-2">
{workflowDashboardMetrics.levelCounts.length > 0 ? (
workflowDashboardMetrics.levelCounts.map(([level, count]) => (
<Badge key={level} variant="outline">
{getWorkflowLevelLabel(level, "模块")}:{count}
</Badge>
))
) : (
<span className="text-xs text-muted-foreground">暂无模块层级数据</span>
)}
</div>
</div>
<div className="space-y-3 rounded-xl border bg-background/80 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">优先补内容的模块</div>
<div className="text-xs text-muted-foreground">
优先显示整支为空或本级没有 Prompt/图片/说明的模块。
</div>
</div>
<Badge variant="outline">{priorityEmptySteps.length} 个待看</Badge>
</div>
{priorityEmptySteps.length > 0 ? (
<div className="max-h-48 space-y-2 overflow-y-auto pr-1">
{priorityEmptySteps.slice(0, 8).map((item) => (
<div key={item.key} className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="break-words text-xs font-medium leading-5">{item.path}</div>
<div className="mt-1 flex flex-wrap gap-1">
<Badge variant="outline">{getWorkflowLevelLabel(item.depth, "模块")}</Badge>
<Badge variant={item.hasSubtreeContent ? "secondary" : "outline"}>
{item.hasSubtreeContent ? "子级有内容" : "整支空内容"}
</Badge>
<Badge variant={item.hasOwnContent ? "secondary" : "outline"}>
{item.hasOwnContent ? "本级有内容" : "本级空内容"}
</Badge>
<Badge variant="outline">{item.optionStats.total} 张卡片</Badge>
</div>
</div>
))}
</div>
) : (
<div className="rounded-lg border border-dashed bg-muted/20 px-3 py-6 text-center text-xs text-muted-foreground">
当前模块内容完整度不错,可以继续检查互斥关系和禁用状态。
</div>
)}
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-xl border bg-background/80 p-4">
<div className="mb-3 text-sm font-medium">最复杂分支</div>
<div className="space-y-2">
{workflowDashboardMetrics.denseSteps.map((item) => (
<div key={item.key} className="flex items-start justify-between gap-3 text-xs">
<span className="min-w-0 break-words leading-5">{item.path}</span>
<Badge variant="outline" className="shrink-0">
{item.subtreeStepCount} 模块 / {item.subtreeOptionCount} 卡片
</Badge>
</div>
))}
</div>
</div>
<div className="rounded-xl border bg-background/80 p-4">
<div className="mb-3 text-sm font-medium">最深路径</div>
<div className="space-y-2">
{workflowDashboardMetrics.deepestSteps.map((item) => (
<div key={item.key} className="flex items-start justify-between gap-3 text-xs">
<span className="min-w-0 break-words leading-5">{item.path}</span>
<Badge variant="outline" className="shrink-0">
{getWorkflowLevelLabel(item.depth, "模块")}
</Badge>
</div>
))}
</div>
</div>
</div>
<div className="rounded-xl border bg-background/80 p-4">
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-sm font-medium">空缺卡片明细</div>
<div className="text-xs text-muted-foreground">
按缺失项数量排序,直接标出每张卡片缺名称、Prompt、说明或预览图。
</div>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">
{workflowDashboardMetrics.missingCardDetails.length} 张有空缺
</Badge>
<Badge variant="outline">
{workflowDashboardMetrics.missingPromptCards} 张缺 Prompt
</Badge>
<Badge variant="outline">
{workflowDashboardMetrics.fullyEmptyCards} 张完全空内容
</Badge>
</div>
</div>
{workflowDashboardMetrics.missingCardDetails.length > 0 ? (
<div className="max-h-72 space-y-2 overflow-y-auto pr-1">
{workflowDashboardMetrics.missingCardDetails.map((item) => (
<div key={`${item.stepKey}:${item.key}`} className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="break-words text-xs font-medium leading-5">
{item.stepPath} / {item.path}
</div>
<div className="mt-1 flex flex-wrap gap-1">
<Badge variant="outline">{getWorkflowLevelLabel(item.depth, "卡片")}</Badge>
<Badge variant={item.disabled ? "secondary" : "outline"}>
{item.disabled ? "开发中" : "已启用"}
</Badge>
<Badge variant="outline">{item.childCount} 子卡片</Badge>
{item.missingFields.map((field) => (
<Badge key={field} variant="outline">
缺{field}
</Badge>
))}
</div>
</div>
))}
</div>
) : (
<div className="rounded-lg border border-dashed bg-muted/20 px-3 py-6 text-center text-xs text-muted-foreground">
所有卡片的名称、Prompt、说明和预览图都已补齐。
</div>
)}
</div>
</CardContent>
</Card>
</motion.div>
);
};
const renderWorkflowStepDropZone = (
targetStepKey: string | null,
position: WorkflowStepDropPosition,
depth = 1
) => {
const active =
stepDropTarget?.stepKey === targetStepKey && stepDropTarget.position === position;
const visible = Boolean(draggedStepKey);
const label = targetStepKey
? position === "before"
? "放到此模块前面"
: position === "after"
? "放到此模块后面"
: "放入此模块作为子模块"
: "放到这里成为一级模块";
return (
<div
onDragOver={(event) => handleWorkflowStepDragOver(event, targetStepKey, position)}
onDrop={(event) => handleWorkflowStepDrop(event, targetStepKey, position)}
onDragLeave={(event) => {
event.stopPropagation();
if (active) {
setStepDropTarget(null);
}
}}
className={cn(
"transition-all",
visible
? "my-2 flex min-h-8 items-center rounded-lg border border-dashed px-3 text-xs"
: "h-1",
depth > 1 && "ml-4 sm:ml-6",
active
? "border-emerald-500 bg-emerald-500/10 text-emerald-700"
: visible
? "border-border/70 bg-muted/20 text-muted-foreground"
: "border-transparent"
)}
>
{visible ? label : null}
</div>
);
};
const renderWorkflowStepCard = (
step: AgentWorkflowStepUiConfig,
depth: number,
indexPath: number[],
siblingCount = 0,
parentStepKey: string | null = null,
previousSiblingStepKey: string | null = null
) => {
const stepLabel = indexPath.join(".");
const levelLabel = getWorkflowLevelLabel(depth, "模块");
const rootOptions = [...(step.options || [])].sort((left, right) => left.sortOrder - right.sortOrder);
const sortedChildSteps = [...(step.children || [])].sort(
(left, right) => left.sortOrder - right.sortOrder
);
const optionCardCount = countAgentWorkflowOptionTree(step.options || []);
const isTopLevelStep = depth === 1;
const canMoveTopLevelStepUp = isTopLevelStep && indexPath[0] > 1;
const canMoveTopLevelStepDown = isTopLevelStep && indexPath[0] < siblingCount;
const subtreeDepth = getWorkflowStepSubtreeDepth(step);
const canPromoteStep = Boolean(parentStepKey);
const canIndentStep =
Boolean(previousSiblingStepKey) && depth + subtreeDepth <= AGENT_WORKFLOW_MAX_TREE_DEPTH;
const canAddChildStep = depth < AGENT_WORKFLOW_MAX_TREE_DEPTH;
const canPasteAsChild =
Boolean(copiedWorkflowStep) &&
depth + getWorkflowStepSubtreeDepth(copiedWorkflowStep as AgentWorkflowStepUiConfig) <=
AGENT_WORKFLOW_MAX_TREE_DEPTH;
const insideDropActive =
stepDropTarget?.stepKey === step.key && stepDropTarget.position === "inside";
return (
<div key={step.key} className="space-y-1">
{renderWorkflowStepDropZone(step.key, "before", depth)}
<motion.div
variants={itemVariants}
className={cn(depth > 1 && "pl-4 sm:pl-6")}
>
<Collapsible
open={expandedSteps.has(step.key)}
onOpenChange={() => toggleStepExpansion(step.key)}
>
<Card
onDragOver={(event) => handleWorkflowStepDragOver(event, step.key, "inside")}
onDrop={(event) => handleWorkflowStepDrop(event, step.key, "inside")}
className={cn(
"transition-shadow",
!step.enabled && "opacity-60",
depth > 1 && "border-l-4 border-l-emerald-500/30",
insideDropActive && "ring-2 ring-emerald-500 ring-offset-2"
)}
>
<CardHeader className="py-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="flex items-center gap-3">
<button
type="button"
draggable
onDragStart={(event) => handleWorkflowStepDragStart(event, step.key)}
onDragEnd={handleWorkflowStepDragEnd}
className="flex h-8 w-8 cursor-grab items-center justify-center rounded-md border bg-background text-muted-foreground transition-colors hover:border-emerald-500/50 hover:text-emerald-600 active:cursor-grabbing"
title="拖动模块,可跨级调整父子关系"
>
<GripVertical className="h-4 w-4" />
</button>
<CollapsibleTrigger asChild>
<button className="flex h-8 w-8 items-center justify-center rounded-md hover:bg-muted">
{expandedSteps.has(step.key) ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
</CollapsibleTrigger>
<div className="flex h-9 min-w-9 items-center justify-center rounded-md bg-emerald-500/10 px-2 text-xs font-medium text-emerald-600">
{stepLabel}
</div>
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<BufferedInput
value={step.title}
onValueCommit={(value) =>
updateStep(step.key, (currentStep) => ({
...currentStep,
title: value,
}))
}
className="h-8 min-w-[180px] max-w-[260px] border-0 bg-transparent px-0 text-base font-medium shadow-none focus-visible:ring-0"
placeholder="模块标题"
/>
<Badge variant="outline">{levelLabel}</Badge>
<Badge variant="secondary">{sortedChildSteps.length} 子模块</Badge>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{step.key}
</code>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{optionCardCount} 张卡片</Badge>
<Badge variant="outline">卡片树结构</Badge>
{step.specialModuleEnabled ? <Badge variant="default">特殊模块</Badge> : null}
{isTopLevelStep ? (
<>
<div className="flex h-8 items-center gap-2 rounded-md border border-emerald-500/20 bg-emerald-500/5 px-2">
<span className="whitespace-nowrap text-xs font-medium text-emerald-700">
特殊模块
</span>
<Switch
checked={Boolean(step.specialModuleEnabled)}
onCheckedChange={(checked) =>
updateStep(step.key, (currentStep) => ({
...currentStep,
specialModuleEnabled: checked,
}))
}
className="scale-75"
aria-label={`设置模块 ${step.title || step.key} 为特殊模块`}
/>
</div>
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => handleMoveTopLevelStep(step.key, "up")}
disabled={!canMoveTopLevelStepUp}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => handleMoveTopLevelStep(step.key, "down")}
disabled={!canMoveTopLevelStepDown}
>
<ArrowDown className="h-4 w-4" />
</Button>
</>
) : null}
{canPromoteStep ? (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 px-2 text-xs"
onClick={() => handlePromoteWorkflowStep(step.key, parentStepKey)}
title="提升到父模块同级,例如三级模块变二级模块"
>
升一级
</Button>
) : null}
{canIndentStep ? (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 px-2 text-xs"
onClick={() => handleIndentWorkflowStep(step.key, previousSiblingStepKey)}
title="移入上一个同级模块下面,降低一级"
>
降一级
</Button>
) : null}
{canAddChildStep ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleAddChildStep(step.key, depth)}
>
<Plus className="mr-2 h-4 w-4" />
子模块
</Button>
) : null}
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => handleCopyWorkflowStep(step.key)}
title="复制模块及全部子模块"
>
<Copy className="h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={!canPasteAsChild}
onClick={() => handlePasteWorkflowStep(step.key)}
title="粘贴为当前模块的子模块"
>
<ClipboardPaste className="mr-2 h-4 w-4" />
粘贴
</Button>
<Switch
checked={step.enabled}
onCheckedChange={(checked) =>
updateStep(step.key, (currentStep) => ({
...currentStep,
enabled: checked,
}))
}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => handleRequestRemoveStep(step.key)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CollapsibleContent>
<CardContent className="border-t bg-muted/20 pt-4">
<div className="mb-4 space-y-2">
<Label className="text-xs">模块说明</Label>
<BufferedTextarea
value={step.description || ""}
onValueCommit={(value) =>
updateStep(step.key, (currentStep) => ({
...currentStep,
description: value,
}))
}
rows={2}
placeholder="描述此模块的用途..."
className="bg-background"
/>
</div>
<div className="mb-4 flex items-center justify-between">
<span className="text-sm font-medium">卡片配置</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleAddOption(step.key)}
>
<Plus className="mr-2 h-4 w-4" />
新增一级卡片
</Button>
</div>
{rootOptions.length > 0 ? (
<div className="space-y-4">
{rootOptions.map((option, optionIndex) =>
renderWorkflowOptionCard(step, option, 1, [optionIndex + 1])
)}
</div>
) : (
<div className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground">
暂无卡片,点击上方按钮添加
</div>
)}
<div className="mt-6 space-y-4 border-t pt-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">子模块配置</div>
<div className="text-xs text-muted-foreground">
支持拖拽到任意模块下,也可以用“升一级 / 降一级”快速调整层级,最多{" "}
{AGENT_WORKFLOW_MAX_TREE_DEPTH} 级。
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{copiedWorkflowStep ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={!canPasteAsChild}
onClick={() => handlePasteWorkflowStep(step.key)}
>
<ClipboardPaste className="mr-2 h-4 w-4" />
粘贴到这里
</Button>
) : null}
{canAddChildStep ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleAddChildStep(step.key, depth)}
>
<Plus className="mr-2 h-4 w-4" />
新增子模块
</Button>
) : (
<Badge variant="outline">{`已达 ${AGENT_WORKFLOW_MAX_TREE_DEPTH} 级`}</Badge>
)}
</div>
</div>
{sortedChildSteps.length > 0 ? (
<div className="space-y-3 border-l border-dashed border-border/80 pl-3 sm:pl-4">
{sortedChildSteps.map((childStep, childIndex) =>
renderWorkflowStepCard(
childStep,
depth + 1,
[...indexPath, childIndex + 1],
sortedChildSteps.length,
step.key,
childIndex > 0 ? sortedChildSteps[childIndex - 1].key : null
)
)}
</div>
) : (
<div className="rounded-lg border border-dashed bg-background/60 px-4 py-6 text-center text-sm text-muted-foreground">
暂无子模块,可拖入其它模块或点击新增子模块
</div>
)}
</div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
</motion.div>
{renderWorkflowStepDropZone(step.key, "after", depth)}
</div>
);
};
const updateBrandModelEntry = (
brandName: string,
updater: (
entry: NonNullable<AgentWorkflowBrandModelConfig["entries"][number]>
) => NonNullable<AgentWorkflowBrandModelConfig["entries"][number]>
) => {
setBrandModelConfig((prev) => {
const entryIndex = prev.entries.findIndex((entry) => entry.brandName === brandName);
const currentEntry =
entryIndex >= 0
? prev.entries[entryIndex]
: {
brandName,
images: [],
};
const nextEntry = updater(currentEntry);
const nextEntries =
entryIndex >= 0
? prev.entries.map((entry, index) => (index === entryIndex ? nextEntry : entry))
: [...prev.entries, nextEntry];
return {
entries: nextEntries
.filter((entry) => entry.images.length > 0 || configuredBrands.includes(entry.brandName))
.map((entry) => ({
...entry,
images: entry.images
.slice()
.sort((left, right) => left.sortOrder - right.sortOrder)
.map((image, index) => ({
...image,
sortOrder: index,
})),
})),
};
});
};
const handleUploadBrandModelImages = async (brandName: string, files: FileList | null) => {
const pickedFiles = Array.from(files || []).filter((file) => file.type.startsWith("image/"));
if (pickedFiles.length === 0) {
toast.error("请先选择图片文件");
return;
}
const refKey = `brand-model:${brandName}`;
try {
setUploadingKey(refKey);
const uploadedImages: AgentWorkflowBrandModelConfig["entries"][number]["images"] = [];
for (const file of pickedFiles) {
const formData = new FormData();
formData.append("file", file);
const response = await fetchUploadWithRetry(formData);
const result = await response.json();
if (!response.ok || !result?.url) {
throw new Error(result?.error || "上传失败");
}
uploadedImages.push({
id:
(typeof result.assetId === "string" && result.assetId.trim()) ||
`${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
assetId: typeof result.assetId === "string" ? result.assetId : null,
imageUrl: result.url,
displayName: file.name,
mimeType: file.type || null,
sortOrder: 0,
});
}
updateBrandModelEntry(brandName, (entry) => ({
...entry,
images: [
...entry.images,
...uploadedImages.map((image, index) => ({
...image,
sortOrder: entry.images.length + index,
})),
],
}));
toast.success(
uploadedImages.length === 1 ? "模特图上传成功" : `已上传 ${uploadedImages.length} 张模特图`
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "模特图上传失败");
} finally {
setUploadingKey(null);
}
};
const handleRemoveBrandModelImage = (brandName: string, imageId: string) => {
updateBrandModelEntry(brandName, (entry) => ({
...entry,
images: entry.images.filter((image) => image.id !== imageId),
}));
};
const handleUploadShowcaseAsset = async (
target: "video" | "poster",
file: File
) => {
const isVideo = target === "video";
const expectedPrefix = isVideo ? "video/" : "image/";
if (!file.type.startsWith(expectedPrefix)) {
toast.error(isVideo ? "请上传视频文件" : "请上传图片文件");
return;
}
const refKey = `showcase:${target}`;
try {
setUploadingKey(refKey);
const formData = new FormData();
formData.append("file", file);
const response = await fetchUploadWithRetry(formData);
const result = await response.json();
if (!response.ok || !result?.url) {
throw new Error(result?.error || "上传失败");
}
setShowcaseConfig((prev) => ({
...prev,
[target === "video" ? "videoUrl" : "posterUrl"]: result.url,
}));
toast.success(isVideo ? "视频海报上传成功" : "视频封面上传成功");
} catch (error) {
toast.error(error instanceof Error ? error.message : "上传失败");
} finally {
setUploadingKey(null);
}
};
if (isLoading) {
return (
<div className="container mx-auto p-6">
<div className="flex h-[60vh] items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span>加载配置中...</span>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-b from-background to-muted/20">
{/* Header */}
<header className="sticky top-0 z-30 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto px-6 py-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 shadow-lg shadow-violet-500/20">
<Sparkles className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-xl font-semibold tracking-tight">风格化工作流配置</h1>
<p className="text-sm text-muted-foreground">
配置风格化页面的视频海报、素材搜索、品牌模特及工作流步骤
</p>
</div>
</div>
<div className="flex items-center gap-2">
{autoDraftUpdatedAt ? (
<span className="hidden rounded-full border bg-background px-3 py-1 text-xs text-muted-foreground lg:inline-flex">
{autoDraftStatus === "restored" ? "已恢复本地草稿" : "本地草稿已自动保存"} ·{" "}
{new Date(autoDraftUpdatedAt).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
</span>
) : null}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRestorePreservedDraft}
>
{recoverableDraftUpdatedAt ? "恢复旧草稿" : "查找旧草稿"}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setSnapshotDialogOpen(true)}
>
<RotateCcw className="mr-2 h-4 w-4" />
快照中心
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/settings/agent-workflow/psd-templates">
<Layers className="mr-2 h-4 w-4" />
PSD模板
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={backHref}>
<ArrowLeft className="mr-2 h-4 w-4" />
{backLabel}
</Link>
</Button>
<Button
onClick={handleSave}
disabled={isSaving}
size="sm"
className="bg-gradient-to-r from-violet-600 to-purple-600 hover:from-violet-500 hover:to-purple-500"
>
{isSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
保存配置
</Button>
</div>
</div>
</div>
</header>
<Dialog open={snapshotDialogOpen} onOpenChange={setSnapshotDialogOpen}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>风格化工作流快照中心</DialogTitle>
<DialogDescription>
查看最近快照、手动创建安全点,并一键恢复到指定版本。恢复前系统会自动再备份一次当前状态。
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 rounded-2xl border bg-muted/20 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">当前共有 {snapshotsData?.snapshots?.length || 0} 条最近快照</div>
<div className="text-xs text-muted-foreground">
自动快照会在高风险保存前生成,手动快照适合在批量编辑前先留一个恢复点。
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => mutateSnapshots()}
disabled={isLoadingSnapshots}
>
<RefreshCw
className={cn("mr-2 h-4 w-4", isLoadingSnapshots && "animate-spin")}
/>
刷新
</Button>
<Button
type="button"
size="sm"
onClick={handleCreateSnapshot}
disabled={isCreatingSnapshot}
>
{isCreatingSnapshot ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
创建快照
</Button>
</div>
</div>
<div className="max-h-[60vh] space-y-3 overflow-y-auto pr-1">
{isLoadingSnapshots ? (
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
正在加载快照列表...
</div>
) : (snapshotsData?.snapshots?.length || 0) === 0 ? (
<div className="flex h-40 items-center justify-center rounded-2xl border border-dashed text-sm text-muted-foreground">
还没有可用快照,先创建一个安全点吧。
</div>
) : (
(snapshotsData?.snapshots || []).map((snapshot, index) => (
<div
key={snapshot.id}
className="rounded-2xl border bg-background p-4 shadow-sm"
>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={index === 0 ? "default" : "secondary"}>
{index === 0 ? "最新" : `#${index + 1}`}
</Badge>
<span className="font-medium">
{snapshot.label || "未命名快照"}
</span>
<span className="font-mono text-xs text-muted-foreground">
{snapshot.id.slice(0, 8)}
</span>
</div>
<div className="text-sm text-muted-foreground">
{new Date(snapshot.createdAt).toLocaleString("zh-CN")}
{" · "}
{snapshot.createdByName ||
snapshot.createdByEmail ||
"系统自动创建"}
</div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="rounded-full border px-2 py-1">
配置键 {snapshot.summary?.keyCount ?? "-"}
</span>
<span className="rounded-full border px-2 py-1">
站点字段 {snapshot.summary?.siteFieldCount ?? 0}
</span>
<span className="rounded-full border px-2 py-1">
非空项 {snapshot.summary?.nonNullKeyCount ?? "-"}
</span>
<span className="rounded-full border px-2 py-1">
大小 {formatSnapshotByteLength(snapshot.summary?.byteLength)}
</span>
</div>
</div>
<Button
type="button"
variant="outline"
onClick={() => handleRestoreSnapshot(snapshot)}
disabled={restoringSnapshotId === snapshot.id}
>
{restoringSnapshotId === snapshot.id ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RotateCcw className="mr-2 h-4 w-4" />
)}
恢复到此版本
</Button>
</div>
</div>
))
)}
</div>
</DialogContent>
</Dialog>
{/* Stats Bar */}
<div className="border-b bg-muted/30">
<div className="container mx-auto px-6 py-3">
<div className="flex flex-wrap items-center gap-6">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="font-mono">
{enabledStepsCount}
</Badge>
<span className="text-sm text-muted-foreground">已启用步骤</span>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="font-mono">
{totalOptionsCount}
</Badge>
<span className="text-sm text-muted-foreground">已启用选项</span>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="font-mono">
{enabledPresetLibrarySectionCount}
</Badge>
<span className="text-sm text-muted-foreground">分类页</span>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="font-mono">
{totalPresetLibraryNodeCount}
</Badge>
<span className="text-sm text-muted-foreground">分类树节点</span>
</div>
<div className="flex items-center gap-2">
<Badge
variant={materialApiConfig.enabled ? "default" : "secondary"}
className={cn(
"font-mono",
materialApiConfig.enabled && "bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20"
)}
>
{materialApiConfig.enabled ? "已开启" : "已关闭"}
</Badge>
<span className="text-sm text-muted-foreground">素材搜索</span>
</div>
<div className="flex items-center gap-2">
<Badge
variant={showcaseConfig.enabled ? "default" : "secondary"}
className={cn(
"font-mono",
showcaseConfig.enabled && "bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20"
)}
>
{showcaseConfig.enabled ? "已开启" : "已关闭"}
</Badge>
<span className="text-sm text-muted-foreground">顶部视频</span>
</div>
</div>
</div>
</div>
{/* Main Content */}
<main className="container mx-auto p-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
<TabsList className="grid w-full grid-cols-5 lg:w-fit">
<TabsTrigger value="showcase" className="gap-2">
<MonitorPlay className="h-4 w-4" />
<span className="hidden sm:inline">顶部海报</span>
</TabsTrigger>
<TabsTrigger value="basics" className="gap-2">
<Settings2 className="h-4 w-4" />
<span className="hidden sm:inline">基础配置</span>
</TabsTrigger>
<TabsTrigger value="models" className="gap-2">
<UserCircle className="h-4 w-4" />
<span className="hidden sm:inline">品牌模特</span>
<Badge variant="secondary" className="ml-1 h-5 px-1.5 text-xs">
{brandModelConfig.entries.reduce((acc, e) => acc + e.images.length, 0)}
</Badge>
</TabsTrigger>
<TabsTrigger value="categories" className="gap-2">
<Package className="h-4 w-4" />
<span className="hidden sm:inline">生图分类</span>
<Badge variant="secondary" className="ml-1 h-5 px-1.5 text-xs">
{sortedPresetLibrarySections.length}
</Badge>
</TabsTrigger>
<TabsTrigger value="workflow" className="gap-2">
<Layers className="h-4 w-4" />
<span className="hidden sm:inline">工作流步骤</span>
<Badge variant="secondary" className="ml-1 h-5 px-1.5 text-xs">
{sortedSteps.length}
</Badge>
</TabsTrigger>
</TabsList>
{/* Showcase Tab */}
<TabsContent value="showcase" className="space-y-6">
<motion.div
initial="hidden"
animate="visible"
variants={containerVariants}
className="grid gap-6 lg:grid-cols-[1fr_400px]"
>
<motion.div variants={itemVariants} className="space-y-6">
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500/10">
<Video className="h-4 w-4 text-violet-600" />
</div>
<div>
<CardTitle className="text-base">顶部视频海报</CardTitle>
<CardDescription>
配置第一步顶部展示区的视频海报内容
</CardDescription>
</div>
</div>
<Switch
checked={showcaseConfig.enabled}
onCheckedChange={(checked) =>
setShowcaseConfig((prev) => ({ ...prev, enabled: checked }))
}
/>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs">顶部主标题</Label>
<Input
value={showcaseConfig.title}
onChange={(e) =>
setShowcaseConfig((prev) => ({ ...prev, title: e.target.value }))
}
placeholder="三倍速出片,电影级质感"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">顶部副标题</Label>
<Input
value={showcaseConfig.subtitle}
onChange={(e) =>
setShowcaseConfig((prev) => ({ ...prev, subtitle: e.target.value }))
}
placeholder="选择品牌、配置风格化提示词、选择生图模式并查看结果"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs">按钮文案</Label>
<Input
value={showcaseConfig.actionText}
onChange={(e) =>
setShowcaseConfig((prev) => ({ ...prev, actionText: e.target.value }))
}
placeholder="我要创作"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">海报底部说明</Label>
<Input
value={showcaseConfig.footerText || ""}
onChange={(e) =>
setShowcaseConfig((prev) => ({ ...prev, footerText: e.target.value }))
}
placeholder="用户作品"
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs">海报叠加文字</Label>
<Textarea
value={showcaseConfig.overlayText || ""}
onChange={(e) =>
setShowcaseConfig((prev) => ({ ...prev, overlayText: e.target.value }))
}
rows={2}
placeholder="让每一步选择,都能直达高质感成片"
/>
</div>
<Separator />
<div className="space-y-4">
<Label className="text-xs">媒体文件</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Input
value={showcaseConfig.videoUrl}
onChange={(e) =>
setShowcaseConfig((prev) => ({ ...prev, videoUrl: e.target.value }))
}
placeholder="视频链接 https://..."
/>
<input
ref={(el) => { fileInputRefs.current["showcase:video"] = el; }}
type="file"
accept="video/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleUploadShowcaseAsset("video", file);
e.target.value = "";
}}
/>
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
disabled={uploadingKey === "showcase:video"}
onClick={() => fileInputRefs.current["showcase:video"]?.click()}
>
{uploadingKey === "showcase:video" ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传视频
</Button>
</div>
<div className="space-y-2">
<Input
value={showcaseConfig.posterUrl || ""}
onChange={(e) =>
setShowcaseConfig((prev) => ({ ...prev, posterUrl: e.target.value }))
}
placeholder="封面链接 https://..."
/>
<input
ref={(el) => { fileInputRefs.current["showcase:poster"] = el; }}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleUploadShowcaseAsset("poster", file);
e.target.value = "";
}}
/>
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
disabled={uploadingKey === "showcase:poster"}
onClick={() => fileInputRefs.current["showcase:poster"]?.click()}
>
{uploadingKey === "showcase:poster" ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传封面
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</motion.div>
{/* Preview Panel */}
<motion.div variants={itemVariants}>
<Card className="sticky top-24">
<CardHeader className="pb-4">
<CardTitle className="text-base">前台预览</CardTitle>
<CardDescription>实时查看海报展示效果</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-hidden rounded-2xl border bg-slate-950 shadow-2xl">
<div className="relative aspect-[21/9] w-full overflow-hidden bg-black">
{showcaseConfig.videoUrl ? (
<video
key={showcaseConfig.videoUrl}
src={showcaseConfig.videoUrl}
poster={showcaseConfig.posterUrl || undefined}
className="h-full w-full object-cover"
autoPlay
muted
loop
playsInline
/>
) : showcaseConfig.posterUrl ? (
<img
src={showcaseConfig.posterUrl}
alt={showcaseConfig.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 text-white/30">
<ImageIcon className="h-8 w-8" />
<span className="text-xs">暂无媒体</span>
</div>
)}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-black/40" />
{showcaseConfig.overlayText && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center px-6 text-center">
<span className="text-lg font-semibold tracking-widest text-white/90 drop-shadow-lg">
{showcaseConfig.overlayText}
</span>
</div>
)}
{showcaseConfig.footerText && (
<div className="pointer-events-none absolute bottom-4 left-4">
<span className="rounded-full border border-white/20 bg-black/40 px-3 py-1 text-xs text-white/80 backdrop-blur-sm">
{showcaseConfig.footerText}
</span>
</div>
)}
</div>
</div>
<div className="mt-4 space-y-3 rounded-xl border bg-muted/30 p-4">
<div>
<span className="text-xs text-muted-foreground">主标题</span>
<p className="font-medium">{showcaseConfig.title || "未设置"}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">副标题</span>
<p className="text-sm">{showcaseConfig.subtitle || "未设置"}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">按钮</span>
<p className="text-sm">{showcaseConfig.actionText || "未设置"}</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
</motion.div>
</TabsContent>
{/* Basics Tab */}
<TabsContent value="basics" className="space-y-6">
<motion.div
initial="hidden"
animate="visible"
variants={containerVariants}
className="grid gap-6 lg:grid-cols-2"
>
<motion.div variants={itemVariants} className="lg:col-span-2">
<Card>
<CardHeader className="pb-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500/10">
<Settings2 className="h-4 w-4 text-violet-600" />
</div>
<div>
<CardTitle className="text-base">前台流程功能开关</CardTitle>
<CardDescription>
控制风格化页面是否展示固定生图模式、素材选择和套 PSD 模板入口
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-4">
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div className="pr-4">
<div className="text-sm font-medium">开启固定生图模式</div>
<div className="text-xs text-muted-foreground">
关闭后前台不展示主图/SKU图、详情和买家秀模式入口,直接进入后续配置并选择模型生图。
</div>
</div>
<Switch
checked={workflowPageFeatureSettings.presetStepEnabled}
onCheckedChange={(checked) =>
setConfig((prev) => ({
...prev,
pageFeatureSettings: {
...(prev.pageFeatureSettings ||
defaultAgentWorkflowPageFeatureSettings),
presetStepEnabled: checked,
},
}))
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div className="pr-4">
<div className="text-sm font-medium">开启素材选择</div>
<div className="text-xs text-muted-foreground">
关闭后前台跳过商品素材搜索,不再强制补齐 SPU / SKU 输入图槽位。
</div>
</div>
<Switch
checked={workflowPageFeatureSettings.materialStepEnabled}
onCheckedChange={(checked) =>
setConfig((prev) => ({
...prev,
pageFeatureSettings: {
...(prev.pageFeatureSettings ||
defaultAgentWorkflowPageFeatureSettings),
materialStepEnabled: checked,
},
}))
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div className="pr-4">
<div className="text-sm font-medium">开启套 PSD 模板</div>
<div className="text-xs text-muted-foreground">
关闭后前台隐藏所有“套PSD”按钮和 PSD 模板编辑弹窗入口。
</div>
</div>
<Switch
checked={workflowPageFeatureSettings.psdTemplateEnabled}
onCheckedChange={(checked) =>
setConfig((prev) => ({
...prev,
pageFeatureSettings: {
...(prev.pageFeatureSettings ||
defaultAgentWorkflowPageFeatureSettings),
psdTemplateEnabled: checked,
},
}))
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div className="pr-4">
<div className="text-sm font-medium">开启批量处理</div>
<div className="text-xs text-muted-foreground">
关闭后前台顶部导航和无限画布工具栏都隐藏“批量处理”入口。
</div>
</div>
<Switch
checked={workflowPageFeatureSettings.batchProcessingEnabled}
onCheckedChange={(checked) =>
setConfig((prev) => ({
...prev,
pageFeatureSettings: {
...(prev.pageFeatureSettings ||
defaultAgentWorkflowPageFeatureSettings),
batchProcessingEnabled: checked,
},
}))
}
/>
</div>
</CardContent>
</Card>
</motion.div>
<motion.div variants={itemVariants} className="lg:col-span-2">
<Card>
<CardHeader className="pb-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-500/10">
<Layers className="h-4 w-4 text-cyan-600" />
</div>
<div>
<CardTitle className="text-base">风格化固定模式配置</CardTitle>
<CardDescription>
前台固定展示主图/SKU图、详情和买家秀三个业务模式;图标优先使用管理员上传的 SVG。
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-xl border bg-background/70 p-4">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(260px,360px)] lg:items-center">
<div>
<div className="text-sm font-semibold">风格化专用默认生图模型</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
当前台业务模式没有单独指定模型时,会优先使用这里的风格化专用模型;未设置时再沿用前台通用默认生图模型。
</p>
</div>
<Select
value={
agentWorkflowDefaultModelId ||
BUSINESS_MODE_DEFAULT_MODEL_EMPTY_VALUE
}
onValueChange={(value) =>
setAgentWorkflowDefaultModelId(
value === BUSINESS_MODE_DEFAULT_MODEL_EMPTY_VALUE
? null
: value
)
}
>
<SelectTrigger>
<SelectValue placeholder="选择风格化默认生图模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value={BUSINESS_MODE_DEFAULT_MODEL_EMPTY_VALUE}>
沿用前台通用默认模型
</SelectItem>
{agentWorkflowDefaultModelId &&
!workflowImageModelLabelById.has(agentWorkflowDefaultModelId) ? (
<SelectItem value={agentWorkflowDefaultModelId}>
已配置模型不存在或已停用({agentWorkflowDefaultModelId})
</SelectItem>
) : null}
{workflowImageModelOptions.map((model) => (
<SelectItem key={model.id} value={model.id}>
{workflowImageModelLabelById.get(model.id) ||
model.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-3">
{workflowBusinessModeSettings.modes.map((mode) => {
const refKey = `business-mode:${mode.key}`;
const isUploading = uploadingKey === refKey;
const Icon =
mode.key === "detail"
? Layers
: mode.key === "buyer_show"
? UserCircle
: Package;
return (
<div key={mode.key} className="space-y-4 rounded-xl border bg-muted/20 p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl border bg-background">
{mode.customSvgUrl ? (
<img
src={mode.customSvgUrl}
alt={mode.title}
className="h-7 w-7 object-contain"
/>
) : (
<Icon className="h-6 w-6 text-muted-foreground" />
)}
</div>
<div>
<div className="text-sm font-semibold">{mode.title}</div>
<div className="text-xs text-muted-foreground">
{mode.key === "buyer_show" ? "预留模块" : "已接入前台入口"}
</div>
</div>
</div>
<Switch
checked={mode.enabled}
onCheckedChange={(checked) =>
updateBusinessMode(mode.key, (current) => ({
...current,
enabled: checked,
}))
}
/>
</div>
<div className="space-y-2">
<Label>显示名称</Label>
<Input
value={mode.title}
onChange={(event) =>
updateBusinessMode(mode.key, (current) => ({
...current,
title: event.target.value,
}))
}
/>
</div>
<div className="space-y-2">
<Label>说明文案</Label>
<Textarea
value={mode.description || ""}
onChange={(event) =>
updateBusinessMode(mode.key, (current) => ({
...current,
description: event.target.value,
}))
}
className="min-h-[90px]"
/>
</div>
<div className="space-y-2">
<Label>默认生图模型</Label>
<Select
value={
mode.defaultModelId ||
BUSINESS_MODE_DEFAULT_MODEL_EMPTY_VALUE
}
onValueChange={(value) =>
updateBusinessMode(mode.key, (current) => ({
...current,
defaultModelId:
value === BUSINESS_MODE_DEFAULT_MODEL_EMPTY_VALUE
? null
: value,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="选择默认生图模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value={BUSINESS_MODE_DEFAULT_MODEL_EMPTY_VALUE}>
沿用风格化专用默认模型
</SelectItem>
{mode.defaultModelId &&
!workflowImageModelLabelById.has(mode.defaultModelId) ? (
<SelectItem value={mode.defaultModelId}>
已配置模型不存在或已停用({mode.defaultModelId})
</SelectItem>
) : null}
{workflowImageModelOptions.map((model) => (
<SelectItem key={model.id} value={model.id}>
{workflowImageModelLabelById.get(model.id) ||
model.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
用户选择该业务模式后,会优先使用这里配置的模型;未配置时沿用上方风格化专用默认模型。
</p>
</div>
{mode.key === "detail" ? (
<div className="grid gap-2 rounded-lg border bg-background/60 p-3">
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-muted-foreground">详情图替换</span>
<Switch
checked={mode.referenceReplaceEnabled !== false}
onCheckedChange={(checked) =>
updateBusinessMode(mode.key, (current) => ({
...current,
referenceReplaceEnabled: checked,
}))
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-muted-foreground">详情图生成</span>
<Switch
checked={mode.aiDetailGenerateEnabled !== false}
onCheckedChange={(checked) =>
updateBusinessMode(mode.key, (current) => ({
...current,
aiDetailGenerateEnabled: checked,
}))
}
/>
</div>
</div>
) : null}
<div className="space-y-2">
<Label>自定义 SVG 图标</Label>
<input
ref={(el) => {
fileInputRefs.current[refKey] = el;
}}
type="file"
accept=".svg,image/svg+xml"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
void handleUploadBusinessModeSvg(mode.key, file);
}
event.target.value = "";
}}
/>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={isUploading}
onClick={() => fileInputRefs.current[refKey]?.click()}
>
{isUploading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传 SVG
</Button>
{mode.customSvgUrl ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
updateBusinessMode(mode.key, (current) => ({
...current,
customSvgUrl: "",
}))
}
>
清除
</Button>
) : null}
</div>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
</motion.div>
<motion.div variants={itemVariants} className="lg:col-span-2">
<Card>
<CardHeader className="pb-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500/10">
<Search className="h-4 w-4 text-emerald-600" />
</div>
<div>
<CardTitle className="text-base">第三方图片素材配置</CardTitle>
<CardDescription>
配置白底图、任务-详情整套预览、详情参考图和 SKU 参考图共用接口
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div>
<div className="text-sm font-medium">启用第三方图片素材</div>
<div className="text-xs text-muted-foreground">
开启后前台和预留接口都可以查询标准模型白底图与任务-详情整套素材
</div>
</div>
<Switch
checked={materialApiConfig.enabled}
onCheckedChange={(checked) =>
setMaterialApiConfig((prev) => ({ ...prev, enabled: checked }))
}
/>
</div>
<div className="space-y-2">
<Label className="text-xs">接口基地址</Label>
<Input
value={materialApiConfig.baseUrl}
onChange={(e) =>
setMaterialApiConfig((prev) => ({ ...prev, baseUrl: e.target.value }))
}
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<Label className="text-xs">统一接口路径</Label>
<Input
value={materialApiConfig.endpointPath || "/standard-model-white-background"}
onChange={(e) =>
setMaterialApiConfig((prev) => ({
...prev,
endpointPath: e.target.value,
}))
}
placeholder="/standard-model-white-background"
/>
<p className="text-xs text-muted-foreground">
按文档默认调用 /api/external/standard-model-white-background;也支持填写完整 URL。
</p>
</div>
<div className="space-y-2">
<Label className="text-xs">API Key</Label>
<Input
type="password"
value={materialApiConfig.apiKey}
onChange={(e) =>
setMaterialApiConfig((prev) => ({ ...prev, apiKey: e.target.value }))
}
placeholder="输入 API Key"
/>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div>
<div className="text-sm font-medium">标准模型白底图</div>
<div className="text-xs text-muted-foreground">
对应 resourceType=white-background
</div>
</div>
<Switch
checked={materialApiConfig.whiteBackgroundEnabled !== false}
onCheckedChange={(checked) =>
setMaterialApiConfig((prev) => ({
...prev,
whiteBackgroundEnabled: checked,
}))
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div>
<div className="text-sm font-medium">任务-详情整套预览</div>
<div className="text-xs text-muted-foreground">
对应 resourceType=task-detail
</div>
</div>
<Switch
checked={materialApiConfig.taskDetailEnabled !== false}
onCheckedChange={(checked) =>
setMaterialApiConfig((prev) => ({
...prev,
taskDetailEnabled: checked,
}))
}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label className="text-xs">任务详情资源主域名</Label>
<Input
value={materialApiConfig.resourceBaseUrl || "https://s3.jiansun.vip"}
onChange={(e) =>
setMaterialApiConfig((prev) => ({
...prev,
resourceBaseUrl: e.target.value,
}))
}
placeholder="https://s3.jiansun.vip"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">备用资源域名</Label>
<Input
value={materialApiConfig.fallbackResourceBaseUrl || "https://s3.yydd.ai"}
onChange={(e) =>
setMaterialApiConfig((prev) => ({
...prev,
fallbackResourceBaseUrl: e.target.value,
}))
}
placeholder="https://s3.yydd.ai"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">请求超时 ms</Label>
<Input
type="number"
min={3000}
max={120000}
step={1000}
value={materialApiConfig.requestTimeoutMs || 30000}
onChange={(e) =>
setMaterialApiConfig((prev) => ({
...prev,
requestTimeoutMs: Number(e.target.value) || 30000,
}))
}
/>
</div>
</div>
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
已预留前端接口:
<code className="mx-1 rounded bg-muted px-1 py-0.5">
/api/third-party-image-materials/search
</code>
,支持
<code className="mx-1 rounded bg-muted px-1 py-0.5">
resourceType=white-background|task-detail
</code>
与
<code className="mx-1 rounded bg-muted px-1 py-0.5">
sku/spu
</code>
查询。
</div>
<Separator />
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div>
<div className="text-sm font-medium">启用商品ID详情图接口</div>
<div className="text-xs text-muted-foreground">
详情模式可按商品ID和渠道拉取参考详情图,密钥仅保存在服务端配置中
</div>
</div>
<Switch
checked={materialApiConfig.productDetailEnabled === true}
onCheckedChange={(checked) =>
setMaterialApiConfig((prev) => ({
...prev,
productDetailEnabled: checked,
}))
}
/>
</div>
<div className="space-y-2">
<Label className="text-xs">商品ID详情图接口基地址</Label>
<Input
value={materialApiConfig.productDetailBaseUrl || ""}
onChange={(e) =>
setMaterialApiConfig((prev) => ({
...prev,
productDetailBaseUrl: e.target.value,
}))
}
placeholder="http://服务地址:端口"
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs">调用方式</Label>
<Select
value={materialApiConfig.productDetailMethod || "GET"}
onValueChange={(value) =>
setMaterialApiConfig((prev) => ({
...prev,
productDetailMethod: value === "POST" ? "POST" : "GET",
}))
}
>
<SelectTrigger>
<SelectValue placeholder="选择调用方式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET /open-api/detail-images</SelectItem>
<SelectItem value="POST">POST /open-api/detail-images/query</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs">默认渠道</Label>
<Select
value={materialApiConfig.productDetailDefaultChannel || "taobao"}
onValueChange={(value) =>
setMaterialApiConfig((prev) => ({
...prev,
productDetailDefaultChannel: value as AgentWorkflowProductDetailChannel,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="选择默认渠道" />
</SelectTrigger>
<SelectContent>
<SelectItem value="taobao">淘宝 taobao</SelectItem>
<SelectItem value="1688">1688</SelectItem>
<SelectItem value="douyin">抖音 douyin</SelectItem>
<SelectItem value="jd">京东 jd</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs">接口路径 / 完整地址</Label>
<Input
value={materialApiConfig.productDetailPath || ""}
onChange={(e) =>
setMaterialApiConfig((prev) => ({
...prev,
productDetailPath: e.target.value,
}))
}
placeholder="/open-api/detail-images"
/>
<p className="text-xs text-muted-foreground">
GET 默认使用 /open-api/detail-images,POST 默认使用 /open-api/detail-images/query;也支持 {"{{item_id}}"}、{"{{productId}}"}、{"{{channel}}"} 占位符。
</p>
</div>
<div className="space-y-2">
<Label className="text-xs">商品ID接口 API Key</Label>
<Input
type="password"
value={materialApiConfig.productDetailApiKey || ""}
onChange={(e) =>
setMaterialApiConfig((prev) => ({
...prev,
productDetailApiKey: e.target.value,
}))
}
placeholder="留空则复用上方 API Key"
/>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div>
<div className="text-sm font-medium">同步分析补全</div>
<div className="text-xs text-muted-foreground">
默认随查询触发详情分析并回写
</div>
</div>
<Switch
checked={materialApiConfig.productDetailSyncAnalysis !== false}
onCheckedChange={(checked) =>
setMaterialApiConfig((prev) => ({
...prev,
productDetailSyncAnalysis: checked,
}))
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div>
<div className="text-sm font-medium">强制重跑分析</div>
<div className="text-xs text-muted-foreground">
通常保持关闭,只在管理员排查时开启
</div>
</div>
<Switch
checked={materialApiConfig.productDetailForceReanalyze === true}
onCheckedChange={(checked) =>
setMaterialApiConfig((prev) => ({
...prev,
productDetailForceReanalyze: checked,
}))
}
/>
</div>
</div>
</CardContent>
</Card>
</motion.div>
<motion.div variants={itemVariants} className="lg:col-span-2">
<Card>
<CardHeader className="pb-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-500/10">
<Package className="h-4 w-4 text-cyan-600" />
</div>
<div>
<CardTitle className="text-base">SKU 商品信息接口</CardTitle>
<CardDescription>配置货号 / 条码查询商品资料接口</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div>
<div className="text-sm font-medium">启用商品信息查询</div>
<div className="text-xs text-muted-foreground">
开启后 SKU 查询结果会显示商品名称、尺寸、材质、设计师、采购负责人、板材类型
</div>
</div>
<Switch
checked={goodsInfoApiConfig.enabled}
onCheckedChange={(checked) =>
setGoodsInfoApiConfig((prev) => ({ ...prev, enabled: checked }))
}
/>
</div>
<div className="space-y-2">
<Label className="text-xs">接口基地址</Label>
<Input
value={goodsInfoApiConfig.baseUrl}
onChange={(e) =>
setGoodsInfoApiConfig((prev) => ({ ...prev, baseUrl: e.target.value }))
}
placeholder="https://www.jiansun.vip:63211"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs">Client Key</Label>
<Input
value={goodsInfoApiConfig.clientKey}
onChange={(e) =>
setGoodsInfoApiConfig((prev) => ({
...prev,
clientKey: e.target.value,
}))
}
placeholder="例如:yunyingb"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">Secret</Label>
<Input
type="password"
value={goodsInfoApiConfig.secret}
onChange={(e) =>
setGoodsInfoApiConfig((prev) => ({
...prev,
secret: e.target.value,
}))
}
placeholder="输入 Secret"
/>
</div>
</div>
</CardContent>
</Card>
</motion.div>
<motion.div variants={itemVariants} className="lg:col-span-2">
<Card>
<CardHeader className="pb-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/10">
<Package className="h-4 w-4 text-amber-600" />
</div>
<div>
<CardTitle className="text-base">基础下拉选项</CardTitle>
<CardDescription>
配置{firstCategoryLabel}、{secondCategoryLabel}与分类层级关系,以及第一页可选的价格带区间
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-xs">层级树</Label>
<p className="text-xs text-muted-foreground">
支持一键下载模板并导入{firstCategoryLabel}、{secondCategoryLabel}、{thirdCategoryLabel}、{fourthCategoryLabel}、{fifthCategoryLabel}表格
</p>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<span className="text-xs text-muted-foreground">
{brandStoreEntries.length} 个{firstCategoryLabel}
</span>
<Button
type="button"
variant="outline"
onClick={handleDownloadBrandTemplate}
className="h-8"
>
<Download className="mr-2 h-4 w-4" />
下载模板
</Button>
<Button
type="button"
variant="outline"
onClick={() => brandImportInputRef.current?.click()}
className="h-8"
disabled={isImportingBrandSheet}
>
{isImportingBrandSheet ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
导入表格
</Button>
<Button
type="button"
variant="outline"
onClick={handleClearBrandTree}
className="h-8 border-destructive/25 text-destructive hover:border-destructive/40 hover:bg-destructive/10 hover:text-destructive"
disabled={brandStoreEntries.length === 0}
>
<Trash2 className="mr-2 h-4 w-4" />
清空当前数据
</Button>
</div>
</div>
<input
ref={brandImportInputRef}
type="file"
accept={AGENT_WORKFLOW_BRAND_IMPORT_ACCEPT}
className="hidden"
onChange={handleBrandSheetImport}
/>
<div className="grid grid-cols-1 gap-3 rounded-xl border bg-background/70 p-3 md:grid-cols-2 xl:grid-cols-5">
<div className="space-y-2">
<Label className="text-xs">第一级名称</Label>
<Input
value={firstCategoryLabel}
onChange={(e) => handleCategoryLabelChange("firstCategory", e.target.value)}
placeholder="例如:品牌分类"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">第二级名称</Label>
<Input
value={secondCategoryLabel}
onChange={(e) => handleCategoryLabelChange("secondCategory", e.target.value)}
placeholder="例如:门店分类"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">第三级名称</Label>
<Input
value={thirdCategoryLabel}
onChange={(e) => handleCategoryLabelChange("thirdCategory", e.target.value)}
placeholder="例如:系列分类"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">第四级名称</Label>
<Input
value={fourthCategoryLabel}
onChange={(e) => handleCategoryLabelChange("fourthCategory", e.target.value)}
placeholder="例如:产品类型"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">第五级名称</Label>
<Input
value={fifthCategoryLabel}
onChange={(e) => handleCategoryLabelChange("fifthCategory", e.target.value)}
placeholder="例如:细分类目"
/>
</div>
</div>
<div className="rounded-xl border border-sky-500/20 bg-sky-500/5 p-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<Label className="text-xs">官方二级 / 三级品类</Label>
<p className="text-xs leading-5 text-muted-foreground">
前台“选择品类”会使用这里的官方品类;工作流任意层级卡片的“品类可用范围”也会从这里选择。
</p>
</div>
<Badge variant="outline">{officialCategoryChoices.length} 个可用范围</Badge>
</div>
<div className="mt-3 grid gap-2 md:grid-cols-2">
{officialCategoryEntries.map((entry) => (
<div
key={entry.categoryName}
className="rounded-lg border bg-background/80 p-3"
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold">{entry.categoryName}</span>
<Badge variant="secondary">
{(entry.thirdCategories || []).length} 个三级品类
</Badge>
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{(entry.thirdCategories || []).map((thirdCategory) => (
<span
key={`${entry.categoryName}-${thirdCategory}`}
className="rounded-full border bg-muted/30 px-2 py-1 text-[11px] text-muted-foreground"
>
{thirdCategory}
</span>
))}
</div>
</div>
))}
</div>
</div>
<div className="rounded-xl border bg-muted/10 p-3">
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={brandDraft}
onChange={(e) => setBrandDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddBrandEntry();
}
}}
placeholder={`输入一个${firstCategoryLabel}名称后点击添加`}
/>
<Button
type="button"
onClick={handleAddBrandEntry}
className="shrink-0"
>
<Plus className="mr-2 h-4 w-4" />
添加{firstCategoryLabel}
</Button>
</div>
{brandStoreEntries.length > 0 ? (
<div className="mt-4 space-y-4">
{brandStoreEntries.map((entry) => (
<div key={entry.brandName} className="rounded-xl border bg-background/80 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<div className="inline-flex items-center gap-2 rounded-full border bg-background px-3 py-1.5 text-sm font-medium">
<span>{entry.brandName}</span>
<button
type="button"
onClick={() => handleRemoveBrandEntry(entry.brandName)}
className="rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
aria-label={`删除${firstCategoryLabel} ${entry.brandName}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<span className="text-xs text-muted-foreground">
{(entry.storeEntries || []).length} 个{secondCategoryLabel}
</span>
</div>
</div>
<div className="mt-3 rounded-xl border bg-muted/20 p-3">
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={storeDrafts[entry.brandName] || ""}
onChange={(e) =>
setStoreDrafts((prev) => ({
...prev,
[entry.brandName]: e.target.value,
}))
}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddStoreToBrand(entry.brandName);
}
}}
placeholder={`给${firstCategoryLabel}“${entry.brandName}”添加${secondCategoryLabel}`}
/>
<Button
type="button"
onClick={() => handleAddStoreToBrand(entry.brandName)}
className="shrink-0"
variant="outline"
>
<Plus className="mr-2 h-4 w-4" />
添加{secondCategoryLabel}
</Button>
</div>
{(entry.storeEntries || []).length > 0 ? (
<div className="mt-3 space-y-3">
{(entry.storeEntries || []).map((storeEntry) => {
const draftKey = `${entry.brandName}::${storeEntry.storeName}`;
return (
<div
key={`${entry.brandName}-${storeEntry.storeName}`}
className="rounded-xl border bg-background/80 p-3"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<div className="inline-flex items-center gap-2 rounded-full border bg-background px-3 py-1.5 text-sm font-medium">
<span>{storeEntry.storeName}</span>
<button
type="button"
onClick={() =>
handleRemoveStoreFromBrand(
entry.brandName,
storeEntry.storeName
)
}
className="rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
aria-label={`删除${secondCategoryLabel} ${storeEntry.storeName}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<span className="text-xs text-muted-foreground">
{(storeEntry.thirdCategoryEntries || []).length} 个{thirdCategoryLabel}
</span>
</div>
</div>
<div className="mt-3 rounded-xl border bg-muted/20 p-3">
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={thirdCategoryDrafts[draftKey] || ""}
onChange={(e) =>
setThirdCategoryDrafts((prev) => ({
...prev,
[draftKey]: e.target.value,
}))
}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddThirdCategoryToStore(
entry.brandName,
storeEntry.storeName
);
}
}}
placeholder={`给${secondCategoryLabel}“${storeEntry.storeName}”添加${thirdCategoryLabel}`}
/>
<Button
type="button"
onClick={() =>
handleAddThirdCategoryToStore(
entry.brandName,
storeEntry.storeName
)
}
className="shrink-0"
variant="outline"
>
<Plus className="mr-2 h-4 w-4" />
添加{thirdCategoryLabel}
</Button>
</div>
{(storeEntry.thirdCategoryEntries || []).length > 0 ? (
<div className="mt-3 space-y-3">
{(storeEntry.thirdCategoryEntries || []).map((thirdEntry) => {
const fourthDraftKey = createThirdCategoryDraftKey(
entry.brandName,
storeEntry.storeName,
thirdEntry.categoryName
);
return (
<div
key={`${entry.brandName}-${storeEntry.storeName}-${thirdEntry.categoryName}`}
className="rounded-xl border bg-background/80 p-3"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<div className="inline-flex items-center gap-2 rounded-full border bg-background px-3 py-1.5 text-sm font-medium">
<span>{thirdEntry.categoryName}</span>
<button
type="button"
onClick={() =>
handleRemoveThirdCategoryFromStore(
entry.brandName,
storeEntry.storeName,
thirdEntry.categoryName
)
}
className="rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
aria-label={`删除${thirdCategoryLabel} ${thirdEntry.categoryName}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<span className="text-xs text-muted-foreground">
{(thirdEntry.fourthCategoryEntries || []).length} 个{fourthCategoryLabel}
</span>
</div>
</div>
<div className="mt-3 rounded-xl border bg-muted/20 p-3">
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={fourthCategoryDrafts[fourthDraftKey] || ""}
onChange={(e) =>
setFourthCategoryDrafts((prev) => ({
...prev,
[fourthDraftKey]: e.target.value,
}))
}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddFourthCategoryToThirdCategory(
entry.brandName,
storeEntry.storeName,
thirdEntry.categoryName
);
}
}}
placeholder={`给${thirdCategoryLabel}“${thirdEntry.categoryName}”添加${fourthCategoryLabel}`}
/>
<Button
type="button"
onClick={() =>
handleAddFourthCategoryToThirdCategory(
entry.brandName,
storeEntry.storeName,
thirdEntry.categoryName
)
}
className="shrink-0"
variant="outline"
>
<Plus className="mr-2 h-4 w-4" />
添加{fourthCategoryLabel}
</Button>
</div>
{(thirdEntry.fourthCategoryEntries || []).length > 0 ? (
<div className="mt-3 space-y-3">
{(thirdEntry.fourthCategoryEntries || []).map(
(fourthEntry) => {
const fifthDraftKey = createFourthCategoryDraftKey(
entry.brandName,
storeEntry.storeName,
thirdEntry.categoryName,
fourthEntry.categoryName
);
return (
<div
key={`${entry.brandName}-${storeEntry.storeName}-${thirdEntry.categoryName}-${fourthEntry.categoryName}`}
className="rounded-xl border bg-background/80 p-3"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<div className="inline-flex items-center gap-2 rounded-full border bg-background px-3 py-1.5 text-sm font-medium">
<span>{fourthEntry.categoryName}</span>
<button
type="button"
onClick={() =>
handleRemoveFourthCategoryFromThirdCategory(
entry.brandName,
storeEntry.storeName,
thirdEntry.categoryName,
fourthEntry.categoryName
)
}
className="rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
aria-label={`删除${fourthCategoryLabel} ${fourthEntry.categoryName}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<span className="text-xs text-muted-foreground">
{(fourthEntry.fifthCategories || []).length} 个{fifthCategoryLabel}
</span>
</div>
</div>
<div className="mt-3 rounded-xl border bg-muted/20 p-3">
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={fifthCategoryDrafts[fifthDraftKey] || ""}
onChange={(e) =>
setFifthCategoryDrafts((prev) => ({
...prev,
[fifthDraftKey]: e.target.value,
}))
}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddFifthCategoryToFourthCategory(
entry.brandName,
storeEntry.storeName,
thirdEntry.categoryName,
fourthEntry.categoryName
);
}
}}
placeholder={`给${fourthCategoryLabel}“${fourthEntry.categoryName}”添加${fifthCategoryLabel}`}
/>
<Button
type="button"
onClick={() =>
handleAddFifthCategoryToFourthCategory(
entry.brandName,
storeEntry.storeName,
thirdEntry.categoryName,
fourthEntry.categoryName
)
}
className="shrink-0"
variant="outline"
>
<Plus className="mr-2 h-4 w-4" />
添加{fifthCategoryLabel}
</Button>
</div>
{(fourthEntry.fifthCategories || []).length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{(fourthEntry.fifthCategories || []).map(
(fifthCategory) => (
<div
key={`${entry.brandName}-${storeEntry.storeName}-${thirdEntry.categoryName}-${fourthEntry.categoryName}-${fifthCategory}`}
className="inline-flex items-center gap-2 rounded-full border bg-background px-3 py-1.5 text-sm"
>
<span>{fifthCategory}</span>
<button
type="button"
onClick={() =>
handleRemoveFifthCategoryFromFourthCategory(
entry.brandName,
storeEntry.storeName,
thirdEntry.categoryName,
fourthEntry.categoryName,
fifthCategory
)
}
className="rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
aria-label={`删除${fifthCategoryLabel} ${fifthCategory}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)
)}
</div>
) : (
<div className="mt-3 rounded-lg border border-dashed bg-background/70 px-3 py-4 text-sm text-muted-foreground">
这个{fourthCategoryLabel}下还没有添加任何{fifthCategoryLabel}。
</div>
)}
</div>
</div>
);
}
)}
</div>
) : (
<div className="mt-3 rounded-lg border border-dashed bg-background/70 px-3 py-4 text-sm text-muted-foreground">
这个{thirdCategoryLabel}下还没有添加任何{fourthCategoryLabel}。
</div>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="mt-3 rounded-lg border border-dashed bg-background/70 px-3 py-4 text-sm text-muted-foreground">
这个{secondCategoryLabel}下还没有添加任何{thirdCategoryLabel}。
</div>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="mt-3 rounded-lg border border-dashed bg-background/70 px-3 py-4 text-sm text-muted-foreground">
这个{firstCategoryLabel}下还没有添加任何{secondCategoryLabel}。
</div>
)}
</div>
</div>
))}
</div>
) : (
<div className="mt-3 rounded-lg border border-dashed bg-background/70 px-3 py-4 text-sm text-muted-foreground">
还没有添加{firstCategoryLabel}。先添加{firstCategoryLabel},再在每个{firstCategoryLabel}下继续添加{secondCategoryLabel}以及{thirdCategoryLabel}到{fifthCategoryLabel}。
</div>
)}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs">价格带区间</Label>
<span className="text-xs text-muted-foreground">
{priceBandOptions.length} 个区间
</span>
</div>
<div className="rounded-xl border bg-muted/10 p-3">
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={priceBandDraft}
onChange={(e) => setPriceBandDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddPriceBand();
}
}}
placeholder="例如:0-999元 / 1000-1999元"
/>
<Button
type="button"
onClick={handleAddPriceBand}
className="shrink-0"
variant="outline"
>
<Plus className="mr-2 h-4 w-4" />
添加价格带
</Button>
</div>
{priceBandOptions.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{priceBandOptions.map((priceBand) => (
<div
key={priceBand}
className="inline-flex items-center gap-2 rounded-full border bg-background px-3 py-1.5 text-sm"
>
<span>{priceBand}</span>
<button
type="button"
onClick={() => handleRemovePriceBand(priceBand)}
className="rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
aria-label={`删除价格带 ${priceBand}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
) : (
<div className="mt-3 rounded-lg border border-dashed bg-background/70 px-3 py-4 text-sm text-muted-foreground">
还没有添加价格带。添加后,第一页{firstCategoryLabel}和{secondCategoryLabel}右侧会显示价格带下拉框。
</div>
)}
</div>
</div>
</CardContent>
</Card>
</motion.div>
<motion.div variants={itemVariants} className="lg:col-span-2">
<Card className="mb-6">
<CardHeader className="pb-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500/10">
<Layers className="h-4 w-4 text-emerald-600" />
</div>
<div>
<CardTitle className="text-base">PSD 模板二级分类</CardTitle>
<CardDescription>
控制前台“选择模板”下拉是否按二级分类分组展示。
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div>
<div className="text-sm font-medium">启用 PSD 模板二级分类</div>
<div className="text-xs text-muted-foreground">
开启后,用户前台会按模板配置里的二级分类分组显示;未填写分类的模板会归入“未分组”。
</div>
</div>
<Switch
checked={psdTemplateSecondaryCategoryConfig.enabled}
onCheckedChange={(checked) =>
setPsdTemplateSecondaryCategoryConfig({ enabled: checked })
}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500/10">
<Settings2 className="h-4 w-4 text-violet-600" />
</div>
<div>
<CardTitle className="text-base">PSD 文字颜色选项</CardTitle>
<CardDescription>
按{firstCategoryLabel}配置套用 PSD 模板时可选的两组文字颜色,前台会跟随当前{firstCategoryLabel}读取。
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{psdTextColorBrandOptions.length > 0 ? (
<div className="space-y-2">
<Label className="text-xs">选择{firstCategoryLabel}</Label>
<SearchableSelect
value={selectedPsdTextColorBrand}
options={psdTextColorBrandOptions}
placeholder={`选择一个${firstCategoryLabel}后配置颜色`}
searchPlaceholder={`输入${firstCategoryLabel}名筛选`}
emptyText={`暂无${firstCategoryLabel}可选`}
onValueChange={setSelectedPsdTextColorBrand}
/>
<p className="text-xs text-muted-foreground">
未单独配置的{firstCategoryLabel}会回退系统默认颜色;当前编辑{firstCategoryLabel}:{selectedPsdTextColorBrand}
</p>
</div>
) : (
<div className="rounded-lg border border-dashed bg-muted/10 px-4 py-3 text-sm text-muted-foreground">
请先在上方“层级树”里配置{firstCategoryLabel},再按{firstCategoryLabel}设置 PSD 文字颜色。
</div>
)}
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div>
<div className="text-sm font-medium">
{selectedPsdTextColorBrand
? `启用“${selectedPsdTextColorBrand}”PSD 文字双色切换`
: "启用 PSD 文字双色切换"}
</div>
<div className="text-xs text-muted-foreground">
开启后,用户在 PSD 模板编辑弹窗里可为当前品牌的文字图层选择两种预设颜色。
</div>
</div>
<Switch
checked={selectedBrandPsdTextColorConfig.enabled}
onCheckedChange={(checked) =>
updateSelectedBrandPsdTextColorConfig((current) => ({
enabled: checked,
colors: current.colors,
}))
}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
{selectedBrandPsdTextColorConfig.colors.map((color, index) => (
<div
key={`psd-text-color-${index + 1}`}
className="rounded-xl border bg-muted/20 p-4"
>
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<Label className="text-xs">{`颜色 ${index + 1}`}</Label>
<div className="mt-1 text-xs text-muted-foreground">
用户前台可直接选择这组文字颜色。
</div>
</div>
<div
className="h-10 w-10 rounded-full border shadow-sm"
style={{ backgroundColor: color }}
/>
</div>
<div className="flex items-center gap-3">
<input
type="color"
value={color}
onChange={(event) =>
updatePsdTextColor(index as 0 | 1, event.target.value)
}
className="h-11 w-14 cursor-pointer rounded-lg border bg-transparent p-1"
/>
<Input
value={color}
onChange={(event) =>
updatePsdTextColor(index as 0 | 1, event.target.value)
}
placeholder="#FFFFFF"
className="font-mono uppercase"
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</motion.div>
</motion.div>
</TabsContent>
{/* Models Tab */}
<TabsContent value="models" className="space-y-6">
<motion.div
initial="hidden"
animate="visible"
variants={containerVariants}
>
<motion.div variants={itemVariants}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-rose-500/10">
<UserCircle className="h-4 w-4 text-rose-600" />
</div>
<div>
<CardTitle className="text-base">{firstCategoryLabel}模特图库</CardTitle>
<CardDescription>
为每个{firstCategoryLabel}配置模特图,第二步会按{firstCategoryLabel}展示供用户选择
</CardDescription>
</div>
</div>
<Badge variant="secondary">
{brandModelConfig.entries.length} 个{firstCategoryLabel}
</Badge>
</div>
</CardHeader>
<CardContent>
{brandModelBrands.length > 0 ? (
<div className="space-y-4">
{brandModelBrands.map((brandName, index) => {
const refKey = `brand-model:${brandName}`;
const entry = brandModelConfig.entries.find((e) => e.brandName === brandName);
const images = entry?.images || [];
const isUploading = uploadingKey === refKey;
return (
<motion.div
key={brandName}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
className="rounded-xl border bg-card/50 p-4"
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-muted font-mono text-sm font-medium">
{brandName.charAt(0)}
</div>
<div>
<div className="font-medium">{brandName}</div>
<div className="text-xs text-muted-foreground">
{images.length} 张模特图
</div>
</div>
</div>
<input
ref={(el) => { fileInputRefs.current[refKey] = el; }}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
void handleUploadBrandModelImages(brandName, e.target.files);
e.target.value = "";
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRefs.current[refKey]?.click()}
disabled={isUploading}
>
{isUploading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传
</Button>
</div>
<AnimatePresence>
{images.length > 0 && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4 md:grid-cols-6"
>
{images.map((image) => (
<div
key={image.id}
className="group relative aspect-square overflow-hidden rounded-lg border"
>
<img
src={image.imageUrl}
alt={image.displayName || `${brandName} 模特图`}
className="h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/40" />
<button
type="button"
onClick={() => handleRemoveBrandModelImage(brandName, image.id)}
className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-white/90 text-destructive opacity-0 shadow-sm transition-opacity group-hover:opacity-100"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
})}
</div>
) : (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed py-12">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<Package className="h-6 w-6 text-muted-foreground" />
</div>
<p className="mt-4 text-sm text-muted-foreground">
先在“基础配置”中添加{firstCategoryLabel},然后为每个{firstCategoryLabel}上传模特图
</p>
</div>
)}
</CardContent>
</Card>
</motion.div>
</motion.div>
</TabsContent>
{/* Categories Tab */}
<TabsContent value="categories" className="space-y-6">
<motion.div
initial="hidden"
animate="visible"
variants={containerVariants}
className="space-y-4"
>
<motion.div variants={itemVariants} className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="font-mono">
{enabledPresetLibrarySectionCount} 分类页
</Badge>
<Badge variant="outline" className="font-mono">
{totalPresetLibraryNodeCount} 树节点
</Badge>
<Badge variant="secondary">“我的模式”固定保留,不在此处编辑</Badge>
</div>
<Button onClick={handleAddPresetLibrarySection} size="sm">
<Plus className="mr-2 h-4 w-4" />
新增分类页
</Button>
</motion.div>
{sortedPresetLibrarySections.length > 0 ? (
sortedPresetLibrarySections.map((section, index) =>
renderPresetLibrarySectionCard(section, index)
)
) : (
<motion.div variants={itemVariants}>
<Card>
<CardContent className="flex flex-col items-center justify-center py-16">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Package className="h-8 w-8 text-muted-foreground" />
</div>
<p className="mt-4 text-lg font-medium">还没有配置生图分类页</p>
<p className="text-sm text-muted-foreground">
点击上方按钮开始新增一个可展示的生图模式分类页
</p>
</CardContent>
</Card>
</motion.div>
)}
</motion.div>
</TabsContent>
{/* Workflow Tab */}
<TabsContent value="workflow" className="space-y-6">
<motion.div
initial="hidden"
animate="visible"
variants={containerVariants}
className="space-y-4"
>
{renderWorkflowDashboard()}
<motion.div
variants={itemVariants}
className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"
>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="font-mono">
{countAgentWorkflowSteps(sortedSteps)} 模块
</Badge>
<Badge variant="outline" className="font-mono">
{countAgentWorkflowOptions(sortedSteps)} 选项
</Badge>
<Badge variant={workflowConstraintSettings.crossLevelMutualExclusionEnabled ? "default" : "secondary"}>
{workflowConstraintSettings.crossLevelMutualExclusionEnabled ? "跨级互斥已开启" : "跨级互斥已关闭"}
</Badge>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="flex items-start justify-between gap-4 rounded-lg border bg-background px-4 py-3 sm:min-w-[380px]">
<div className="space-y-1">
<div className="text-sm font-medium">跨级互斥</div>
<div className="text-xs leading-5 text-muted-foreground">
关闭时仅允许同级卡片互斥;开启后任意级别卡片都可以互相限制。
</div>
</div>
<Switch
checked={workflowConstraintSettings.crossLevelMutualExclusionEnabled}
onCheckedChange={(checked) =>
setConfig((prev) => ({
...prev,
constraintSettings: {
...(prev.constraintSettings || defaultAgentWorkflowConstraintSettings),
crossLevelMutualExclusionEnabled: checked,
},
}))
}
/>
</div>
<Button onClick={() => handleAddStep()} size="sm">
<Plus className="mr-2 h-4 w-4" />
新增一级模块
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={!canPasteCopiedWorkflowStepAsRoot}
onClick={() => handlePasteWorkflowStep(null)}
>
<ClipboardPaste className="mr-2 h-4 w-4" />
粘贴为一级模块
</Button>
</div>
</motion.div>
{renderWorkflowStepDropZone(null, "inside")}
{sortedSteps.length > 0 ? (
sortedSteps.map((step, stepIndex) =>
renderWorkflowStepCard(
step,
1,
[stepIndex + 1],
sortedSteps.length,
null,
stepIndex > 0 ? sortedSteps[stepIndex - 1].key : null
)
)
) : (
<motion.div variants={itemVariants}>
<Card>
<CardContent className="flex flex-col items-center justify-center py-16">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Layers className="h-8 w-8 text-muted-foreground" />
</div>
<p className="mt-4 text-lg font-medium">还没有配置工作流模块</p>
<p className="text-sm text-muted-foreground">
点击上方按钮开始创建第一个一级模块
</p>
</CardContent>
</Card>
</motion.div>
)}
</motion.div>
</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>
);
}