2619 lines
102 KiB
TypeScript
2619 lines
102 KiB
TypeScript
"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 {
|
||
defaultAgentWorkflowGoodsInfoApiConfig,
|
||
normalizeAgentWorkflowGoodsInfoApiConfig,
|
||
} from "@/lib/agents/workflow-goods-info-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,
|
||
AgentWorkflowGoodsInfoApiConfig,
|
||
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[],
|
||
priceBands: string[] = []
|
||
) {
|
||
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,
|
||
}));
|
||
const normalizedPriceBands = Array.from(
|
||
new Set(
|
||
priceBands
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
)
|
||
);
|
||
|
||
return {
|
||
brandStoreEntries: normalizedEntries,
|
||
brands: normalizedEntries.map((entry) => entry.brandName),
|
||
stores: Array.from(new Set(normalizedEntries.flatMap((entry) => entry.stores))),
|
||
priceBands: normalizedPriceBands,
|
||
};
|
||
}
|
||
|
||
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;
|
||
}>("/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 [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 [priceBandDraft, setPriceBandDraft] = 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)
|
||
);
|
||
setGoodsInfoApiConfig(
|
||
normalizeAgentWorkflowGoodsInfoApiConfig(data.agentWorkflowGoodsInfoApiConfig)
|
||
);
|
||
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 priceBandOptions = useMemo(
|
||
() =>
|
||
config.inputOptions?.priceBands ||
|
||
defaultAgentWorkflowUiConfig.inputOptions?.priceBands ||
|
||
[],
|
||
[config.inputOptions?.priceBands]
|
||
);
|
||
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,
|
||
prev.inputOptions?.priceBands || []
|
||
),
|
||
}));
|
||
};
|
||
|
||
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 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]
|
||
),
|
||
}));
|
||
setPriceBandDraft("");
|
||
};
|
||
|
||
const handleRemovePriceBand = (priceBand: string) => {
|
||
setConfig((prev) => ({
|
||
...prev,
|
||
inputOptions: buildWorkflowInputOptionsFromBrandEntries(
|
||
prev.inputOptions?.brandStoreEntries || [],
|
||
(prev.inputOptions?.priceBands || []).filter((item) => item !== priceBand)
|
||
),
|
||
}));
|
||
};
|
||
|
||
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,
|
||
agentWorkflowGoodsInfoApiConfig: goodsInfoApiConfig,
|
||
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-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}>
|
||
<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>
|
||
|
||
<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">
|
||
还没有添加价格带。添加后,第一页品牌和店铺右侧会显示价格带下拉框。
|
||
</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>
|
||
);
|
||
}
|