7671 lines
312 KiB
TypeScript
7671 lines
312 KiB
TypeScript
"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,
|
||
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,
|
||
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,
|
||
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 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);
|
||
}
|
||
|
||
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: "",
|
||
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());
|
||
}
|
||
|
||
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 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>(
|
||
"/api/site-settings",
|
||
fetcher,
|
||
{ refreshInterval: 60_000 }
|
||
);
|
||
const { data: aiModelsData } = useSWR<{ models: AdminAiModelOption[] }>(
|
||
"/api/admin/ai-models",
|
||
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 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);
|
||
|
||
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.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 fetch("/api/site-settings", {
|
||
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 response = await fetch("/api/site-settings", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(changedPayload),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
let message = "保存失败";
|
||
|
||
try {
|
||
const payload = await response.json();
|
||
if (Array.isArray(payload?.details) && payload.details.length > 0) {
|
||
const firstIssue = payload.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 = [payload?.error, pathText, reasonText].filter(Boolean).join(":");
|
||
} else if (typeof payload?.error === "string" && payload.error.trim()) {
|
||
message = payload.error.trim();
|
||
}
|
||
} catch {
|
||
// Ignore JSON parsing issues and keep the generic message.
|
||
}
|
||
|
||
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 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("、")}`
|
||
);
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
};
|
||
|
||
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 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>
|
||
|
||
{/* 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">
|
||
开启后支持搜索白底素材图、详情参考图和 SKU 参考图
|
||
</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">API Key</Label>
|
||
<Input
|
||
type="password"
|
||
value={materialApiConfig.apiKey}
|
||
onChange={(e) =>
|
||
setMaterialApiConfig((prev) => ({ ...prev, apiKey: e.target.value }))
|
||
}
|
||
placeholder="输入 API Key"
|
||
/>
|
||
</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>
|
||
);
|
||
}
|