512 lines
19 KiB
TypeScript
512 lines
19 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo } from "react";
|
||
import { motion } from "framer-motion";
|
||
import { ArrowRight, Check, Copy, RotateCcw, Sparkles, ChevronRight, Wand2, LayoutGrid, FolderOpen, User, Building2, Menu } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { Button } from "@/components/ui/button";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { cn } from "@/lib/utils";
|
||
import { defaultAgentWorkflowUiConfig } from "@/lib/agents/workflow-ui-config";
|
||
import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config";
|
||
import {
|
||
buildAgentWorkflowComposerContext,
|
||
getAgentWorkflowProgress,
|
||
} from "@/lib/agents/workflow-composer";
|
||
import type {
|
||
AgentWorkflowComposerSelection,
|
||
AgentWorkflowMaterialSelections,
|
||
AgentWorkflowStepKey,
|
||
AgentWorkflowUiConfig,
|
||
} from "@/types/agent";
|
||
|
||
export interface AgentWorkflowInputs {
|
||
primarySku: string;
|
||
secondarySku: string;
|
||
brand: string;
|
||
store: string;
|
||
}
|
||
|
||
interface AgentWorkflowPanelDarkV2Props {
|
||
config?: AgentWorkflowUiConfig | null;
|
||
inputs: AgentWorkflowInputs;
|
||
onInputsChange: (next: AgentWorkflowInputs) => void;
|
||
materials: AgentWorkflowMaterialSelections;
|
||
onMaterialsChange: (next: AgentWorkflowMaterialSelections) => void;
|
||
selections: Partial<Record<AgentWorkflowStepKey, string>>;
|
||
onSelectionsChange: (next: Partial<Record<AgentWorkflowStepKey, string>>) => void;
|
||
onReset?: () => void;
|
||
onNextStep?: () => void;
|
||
onSkipStep?: () => void;
|
||
}
|
||
|
||
function buildSelectionSummary(params: {
|
||
config: AgentWorkflowUiConfig;
|
||
selections: Partial<Record<AgentWorkflowStepKey, string>>;
|
||
}) {
|
||
const result: AgentWorkflowComposerSelection[] = [];
|
||
for (const step of params.config.steps) {
|
||
const selectedOptionKey = params.selections[step.key];
|
||
if (!selectedOptionKey) continue;
|
||
const option = step.options.find((item) => item.key === selectedOptionKey && item.enabled);
|
||
if (!option) continue;
|
||
result.push({
|
||
stepKey: step.key,
|
||
stepTitle: step.title,
|
||
optionKey: option.key,
|
||
optionName: option.buttonName,
|
||
promptFragment: option.promptFragment || "",
|
||
});
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function StepOptionCard({
|
||
option,
|
||
step,
|
||
selected,
|
||
onClick,
|
||
index,
|
||
}: {
|
||
option: { key: string; buttonName: string; description?: string | null; previewImageUrl?: string | null };
|
||
step: { title: string };
|
||
selected: boolean;
|
||
onClick: () => void;
|
||
index: number;
|
||
}) {
|
||
return (
|
||
<motion.button
|
||
type="button"
|
||
onClick={onClick}
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.4, delay: index * 0.05 }}
|
||
whileHover={{ y: -4, scale: 1.02 }}
|
||
whileTap={{ scale: 0.98 }}
|
||
className={cn(
|
||
"group relative overflow-hidden rounded-2xl text-left",
|
||
"aspect-[16/10] w-full",
|
||
"transition-all duration-500",
|
||
selected
|
||
? "ring-2 ring-white/30 shadow-[0_0_30px_rgba(255,255,255,0.1)]"
|
||
: "ring-1 ring-white/5 hover:ring-white/20"
|
||
)}
|
||
>
|
||
{/* 背景 */}
|
||
<div className="absolute inset-0 bg-zinc-900">
|
||
{option.previewImageUrl ? (
|
||
<>
|
||
<img
|
||
src={option.previewImageUrl}
|
||
alt={option.buttonName}
|
||
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||
/>
|
||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent" />
|
||
</>
|
||
) : (
|
||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-zinc-800 to-zinc-900">
|
||
<Sparkles className="h-10 w-10 text-white/20" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 选中标记 */}
|
||
{selected && (
|
||
<motion.div
|
||
initial={{ scale: 0 }}
|
||
animate={{ scale: 1 }}
|
||
className="absolute right-3 top-3 flex h-6 w-6 items-center justify-center rounded-full bg-white text-black"
|
||
>
|
||
<Check className="h-4 w-4" />
|
||
</motion.div>
|
||
)}
|
||
|
||
{/* 步骤标签 */}
|
||
<div className="absolute left-3 top-3">
|
||
<span className="rounded-full bg-black/60 px-2.5 py-1 text-[10px] font-medium text-white/80 backdrop-blur-md">
|
||
{step.title}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 底部信息 */}
|
||
<div className="absolute bottom-0 left-0 right-0 p-4">
|
||
<h4 className="text-sm font-semibold text-white drop-shadow-lg">
|
||
{option.buttonName}
|
||
</h4>
|
||
{option.description && (
|
||
<p className="mt-1 line-clamp-1 text-xs text-white/60">
|
||
{option.description}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* 选中边框 */}
|
||
{selected && (
|
||
<div className="absolute inset-0 rounded-2xl ring-2 ring-inset ring-white/30" />
|
||
)}
|
||
</motion.button>
|
||
);
|
||
}
|
||
|
||
export function AgentWorkflowPanelDarkV2({
|
||
config,
|
||
inputs,
|
||
onInputsChange,
|
||
materials,
|
||
onMaterialsChange,
|
||
selections,
|
||
onSelectionsChange,
|
||
onReset,
|
||
onNextStep,
|
||
onSkipStep,
|
||
}: AgentWorkflowPanelDarkV2Props) {
|
||
const brandOptions = useMemo(
|
||
() => config?.inputOptions?.brands || defaultAgentWorkflowUiConfig.inputOptions?.brands || [],
|
||
[config?.inputOptions?.brands]
|
||
);
|
||
|
||
const storeOptions = useMemo(
|
||
() => config?.inputOptions?.stores || defaultAgentWorkflowUiConfig.inputOptions?.stores || [],
|
||
[config?.inputOptions?.stores]
|
||
);
|
||
|
||
const resolvedConfig = useMemo(() => {
|
||
const source = config || defaultAgentWorkflowUiConfig;
|
||
return {
|
||
steps: [...source.steps]
|
||
.filter((step) => step.enabled)
|
||
.map((step) => ({
|
||
...step,
|
||
options: [...step.options]
|
||
.filter((option) => option.enabled)
|
||
.sort((left, right) => left.sortOrder - right.sortOrder),
|
||
}))
|
||
.filter((step) => step.options.length > 0)
|
||
.sort((left, right) => left.sortOrder - right.sortOrder),
|
||
};
|
||
}, [config]);
|
||
|
||
const summary = useMemo(
|
||
() => buildSelectionSummary({ config: resolvedConfig, selections }),
|
||
[resolvedConfig, selections]
|
||
);
|
||
|
||
const composerContext = useMemo(
|
||
() =>
|
||
buildAgentWorkflowComposerContext({
|
||
config: resolvedConfig,
|
||
inputs,
|
||
materials,
|
||
selections,
|
||
}),
|
||
[inputs, materials, resolvedConfig, selections]
|
||
);
|
||
|
||
const progress = useMemo(
|
||
() => getAgentWorkflowProgress({ config: resolvedConfig, selections }),
|
||
[resolvedConfig, selections]
|
||
);
|
||
|
||
const handleCopyPrompt = async () => {
|
||
if (!composerContext?.promptText) {
|
||
toast.error("当前还没有可复制的 Prompt");
|
||
return;
|
||
}
|
||
try {
|
||
await navigator.clipboard.writeText(composerContext.promptText);
|
||
toast.success("Prompt 已复制");
|
||
} catch {
|
||
toast.error("复制失败");
|
||
}
|
||
};
|
||
|
||
// 导航标签配置
|
||
const navTabs = [
|
||
{ id: "cases", label: "案例广场", icon: LayoutGrid },
|
||
{ id: "my-works", label: "我的作品", icon: FolderOpen },
|
||
{ id: "personal-assets", label: "个人资产库", icon: User },
|
||
{ id: "company-assets", label: "公司资产库", icon: Building2 },
|
||
];
|
||
|
||
return (
|
||
<div className="min-h-screen bg-black">
|
||
{/* 顶部固定导航栏 */}
|
||
<header className="fixed top-0 left-0 right-0 z-50 border-b border-white/5 bg-black/80 backdrop-blur-xl">
|
||
<div className="mx-auto max-w-[1800px] px-6">
|
||
<div className="flex h-16 items-center justify-between">
|
||
{/* Logo & Site Name */}
|
||
<div className="flex items-center gap-3">
|
||
<button className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/5 text-white/60 hover:bg-white/10 hover:text-white transition-colors">
|
||
<Menu className="h-5 w-5" />
|
||
</button>
|
||
<div className="flex items-center gap-2.5">
|
||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500 to-purple-600">
|
||
<Wand2 className="h-4 w-4 text-white" />
|
||
</div>
|
||
<span className="text-base font-semibold text-white">AI创作平台</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 导航标签 */}
|
||
<nav className="flex items-center gap-1 rounded-full bg-white/5 p-1">
|
||
{navTabs.map((tab) => {
|
||
const Icon = tab.icon;
|
||
return (
|
||
<button
|
||
key={tab.id}
|
||
className="flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium text-white/60 transition-all duration-300 hover:text-white hover:bg-white/5"
|
||
>
|
||
<Icon className="h-4 w-4" />
|
||
{tab.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</nav>
|
||
|
||
{/* 右侧操作 */}
|
||
<div className="flex items-center gap-3">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => {
|
||
if (onReset) {
|
||
onReset();
|
||
return;
|
||
}
|
||
onInputsChange({ primarySku: "", secondarySku: "", brand: "", store: "" });
|
||
onMaterialsChange({
|
||
primary: createEmptyAgentWorkflowMaterialSelection(),
|
||
secondary: createEmptyAgentWorkflowMaterialSelection(),
|
||
});
|
||
onSelectionsChange({});
|
||
}}
|
||
className="h-9 gap-1.5 rounded-full text-white/60 hover:bg-white/10 hover:text-white"
|
||
>
|
||
<RotateCcw className="h-3.5 w-3.5" />
|
||
重置
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Hero Section */}
|
||
<section className="relative pt-16">
|
||
<div className="mx-auto max-w-[1800px] px-6 py-8">
|
||
{/* 标题区域 */}
|
||
<motion.div
|
||
className="mb-8 text-center"
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.6 }}
|
||
>
|
||
<h1 className="mb-3 text-3xl font-bold text-white md:text-4xl">
|
||
三倍速出片,电影级质感
|
||
</h1>
|
||
<p className="mb-6 text-base text-white/50">
|
||
<span className="text-emerald-400 font-medium">第一步:风格选择</span>
|
||
<span className="mx-2 text-white/20">→</span>
|
||
<span className="text-white/40">第二步:智能体生图</span>
|
||
</p>
|
||
</motion.div>
|
||
|
||
{/* 进度指示 */}
|
||
<motion.div
|
||
className="mx-auto mb-8 max-w-md"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
transition={{ delay: 0.2 }}
|
||
>
|
||
<div className="flex items-center justify-between text-sm text-white/50 mb-2">
|
||
<span>完成进度</span>
|
||
<span className="font-medium text-white">{progress.completed}/{progress.total}</span>
|
||
</div>
|
||
<div className="h-2 overflow-hidden rounded-full bg-white/10">
|
||
<motion.div
|
||
className="h-full rounded-full bg-gradient-to-r from-emerald-500 to-teal-500"
|
||
initial={{ width: 0 }}
|
||
animate={{ width: `${progress.total > 0 ? (progress.completed / progress.total) * 100 : 0}%` }}
|
||
transition={{ duration: 0.5 }}
|
||
/>
|
||
</div>
|
||
</motion.div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* Main Content */}
|
||
<main className="pb-20">
|
||
<div className="mx-auto max-w-[1800px] px-6">
|
||
<ScrollArea className="h-[calc(100vh-300px)]">
|
||
<div className="space-y-8 pr-4">
|
||
{/* 基础配置 */}
|
||
<motion.section
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ delay: 0.1 }}
|
||
>
|
||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-white">
|
||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-emerald-500/20 text-xs font-bold text-emerald-400">
|
||
1
|
||
</span>
|
||
基础配置
|
||
</h2>
|
||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 max-w-2xl">
|
||
<SearchableSelect
|
||
value={inputs.brand}
|
||
options={brandOptions}
|
||
placeholder="选择品牌"
|
||
searchPlaceholder="输入品牌名筛选"
|
||
emptyText="暂无品牌可选"
|
||
onValueChange={(value) => onInputsChange({ ...inputs, brand: value })}
|
||
/>
|
||
<SearchableSelect
|
||
value={inputs.store}
|
||
options={storeOptions}
|
||
placeholder="选择店铺"
|
||
searchPlaceholder="输入店铺名筛选"
|
||
emptyText="暂无店铺可选"
|
||
onValueChange={(value) => onInputsChange({ ...inputs, store: value })}
|
||
/>
|
||
</div>
|
||
</motion.section>
|
||
|
||
{/* 工作流步骤 */}
|
||
{resolvedConfig.steps.map((step, stepIndex) => (
|
||
<motion.section
|
||
key={step.key}
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ delay: 0.15 + stepIndex * 0.05 }}
|
||
>
|
||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-white">
|
||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-emerald-500/20 text-xs font-bold text-emerald-400">
|
||
{stepIndex + 2}
|
||
</span>
|
||
{step.title}
|
||
</h2>
|
||
{step.description && (
|
||
<p className="mb-4 text-sm text-white/40">{step.description}</p>
|
||
)}
|
||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||
{step.options.map((option, optionIndex) => (
|
||
<StepOptionCard
|
||
key={option.key}
|
||
option={option}
|
||
step={step}
|
||
selected={selections[step.key] === option.key}
|
||
onClick={() =>
|
||
onSelectionsChange({
|
||
...selections,
|
||
[step.key]: selections[step.key] === option.key ? undefined : option.key,
|
||
})
|
||
}
|
||
index={optionIndex}
|
||
/>
|
||
))}
|
||
</div>
|
||
</motion.section>
|
||
))}
|
||
|
||
{/* 当前选择摘要 */}
|
||
{summary.length > 0 && (
|
||
<motion.section
|
||
className="rounded-2xl border border-white/5 bg-white/[0.02] p-6"
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
>
|
||
<h3 className="mb-4 text-sm font-semibold text-white/80">当前选择</h3>
|
||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||
{summary.map((item) => (
|
||
<div
|
||
key={`${item.stepKey}-${item.optionKey}`}
|
||
className="flex items-center justify-between rounded-xl bg-white/5 px-4 py-3"
|
||
>
|
||
<span className="text-sm text-white/50">{item.stepTitle}</span>
|
||
<span className="text-sm font-medium text-white">{item.optionName}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</motion.section>
|
||
)}
|
||
|
||
{/* Prompt 预览 */}
|
||
<motion.section
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
>
|
||
<div className="mb-4 flex items-center justify-between">
|
||
<h3 className="text-sm font-semibold text-white/80">Prompt 预览</h3>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={handleCopyPrompt}
|
||
className="h-8 gap-1.5 text-xs text-white/60 hover:bg-white/10 hover:text-white"
|
||
>
|
||
<Copy className="h-3.5 w-3.5" />
|
||
复制
|
||
</Button>
|
||
</div>
|
||
<Textarea
|
||
value={composerContext?.promptText || ""}
|
||
readOnly
|
||
rows={6}
|
||
placeholder="选择工作流后,这里会自动生成结构化 Prompt"
|
||
className="resize-none rounded-xl border-white/5 bg-white/[0.03] text-sm text-white/80 placeholder:text-white/20 focus:border-white/10"
|
||
/>
|
||
</motion.section>
|
||
|
||
{/* 底部操作 */}
|
||
{onNextStep && (
|
||
<motion.div
|
||
className="flex justify-end gap-3 pt-4 pb-8"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
>
|
||
{onSkipStep && (
|
||
<Button
|
||
variant="ghost"
|
||
onClick={onSkipStep}
|
||
className="h-11 gap-2 rounded-full text-white/60 hover:bg-white/10 hover:text-white"
|
||
>
|
||
跳过
|
||
<ChevronRight className="h-4 w-4" />
|
||
</Button>
|
||
)}
|
||
<Button
|
||
onClick={onNextStep}
|
||
disabled={progress.total > 0 && !progress.isComplete}
|
||
className={cn(
|
||
"h-11 gap-2 rounded-full px-8",
|
||
"bg-gradient-to-r from-emerald-500 to-teal-500",
|
||
"text-white font-medium",
|
||
"hover:from-emerald-400 hover:to-teal-400",
|
||
"shadow-[0_0_30px_rgba(16,185,129,0.3)]",
|
||
"disabled:opacity-30"
|
||
)}
|
||
>
|
||
下一步:选择智能体
|
||
<ArrowRight className="h-4 w-4" />
|
||
</Button>
|
||
</motion.div>
|
||
)}
|
||
</div>
|
||
</ScrollArea>
|
||
</div>
|
||
</main>
|
||
|
||
{/* 页脚 */}
|
||
<footer className="border-t border-white/5 py-6">
|
||
<div className="mx-auto max-w-[1800px] px-6">
|
||
<div className="flex items-center justify-center gap-6 text-sm text-white/30">
|
||
<a href="#" className="hover:text-white/60 transition-colors">使用协议</a>
|
||
<a href="#" className="hover:text-white/60 transition-colors">隐私条款</a>
|
||
<span>© 2024 AI创作平台</span>
|
||
</div>
|
||
</div>
|
||
</footer>
|
||
</div>
|
||
);
|
||
}
|