ai-chatbot/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx
Your Name b6db6a671c
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: upgrade agent workflow generation modes
2026-03-18 22:17:11 +08:00

1554 lines
67 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 } 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 {
defaultAgentWorkflowUiConfig,
normalizeAgentWorkflowUiConfig,
} from "@/lib/agents/workflow-ui-config";
import {
defaultAgentWorkflowBrandModelConfig,
normalizeAgentWorkflowBrandModelConfig,
} from "@/lib/agents/workflow-brand-model-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 {
AgentWorkflowBrandModelConfig,
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 }
};
function normalizeConfigOrdering(config: AgentWorkflowUiConfig): AgentWorkflowUiConfig {
return {
...config,
steps: config.steps.map((step, stepIndex) => ({
...step,
sortOrder: stepIndex,
options: step.options.map((option, optionIndex) => ({
...option,
sortOrder: optionIndex,
})),
})),
};
}
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}执行。`,
};
}
function createWorkflowStepDraft(
stepIndex: number,
existingStepKeys: string[]
): AgentWorkflowStepUiConfig {
const stepKey = ensureUniqueWorkflowKey(`step_${stepIndex + 1}`, existingStepKeys);
return {
key: stepKey,
title: `新步骤${stepIndex + 1}`,
description: "",
enabled: true,
sortOrder: stepIndex,
options: [createWorkflowOptionDraft(stepKey, 0, [])],
};
}
interface AgentWorkflowSettingsClientProps {
backHref?: string;
backLabel?: string;
}
export function AgentWorkflowSettingsClient({
backHref = "/settings",
backLabel = "返回设置",
}: AgentWorkflowSettingsClientProps = {}) {
const { data, isLoading, mutate } = useSWR<{
agentWorkflowUiConfig?: AgentWorkflowUiConfig;
agentWorkflowMaterialApiConfig?: AgentWorkflowMaterialApiConfig;
agentWorkflowBrandModelConfig?: AgentWorkflowBrandModelConfig;
agentWorkflowShowcaseConfig?: AgentWorkflowShowcaseConfig;
}>("/api/site-settings", fetcher);
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 [storeDraft, setStoreDraft] = useState("");
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
useEffect(() => {
if (!data) return;
setConfig(normalizeAgentWorkflowUiConfig(data.agentWorkflowUiConfig));
setMaterialApiConfig(
normalizeAgentWorkflowMaterialApiConfig(data.agentWorkflowMaterialApiConfig)
);
setShowcaseConfig(
normalizeAgentWorkflowShowcaseConfig(data.agentWorkflowShowcaseConfig)
);
setBrandModelConfig(
normalizeAgentWorkflowBrandModelConfig(data.agentWorkflowBrandModelConfig)
);
// Expand first step by default
if (data.agentWorkflowUiConfig?.steps?.[0]) {
setExpandedSteps(new Set([data.agentWorkflowUiConfig.steps[0].key]));
}
}, [data]);
const configuredBrands = useMemo(
() => config.inputOptions?.brands || defaultAgentWorkflowUiConfig.inputOptions?.brands || [],
[config.inputOptions?.brands]
);
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 brandOptions = config.inputOptions?.brands || [];
const storeOptions = config.inputOptions?.stores || [];
const updateInputOptions = (updates: Partial<{ brands: string[]; stores: string[] }>) => {
setConfig((prev) => ({
...prev,
inputOptions: {
brands: updates.brands ?? prev.inputOptions?.brands ?? [],
stores: updates.stores ?? prev.inputOptions?.stores ?? [],
},
}));
};
const normalizeInputOptionValue = (value: string) =>
value
.replace(/\s+/g, " ")
.trim();
const handleAddInputOption = (kind: "brands" | "stores") => {
const draft = kind === "brands" ? brandDraft : storeDraft;
const nextValue = normalizeInputOptionValue(draft);
if (!nextValue) {
toast.error(kind === "brands" ? "请输入品牌名称" : "请输入店铺名称");
return;
}
const currentList = kind === "brands" ? brandOptions : storeOptions;
if (currentList.some((item) => item.toLowerCase() === nextValue.toLowerCase())) {
toast.error(kind === "brands" ? "该品牌已存在" : "该店铺已存在");
return;
}
if (kind === "brands") {
updateInputOptions({ brands: [...brandOptions, nextValue] });
setBrandDraft("");
return;
}
updateInputOptions({ stores: [...storeOptions, nextValue] });
setStoreDraft("");
};
const handleRemoveInputOption = (kind: "brands" | "stores", value: string) => {
if (kind === "brands") {
updateInputOptions({
brands: brandOptions.filter((item) => item !== value),
});
return;
}
updateInputOptions({
stores: storeOptions.filter((item) => item !== value),
});
};
const enabledStepsCount = useMemo(
() => sortedSteps.filter((s) => s.enabled).length,
[sortedSteps]
);
const totalOptionsCount = useMemo(
() => sortedSteps.reduce((acc, s) => acc + s.options.filter((o) => o.enabled).length, 0),
[sortedSteps]
);
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: prev.steps.map((step) => (step.key === stepKey ? updater(step) : step)),
})
);
};
const updateOption = (
stepKey: string,
optionKey: string,
updater: (
option: AgentWorkflowUiConfig["steps"][number]["options"][number]
) => AgentWorkflowUiConfig["steps"][number]["options"][number]
) => {
updateStep(stepKey, (step) => ({
...step,
options: step.options.map((option) =>
option.key === optionKey ? updater(option) : option
),
}));
};
const handleAddStep = () => {
setConfig((prev) => {
const newStep = createWorkflowStepDraft(prev.steps.length, prev.steps.map((step) => step.key));
// Auto expand new step
setExpandedSteps((prevExpanded) => new Set([...prevExpanded, newStep.key]));
return normalizeConfigOrdering({
...prev,
steps: [...prev.steps, newStep],
});
});
// Switch to workflow tab
setActiveTab("workflow");
};
const handleRemoveStep = (stepKey: string) => {
setConfig((prev) =>
normalizeConfigOrdering({
...prev,
steps: prev.steps.filter((step) => step.key !== stepKey),
})
);
setExpandedSteps((prev) => {
const next = new Set(prev);
next.delete(stepKey);
return next;
});
};
const handleAddOption = (stepKey: string) => {
updateStep(stepKey, (step) => ({
...step,
options: [
...step.options,
createWorkflowOptionDraft(step.key, step.options.length, step.options.map((item) => item.key)),
],
}));
};
const handleRemoveOption = (stepKey: string, optionKey: string) => {
updateStep(stepKey, (step) => ({
...step,
options: step.options.filter((option) => option.key !== 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({
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 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={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-4 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="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">
{brandOptions.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();
handleAddInputOption("brands");
}
}}
placeholder="输入一个品牌名称后点击添加"
/>
<Button
type="button"
onClick={() => handleAddInputOption("brands")}
className="shrink-0"
>
<Plus className="mr-2 h-4 w-4" />
添加品牌
</Button>
</div>
{brandOptions.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{brandOptions.map((brand) => (
<div
key={brand}
className="inline-flex items-center gap-2 rounded-full border bg-background px-3 py-1.5 text-sm"
>
<span>{brand}</span>
<button
type="button"
onClick={() => handleRemoveInputOption("brands", brand)}
className="rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
aria-label={`删除品牌 ${brand}`}
>
<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>
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs">店铺列表</Label>
<span className="text-xs text-muted-foreground">
{storeOptions.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={storeDraft}
onChange={(e) => setStoreDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddInputOption("stores");
}
}}
placeholder="输入一个店铺名称后点击添加"
/>
<Button
type="button"
onClick={() => handleAddInputOption("stores")}
className="shrink-0"
>
<Plus className="mr-2 h-4 w-4" />
添加店铺
</Button>
</div>
{storeOptions.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{storeOptions.map((store) => (
<div
key={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={() => handleRemoveInputOption("stores", 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>
</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>
{/* 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">
{sortedSteps.length} 步骤
</Badge>
<Badge variant="outline" className="font-mono">
{sortedSteps.reduce((acc, s) => acc + s.options.length, 0)} 选项
</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) => (
<motion.div key={step.key} variants={itemVariants}>
<Collapsible
open={expandedSteps.has(step.key)}
onOpenChange={() => toggleStepExpansion(step.key)}
>
<Card className={cn(!step.enabled && "opacity-60")}>
<CardHeader className="py-4">
<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-8 w-8 items-center justify-center rounded-md bg-violet-500/10 text-xs font-medium text-violet-600">
{stepIndex + 1}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Input
value={step.title}
onChange={(e) =>
updateStep(step.key, (s) => ({ ...s, title: e.target.value }))
}
className="h-8 w-[200px] border-0 bg-transparent px-0 text-base font-medium shadow-none focus-visible:ring-0"
placeholder="步骤标题"
/>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{step.key}
</code>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary">
{step.options.filter((o) => o.enabled).length} 选项
</Badge>
<Switch
checked={step.enabled}
onCheckedChange={(checked) =>
updateStep(step.key, (s) => ({ ...s, 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, (s) => ({ ...s, 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>
{step.options.length > 0 ? (
<div className="grid gap-4 lg:grid-cols-2">
{step.options
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((option) => {
const refKey = `${step.key}:${option.key}`;
const isUploading = uploadingKey === refKey;
return (
<div
key={option.key}
className={cn(
"rounded-lg border bg-card p-4",
!option.enabled && "opacity-60"
)}
>
<div className="mb-4 flex items-start justify-between gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{option.key}
</code>
<div className="flex items-center gap-1">
<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, (o) => ({
...o,
enabled: checked,
}))
}
className="scale-75"
/>
</div>
</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, (o) => ({
...o,
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, (o) => ({
...o,
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, (o) => ({
...o,
promptFragment: e.target.value,
}))
}
placeholder="注入的提示词片段"
rows={2}
className="resize-none text-xs"
/>
</div>
</div>
</div>
</div>
);
})}
</div>
) : (
<div className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground">
暂无选项,点击上方按钮添加
</div>
)}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
</motion.div>
))
) : (
<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>
);
}