ai-chatbot/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx
Your Name 8d2066a3bf
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: expand agent workflow and project management
2026-03-19 18:33:42 +08:00

2423 lines
93 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 ReactNode } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import { motion, AnimatePresence } from "framer-motion";
import {
ArrowLeft,
ChevronDown,
ChevronRight,
GripVertical,
Image as ImageIcon,
Layers,
Loader2,
MonitorPlay,
Package,
Plus,
Save,
Search,
Settings2,
Sparkles,
Trash2,
Upload,
UserCircle,
Video,
} from "lucide-react";
import {
AGENT_WORKFLOW_MAX_TREE_DEPTH,
countAgentWorkflowOptions,
countAgentWorkflowOptionTree,
countAgentWorkflowSteps,
defaultAgentWorkflowUiConfig,
findAgentWorkflowOptionByKey,
findAgentWorkflowStepByKey,
flattenAgentWorkflowOptionTree,
getEnabledAgentWorkflowOptionTree,
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 { 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 { cn } from "@/lib/utils";
import type {
AgentPresetLibraryCategoryNode,
AgentPresetLibraryConfig,
AgentPresetLibrarySection,
AgentWorkflowBrandModelConfig,
AgentWorkflowBrandStoreEntry,
AgentWorkflowMaterialApiConfig,
AgentWorkflowOptionUiConfig,
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 WORKFLOW_LEVEL_LABELS = ["一级模块", "二级模块", "三级模块"];
const WORKFLOW_OPTION_LEVEL_LABELS = ["一级卡片", "二级卡片", "三级卡片"];
const PRESET_LIBRARY_LEVEL_LABELS = ["一级分类", "二级分类", "三级分类"];
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}执行。`,
children: [],
};
}
function createWorkflowStepDraft(
depth: number,
stepIndex: number,
existingStepKeys: string[]
): AgentWorkflowStepUiConfig {
const levelLabel = WORKFLOW_LEVEL_LABELS[Math.max(0, Math.min(depth - 1, 2))] || "模块";
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 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 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[]
) {
const normalizedEntries = entries.map((entry, index) => ({
brandName: entry.brandName.trim(),
stores: Array.from(
new Set(
(entry.stores || [])
.map((store) => store.trim())
.filter(Boolean)
)
),
sortOrder: index,
}));
return {
brandStoreEntries: normalizedEntries,
brands: normalizedEntries.map((entry) => entry.brandName),
stores: Array.from(new Set(normalizedEntries.flatMap((entry) => entry.stores))),
};
}
interface AgentWorkflowSettingsClientProps {
backHref?: string;
backLabel?: string;
}
export function AgentWorkflowSettingsClient({
backHref = "/settings",
backLabel = "返回设置",
}: AgentWorkflowSettingsClientProps = {}) {
const { data, isLoading, mutate } = useSWR<{
agentPresetLibraryConfig?: AgentPresetLibraryConfig;
agentWorkflowUiConfig?: AgentWorkflowUiConfig;
agentWorkflowMaterialApiConfig?: AgentWorkflowMaterialApiConfig;
agentWorkflowBrandModelConfig?: AgentWorkflowBrandModelConfig;
agentWorkflowShowcaseConfig?: AgentWorkflowShowcaseConfig;
}>("/api/site-settings", fetcher);
const [presetLibraryConfig, setPresetLibraryConfig] = useState<AgentPresetLibraryConfig>(
defaultAgentPresetLibraryConfig
);
const [config, setConfig] = useState<AgentWorkflowUiConfig>(defaultAgentWorkflowUiConfig);
const [materialApiConfig, setMaterialApiConfig] = useState<AgentWorkflowMaterialApiConfig>(
defaultAgentWorkflowMaterialApiConfig
);
const [showcaseConfig, setShowcaseConfig] = useState<AgentWorkflowShowcaseConfig>(
defaultAgentWorkflowShowcaseConfig
);
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 [activeTab, setActiveTab] = useState("showcase");
const [brandDraft, setBrandDraft] = useState("");
const [storeDrafts, setStoreDrafts] = useState<Record<string, string>>({});
const fileInputRefs = useRef<Record<string, HTMLInputElement | 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)
);
setShowcaseConfig(
normalizeAgentWorkflowShowcaseConfig(data.agentWorkflowShowcaseConfig)
);
setBrandModelConfig(
normalizeAgentWorkflowBrandModelConfig(data.agentWorkflowBrandModelConfig)
);
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 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 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 updateInputOptions = (nextEntries: AgentWorkflowBrandStoreEntry[]) => {
setConfig((prev) => ({
...prev,
inputOptions: buildWorkflowInputOptionsFromBrandEntries(nextEntries),
}));
};
const normalizeInputOptionValue = (value: string) =>
value
.replace(/\s+/g, " ")
.trim();
const handleAddBrandEntry = () => {
const nextValue = normalizeInputOptionValue(brandDraft);
if (!nextValue) {
toast.error("请输入品牌名称");
return;
}
if (brandStoreEntries.some((item) => item.brandName.toLowerCase() === nextValue.toLowerCase())) {
toast.error("该品牌已存在");
return;
}
updateInputOptions([
...brandStoreEntries,
{
brandName: nextValue,
stores: [],
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;
});
};
const handleAddStoreToBrand = (brandName: string) => {
const draft = storeDrafts[brandName] || "";
const nextValue = normalizeInputOptionValue(draft);
if (!nextValue) {
toast.error("请输入店铺名称");
return;
}
const targetEntry = brandStoreEntries.find((entry) => entry.brandName === brandName);
if (targetEntry?.stores.some((store) => store.toLowerCase() === nextValue.toLowerCase())) {
toast.error("该店铺已存在");
return;
}
updateInputOptions(
brandStoreEntries.map((entry) => {
if (entry.brandName !== brandName) return entry;
return {
...entry,
stores: [...entry.stores, nextValue],
};
})
);
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),
}
: entry
)
);
};
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 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 >= 3) {
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 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 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,
agentWorkflowBrandModelConfig: brandModelConfig,
agentWorkflowShowcaseConfig: showcaseConfig,
}),
});
if (!response.ok) {
throw new Error("保存失败");
}
toast.success("智能体工作流配置已保存");
await mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
} finally {
setIsSaving(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
);
return (
<div key={node.id} className={cn("space-y-3", depth > 1 && "pl-4 sm:pl-6")}>
<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">
<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>
<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>
</Card>
</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 =
WORKFLOW_OPTION_LEVEL_LABELS[Math.max(0, Math.min(depth - 1, WORKFLOW_OPTION_LEVEL_LABELS.length - 1))] ||
`第 ${depth} 级卡片`;
const sortedChildren = [...(option.children || [])].sort(
(left, right) => left.sortOrder - right.sortOrder
);
const canAddChild = depth < 3;
return (
<div key={option.key} className={cn("space-y-4", depth > 1 && "pl-4 sm:pl-6")}>
<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="mb-4 flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex-1">
<div className="mb-3 flex flex-wrap items-center gap-2">
<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>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{option.key}
</code>
</div>
<div className="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>
</div>
</div>
<div className="flex flex-wrap items-center gap-1">
{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">已达三级</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>
<Switch
checked={option.enabled}
onCheckedChange={(checked) =>
updateOption(step.key, option.key, (currentOption) => ({
...currentOption,
enabled: checked,
}))
}
className="scale-75"
/>
</div>
</div>
<div className="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">
当前卡片下可继续建立二级、三级卡片母子关系。
</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>
</div>
</div>
);
};
const renderWorkflowStepCard = (
step: AgentWorkflowStepUiConfig,
depth: number,
indexPath: number[]
) => {
const stepLabel = indexPath.join(".");
const levelLabel =
WORKFLOW_LEVEL_LABELS[Math.max(0, Math.min(depth - 1, WORKFLOW_LEVEL_LABELS.length - 1))] ||
`第 ${depth} 级模块`;
const rootOptions = [...(step.options || [])].sort((left, right) => left.sortOrder - right.sortOrder);
const optionCardCount = countAgentWorkflowOptionTree(step.options || []);
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>
<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={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}>
<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}>
<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>配置品牌与品牌下对应的店铺母子关系</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs">品牌树</Label>
<span className="text-xs text-muted-foreground">
{brandStoreEntries.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={brandDraft}
onChange={(e) => setBrandDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddBrandEntry();
}
}}
placeholder="输入一个品牌名称后点击添加"
/>
<Button
type="button"
onClick={handleAddBrandEntry}
className="shrink-0"
>
<Plus className="mr-2 h-4 w-4" />
添加品牌
</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={`删除品牌 ${entry.brandName}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<span className="text-xs text-muted-foreground">
{entry.stores.length} 个店铺
</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={`给品牌“${entry.brandName}”添加店铺`}
/>
<Button
type="button"
onClick={() => handleAddStoreToBrand(entry.brandName)}
className="shrink-0"
variant="outline"
>
<Plus className="mr-2 h-4 w-4" />
添加店铺
</Button>
</div>
{entry.stores.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{entry.stores.map((store) => (
<div
key={`${entry.brandName}-${store}`}
className="inline-flex items-center gap-2 rounded-full border bg-background px-3 py-1.5 text-sm"
>
<span>{store}</span>
<button
type="button"
onClick={() => handleRemoveStoreFromBrand(entry.brandName, store)}
className="rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
aria-label={`删除店铺 ${store}`}
>
<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">
这个品牌下还没有添加任何店铺。
</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">
还没有添加品牌。先添加品牌,再在每个品牌下继续添加店铺。
</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">品牌模特图库</CardTitle>
<CardDescription>
为每个品牌配置模特图,第二步会按品牌展示供用户选择
</CardDescription>
</div>
</div>
<Badge variant="secondary">
{brandModelConfig.entries.length} 个品牌
</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">
先在"基础配置"中添加品牌,然后为每个品牌上传模特图
</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 items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline" className="font-mono">
{countAgentWorkflowSteps(sortedSteps)} 模块
</Badge>
<Badge variant="outline" className="font-mono">
{countAgentWorkflowOptions(sortedSteps)} 选项
</Badge>
</div>
<Button onClick={() => handleAddStep()} size="sm">
<Plus className="mr-2 h-4 w-4" />
新增一级模块
</Button>
</motion.div>
{sortedSteps.length > 0 ? (
sortedSteps.map((step, stepIndex) =>
renderWorkflowStepCard(step, 1, [stepIndex + 1])
)
) : (
<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>
);
}