ai-chatbot/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx
Your Name a197a21337
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: 支持工作流跨级互斥与开发中卡片态
2026-04-03 13:31:26 +08:00

4514 lines
189 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

"use client";
import Link from "next/link";
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import { motion, AnimatePresence } from "framer-motion";
import {
ArrowDown,
ArrowLeft,
ArrowUp,
ChevronDown,
ChevronRight,
Download,
Image as ImageIcon,
Layers,
Loader2,
MonitorPlay,
Package,
Plus,
Save,
Search,
Settings2,
Sparkles,
Trash2,
Upload,
UserCircle,
Video,
} from "lucide-react";
import {
defaultAgentWorkflowConstraintSettings,
AGENT_WORKFLOW_MAX_OPTION_DEPTH,
AGENT_WORKFLOW_MAX_TREE_DEPTH,
countAgentWorkflowOptions,
countAgentWorkflowOptionTree,
countAgentWorkflowSteps,
defaultAgentWorkflowCategoryLabels,
defaultAgentWorkflowUiConfig,
findAgentWorkflowOptionByKey,
findAgentWorkflowStepByKey,
flattenAgentWorkflowOptionTree,
getEnabledAgentWorkflowOptionTree,
getAgentWorkflowCategoryLabels,
getAgentWorkflowConstraintSettings,
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Separator } from "@/components/ui/separator";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { cn } from "@/lib/utils";
import type {
AgentPresetLibraryCategoryNode,
AgentPresetLibraryConfig,
AgentPsdTextColorBrandConfig,
AgentPresetLibrarySection,
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 = ["一级分类", "二级分类", "三级分类"];
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: [],
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,
options: [createWorkflowOptionDraft(stepKey, 0, [])],
children: [],
};
}
function updateWorkflowStepTree(
steps: AgentWorkflowStepUiConfig[],
stepKey: string,
updater: (step: AgentWorkflowStepUiConfig) => AgentWorkflowStepUiConfig
): AgentWorkflowStepUiConfig[] {
return steps.map((step) => {
if (step.key === stepKey) {
return updater(step);
}
return {
...step,
children: updateWorkflowStepTree(step.children || [], stepKey, updater),
};
});
}
function collectWorkflowOptionKeys(options: AgentWorkflowOptionUiConfig[]): string[] {
return flattenAgentWorkflowOptionTree(options).map(({ option }) => option.key);
}
function collectWorkflowOptionRelations(
options: AgentWorkflowOptionUiConfig[],
ancestors: string[] = []
): Array<{
key: string;
label: string;
depth: number;
}> {
return options.flatMap((option) => {
const currentPath = [...ancestors, option.buttonName || option.key];
return [
{
key: option.key,
label: currentPath.join(" / "),
depth: currentPath.length,
},
...collectWorkflowOptionRelations(option.children || [], currentPath),
];
});
}
function collectWorkflowOptionRelationsFromSteps(
steps: AgentWorkflowStepUiConfig[]
): Array<{
key: string;
label: string;
depth: number;
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[] {
return options.map((option) => {
if (option.key === optionKey) {
return updater(option);
}
return {
...option,
children: updateWorkflowOptionTree(option.children || [], optionKey, updater),
};
});
}
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 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;
}
export function AgentWorkflowSettingsClient({
backHref = "/settings",
backLabel = "返回设置",
}: AgentWorkflowSettingsClientProps = {}) {
const { data, isLoading, mutate } = useSWR<{
agentPresetLibraryConfig?: AgentPresetLibraryConfig;
agentWorkflowUiConfig?: AgentWorkflowUiConfig;
agentWorkflowMaterialApiConfig?: AgentWorkflowMaterialApiConfig;
agentWorkflowGoodsInfoApiConfig?: AgentWorkflowGoodsInfoApiConfig;
agentWorkflowBrandModelConfig?: AgentWorkflowBrandModelConfig;
agentWorkflowShowcaseConfig?: AgentWorkflowShowcaseConfig;
agentPsdTextColorConfig?: AgentPsdTextColorConfig;
agentPsdTemplateSecondaryCategoryConfig?: AgentPsdTemplateSecondaryCategoryConfig;
}>("/api/site-settings", fetcher);
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 [isImportingBrandSheet, setIsImportingBrandSheet] = useState(false);
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
const brandImportInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!data) return;
setPresetLibraryConfig(
normalizeAgentPresetLibraryConfig(data.agentPresetLibraryConfig)
);
const normalizedUiConfig = normalizeAgentWorkflowUiConfig(data.agentWorkflowUiConfig);
const flattenedUiConfig = normalizeConfigOrdering({
...normalizedUiConfig,
steps: flattenAgentWorkflowSteps(normalizedUiConfig.steps).map(({ step }, index) => ({
...step,
sortOrder: index,
children: [],
})),
});
setConfig(flattenedUiConfig);
setMaterialApiConfig(
normalizeAgentWorkflowMaterialApiConfig(data.agentWorkflowMaterialApiConfig)
);
setGoodsInfoApiConfig(
normalizeAgentWorkflowGoodsInfoApiConfig(data.agentWorkflowGoodsInfoApiConfig)
);
setShowcaseConfig(
normalizeAgentWorkflowShowcaseConfig(data.agentWorkflowShowcaseConfig)
);
setPsdTextColorConfig(
normalizeAgentPsdTextColorConfig(data.agentPsdTextColorConfig)
);
setPsdTemplateSecondaryCategoryConfig(
normalizeAgentPsdTemplateSecondaryCategoryConfig(
data.agentPsdTemplateSecondaryCategoryConfig
)
);
setBrandModelConfig(
normalizeAgentWorkflowBrandModelConfig(data.agentWorkflowBrandModelConfig)
);
setExpandedWorkflowOptions({});
setExpandedPresetLibraryNodes({});
if (flattenedUiConfig.steps[0]) {
setExpandedSteps(new Set([flattenedUiConfig.steps[0].key]));
}
}, [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 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 sortedSteps = useMemo(
() => [...config.steps].sort((left, right) => left.sortOrder - right.sortOrder),
[config.steps]
);
const sortedPresetLibrarySections = useMemo(
() =>
[...presetLibraryConfig.sections].sort((left, right) => left.sortOrder - right.sortOrder),
[presetLibraryConfig.sections]
);
const workflowOptionRelations = useMemo(
() => collectWorkflowOptionRelationsFromSteps(config.steps || []),
[config.steps]
);
const workflowOptionRelationLookup = useMemo(
() => new Map(workflowOptionRelations.map((item) => [item.key, item] as const)),
[workflowOptionRelations]
);
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) =>
normalizeConfigOrdering({
...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 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 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 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 handleSave = async () => {
try {
setIsSaving(true);
const response = await fetch("/api/site-settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
agentPresetLibraryConfig: presetLibraryConfig,
agentWorkflowUiConfig: config,
agentWorkflowMaterialApiConfig: materialApiConfig,
agentWorkflowGoodsInfoApiConfig: goodsInfoApiConfig,
agentWorkflowBrandModelConfig: brandModelConfig,
agentWorkflowShowcaseConfig: showcaseConfig,
agentPsdTextColorConfig: psdTextColorConfig,
agentPsdTemplateSecondaryCategoryConfig: psdTemplateSecondaryCategoryConfig,
}),
});
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);
}
toast.success("智能体工作流配置已保存");
await mutate();
} 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 blockedCandidates = workflowOptionRelations.filter(
(item) =>
item.key !== option.key &&
(workflowConstraintSettings.crossLevelMutualExclusionEnabled || item.depth === depth)
);
const selectedBlockedItems = (option.blockedOptionKeys || [])
.map((blockedKey) => workflowOptionRelationLookup.get(blockedKey))
.filter((item): item is WorkflowOptionRelation => Boolean(item));
const isExpanded = isWorkflowOptionExpanded(option.key, depth);
const blockedSearchValue = blockedOptionSearches[refKey] || "";
const normalizedBlockedSearchValue = blockedSearchValue.trim().toLowerCase();
const filteredBlockedCandidates = blockedCandidates.filter((candidate) => {
if (!normalizedBlockedSearchValue) {
return true;
}
return [candidate.label, candidate.stepTitle, candidate.key].some((value) =>
value.toLowerCase().includes(normalizedBlockedSearchValue)
);
});
const canMoveUp = depth === 1 && indexPath[0] > 1;
const canMoveDown = depth === 1 && indexPath[0] < (step.options || []).length;
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.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>
<Input
value={option.buttonName}
onChange={(e) =>
updateOption(step.key, option.key, (currentOption) => ({
...currentOption,
buttonName: e.target.value,
}))
}
placeholder="选项名称"
className="h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">说明文案</Label>
<Input
value={option.description || ""}
onChange={(e) =>
updateOption(step.key, option.key, (currentOption) => ({
...currentOption,
description: e.target.value,
}))
}
placeholder="简短描述"
className="h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Prompt 片段</Label>
<Textarea
value={option.promptFragment || ""}
onChange={(e) =>
updateOption(step.key, option.key, (currentOption) => ({
...currentOption,
promptFragment: e.target.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-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>
)}
<div className="space-y-2 rounded-md border bg-background/70 p-2">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={blockedSearchValue}
onChange={(e) =>
setBlockedOptionSearches((prev) => ({
...prev,
[refKey]: e.target.value,
}))
}
placeholder={`${workflowConstraintSettings.crossLevelMutualExclusionEnabled ? "搜索所有层级卡片" : "搜索同级卡片"}(${blockedCandidates.length} 个候选)`}
className="h-8 pl-8 text-xs"
/>
</div>
<div className="max-h-44 space-y-2 overflow-y-auto">
{filteredBlockedCandidates.length > 0 ? (
filteredBlockedCandidates.map((candidate) => {
const active = (option.blockedOptionKeys || []).includes(candidate.key);
return (
<button
key={candidate.key}
type="button"
className={cn(
"flex w-full items-center justify-between rounded-md border px-3 py-2 text-left text-xs transition-colors",
active
? "border-amber-500/50 bg-amber-500/10 text-foreground"
: "border-border/70 bg-background hover:border-foreground/20 hover:bg-muted/60"
)}
onClick={() =>
updateOption(step.key, option.key, (currentOption) => {
const currentBlocked = new Set(
currentOption.blockedOptionKeys || []
);
if (currentBlocked.has(candidate.key)) {
currentBlocked.delete(candidate.key);
} else {
currentBlocked.add(candidate.key);
}
return {
...currentOption,
blockedOptionKeys: Array.from(currentBlocked),
};
})
}
>
<span className="min-w-0">
<span className="block truncate font-medium">
{candidate.label}
</span>
<span className="block text-[10px] text-muted-foreground">
{getWorkflowLevelLabel(candidate.depth, "卡片")} · {candidate.key}
</span>
</span>
<Badge variant={active ? "default" : "outline"}>
{active ? "已限制" : "可限制"}
</Badge>
</button>
);
})
) : (
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
{blockedCandidates.length > 0
? "没有命中搜索结果,换个关键词试试"
: workflowConstraintSettings.crossLevelMutualExclusionEnabled
? "当前工作流下没有其他可限制的卡片"
: "当前工作流下没有其他可限制的同级卡片"}
</div>
)}
</div>
</div>
</div>
</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 renderWorkflowStepCard = (
step: AgentWorkflowStepUiConfig,
depth: number,
indexPath: number[],
siblingCount = 0
) => {
const stepLabel = indexPath.join(".");
const levelLabel = getWorkflowLevelLabel(depth, "模块");
const rootOptions = [...(step.options || [])].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;
return (
<motion.div
key={step.key}
variants={itemVariants}
className={cn(depth > 1 && "pl-4 sm:pl-6")}
>
<Collapsible
open={expandedSteps.has(step.key)}
onOpenChange={() => toggleStepExpansion(step.key)}
>
<Card
className={cn(
!step.enabled && "opacity-60",
depth > 1 && "border-l-4 border-l-emerald-500/30"
)}
>
<CardHeader className="py-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="flex items-center gap-4">
<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">
<Input
value={step.title}
onChange={(e) =>
updateStep(step.key, (currentStep) => ({
...currentStep,
title: e.target.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>
<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>
{isTopLevelStep ? (
<>
<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}
<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={() => handleRemoveStep(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>
<Textarea
value={step.description || ""}
onChange={(e) =>
updateStep(step.key, (currentStep) => ({
...currentStep,
description: e.target.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>
)}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
</motion.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">
<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-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 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"
>
<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>
</div>
</motion.div>
{sortedSteps.length > 0 ? (
sortedSteps.map((step, stepIndex) =>
renderWorkflowStepCard(step, 1, [stepIndex + 1], sortedSteps.length)
)
) : (
<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>
</div>
);
}