ai-chatbot/components/style-pro/StyleProWorkspace.tsx
Your Name 5387d6f1d2
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add style pro canvas workspace
2026-05-16 15:06:12 +08:00

887 lines
34 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 {
ArrowLeft,
Clock3,
Home,
ImagePlus,
Loader2,
RotateCcw,
Sparkles,
Upload,
UserRound,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { imageGenerationService } from "@/services/canvas";
import { cn, fetcher } from "@/lib/utils";
import type {
PublicStyleProCategory,
PublicStyleProConfig,
StyleProHistoryItem,
StyleProPersistedState,
StyleProSelectionMap,
} from "@/lib/style-pro/config";
type BrandingSettings = {
siteName?: string;
siteLogo?: string | null;
siteLogoDark?: string | null;
};
type StyleProConfigResponse = {
success: boolean;
config?: PublicStyleProConfig;
error?: string;
};
type StyleProGenerateResponse = {
success: boolean;
error?: string;
data?: {
imageUrl: string;
thumbnailUrl?: string;
model?: string;
modelRef?: string;
selections: StyleProSelectionMap;
optionLabels: string[];
};
};
type StyleProWorkspaceProps = {
canvasId: string;
initialSourceImageUrl?: string | null;
persistedState?: StyleProPersistedState | null;
onAddToCanvas?: (item: StyleProHistoryItem) => void | Promise<void>;
onBack?: () => void;
onPersistState?: (state: StyleProPersistedState) => void | Promise<void>;
};
function getHistoryId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `style-pro-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function firstEnabledOptionId(category: PublicStyleProCategory) {
return category.options.find((option) => option.enabled)?.id || "";
}
function loadImageDimensions(url: string) {
return new Promise<{ width?: number; height?: number }>((resolve) => {
if (typeof window === "undefined" || !url) {
resolve({});
return;
}
const image = new window.Image();
image.onload = () => {
resolve({
width: image.naturalWidth || undefined,
height: image.naturalHeight || undefined,
});
};
image.onerror = () => resolve({});
image.src = url;
});
}
function normalizeHistory(history: StyleProHistoryItem[] | undefined) {
return Array.isArray(history) ? history.slice(0, 4) : [];
}
function ImagePreview({
alt,
className,
imageUrl,
}: {
alt: string;
className?: string;
imageUrl?: string | null;
}) {
if (!imageUrl) {
return (
<div
className={cn(
"flex h-full min-h-[220px] items-center justify-center rounded-lg border border-dashed border-white/15 bg-white/[0.03] text-sm text-white/45",
className
)}
>
等待图片
</div>
);
}
return (
<img
alt={alt}
className={cn("h-full w-full rounded-lg object-contain", className)}
draggable={false}
src={imageUrl}
/>
);
}
function GeneratingImagePlaceholder() {
return (
<div className="min-h-[460px] rounded-lg border border-white/10 bg-white p-5 text-neutral-800">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<div className="font-semibold text-[15px] text-neutral-700">
正在创建图片
</div>
<div className="mt-1 text-xs text-neutral-400">
正在理解图片与提示词组合
</div>
</div>
<span className="inline-flex items-center gap-2 rounded-lg bg-neutral-100 px-3 py-1.5 text-xs font-medium text-neutral-500">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
生成中
</span>
</div>
<div className="style-pro-generating-frame relative mx-auto h-[390px] max-h-[58vh] w-full max-w-[340px] overflow-hidden rounded-lg bg-[#f4f4f4]">
<div className="style-pro-dot-field absolute inset-0" />
<div className="style-pro-light-field absolute inset-0" />
<div className="style-pro-scan-field absolute inset-0" />
</div>
<style>{`
@keyframes styleProDotDrift {
0% { transform: translate3d(-10px, -8px, 0); opacity: 0.35; }
45% { transform: translate3d(8px, 18px, 0); opacity: 0.78; }
100% { transform: translate3d(18px, -4px, 0); opacity: 0.42; }
}
@keyframes styleProLightFloat {
0% { transform: translate3d(-18%, -10%, 0) scale(1); opacity: 0.72; }
50% { transform: translate3d(18%, 16%, 0) scale(1.06); opacity: 0.98; }
100% { transform: translate3d(34%, -4%, 0) scale(1); opacity: 0.74; }
}
@keyframes styleProScanMove {
0% { transform: translateX(-80%); opacity: 0; }
18% { opacity: 0.65; }
54% { opacity: 0.82; }
100% { transform: translateX(80%); opacity: 0; }
}
.style-pro-generating-frame {
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.025), 0 18px 50px rgba(0,0,0,0.08);
}
.style-pro-dot-field {
background-image: radial-gradient(circle, rgba(116,116,116,0.32) 1.05px, transparent 1.05px);
background-size: 16px 16px;
mask-image: radial-gradient(ellipse 88% 72% at 50% 44%, #000 0%, rgba(0,0,0,0.78) 48%, transparent 82%);
animation: styleProDotDrift 3.8s ease-in-out infinite alternate;
}
.style-pro-light-field {
background:
radial-gradient(circle at 24% 24%, rgba(255,255,255,0.96) 0%, rgba(255,255,255,0.82) 22%, transparent 45%),
radial-gradient(circle at 70% 72%, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.7) 20%, transparent 44%);
animation: styleProLightFloat 4.4s ease-in-out infinite alternate;
}
.style-pro-scan-field {
width: 150%;
left: -25%;
background: linear-gradient(105deg, transparent 20%, rgba(255,255,255,0.92) 48%, transparent 74%);
animation: styleProScanMove 3.4s ease-in-out infinite;
}
`}</style>
</div>
);
}
function BeforeAfterSlider({
afterUrl,
beforeUrl,
generating = false,
}: {
afterUrl?: string | null;
beforeUrl?: string | null;
generating?: boolean;
}) {
const [position, setPosition] = useState(50);
if (generating) {
return <GeneratingImagePlaceholder />;
}
if (!beforeUrl && !afterUrl) {
return (
<div className="flex min-h-[460px] items-center justify-center rounded-lg border border-dashed border-white/15 bg-white/[0.03] text-white/45">
上传图片后开始生成
</div>
);
}
if (!afterUrl) {
return (
<div className="min-h-[460px] rounded-lg border border-white/10 bg-[#111] p-3">
<ImagePreview alt="上传图片" imageUrl={beforeUrl} />
</div>
);
}
return (
<div className="relative min-h-[460px] overflow-hidden rounded-lg border border-white/10 bg-[#111]">
<ImagePreview
alt="生成后"
className="absolute inset-0 h-full w-full"
imageUrl={afterUrl}
/>
<div
className="absolute inset-0 overflow-hidden"
style={{ clipPath: `inset(0 ${100 - position}% 0 0)` }}
>
<ImagePreview
alt="生成前"
className="h-full w-full"
imageUrl={beforeUrl}
/>
</div>
<div
className="pointer-events-none absolute inset-y-0 w-px bg-white"
style={{ left: `${position}%` }}
>
<span className="-translate-x-1/2 absolute top-1/2 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/70 bg-black/70 text-[11px] font-semibold text-white shadow-lg">
对比
</span>
</div>
<input
aria-label="图像对比滑块"
className="absolute inset-x-4 bottom-5 z-10 accent-emerald-400"
max={100}
min={0}
onChange={(event) => setPosition(Number(event.target.value))}
type="range"
value={position}
/>
<div className="absolute left-4 top-4 rounded-full bg-black/65 px-3 py-1 text-xs text-white/80">
原图
</div>
<div className="absolute right-4 top-4 rounded-full bg-emerald-500/90 px-3 py-1 text-xs font-semibold text-black">
结果
</div>
</div>
);
}
export function StyleProWorkspace({
canvasId,
initialSourceImageUrl,
onAddToCanvas,
onBack,
onPersistState,
persistedState,
}: StyleProWorkspaceProps) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const { data: brandingData } = useSWR<BrandingSettings>(
"/api/site-settings?scope=branding",
fetcher
);
const { data: configData, isLoading: configLoading } =
useSWR<StyleProConfigResponse>("/api/style-pro/config", fetcher);
const config = configData?.config;
const enabledCategories = useMemo(
() =>
(config?.categories || [])
.filter((category) => category.enabled)
.map((category) => ({
...category,
options: category.options.filter((option) => option.enabled),
}))
.filter((category) => category.options.length > 0),
[config?.categories]
);
const [sourceImageUrl, setSourceImageUrl] = useState(
persistedState?.sourceImageUrl || initialSourceImageUrl || ""
);
const [currentResult, setCurrentResult] =
useState<StyleProHistoryItem | null>(
normalizeHistory(persistedState?.history)[0] || null
);
const [history, setHistory] = useState<StyleProHistoryItem[]>(
normalizeHistory(persistedState?.history)
);
const [selections, setSelections] = useState<StyleProSelectionMap>({});
const [uploading, setUploading] = useState(false);
const [generating, setGenerating] = useState(false);
useEffect(() => {
const nextSource =
persistedState?.sourceImageUrl || initialSourceImageUrl || "";
setSourceImageUrl((current) => current || nextSource);
const nextHistory = normalizeHistory(persistedState?.history);
setHistory(nextHistory);
setCurrentResult((current) => current || nextHistory[0] || null);
}, [initialSourceImageUrl, persistedState?.history, persistedState?.sourceImageUrl]);
const siteName = brandingData?.siteName || "由壹点AI";
const logo = brandingData?.siteLogoDark || brandingData?.siteLogo || "";
const selectedOptionLabels = useMemo(() => {
const labels: string[] = [];
for (const category of enabledCategories) {
const selected = selections[category.id] || [];
for (const optionId of selected) {
const option = category.options.find((item) => item.id === optionId);
if (option) {
labels.push(`${category.title}:${option.label}`);
}
}
}
return labels;
}, [enabledCategories, selections]);
const missingRequiredCategories = useMemo(
() =>
enabledCategories.filter((category) => {
if (!category.required && category.minSelections <= 0) return false;
const selectedCount = selections[category.id]?.length || 0;
return (
selectedCount <
Math.max(category.required ? 1 : 0, category.minSelections)
);
}),
[enabledCategories, selections]
);
const persistState = useCallback(
async (nextState: StyleProPersistedState) => {
await onPersistState?.({
sourceImageUrl: nextState.sourceImageUrl,
history: normalizeHistory(nextState.history),
});
},
[onPersistState]
);
const updateSourceImage = useCallback(
async (url: string) => {
const nextUrl = String(url || "").trim();
setSourceImageUrl(nextUrl);
setCurrentResult(null);
await persistState({ sourceImageUrl: nextUrl, history });
},
[history, persistState]
);
const handleFile = useCallback(
async (file: File | null | undefined) => {
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
return;
}
setUploading(true);
try {
const result = await imageGenerationService.uploadImage(file);
if (!result.success || !result.data?.url) {
throw new Error(result.error || "上传失败");
}
await updateSourceImage(result.data.url);
toast.success("图片已上传");
} catch (error) {
toast.error(error instanceof Error ? error.message : "上传失败");
} finally {
setUploading(false);
}
},
[updateSourceImage]
);
const toggleOption = useCallback(
(category: PublicStyleProCategory, optionId: string) => {
setSelections((current) => {
const previous = current[category.id] || [];
const selected = previous.includes(optionId);
let nextSelected = selected
? previous.filter((id) => id !== optionId)
: [...previous, optionId];
if (!selected && category.maxSelections <= 1) {
nextSelected = [optionId];
}
if (nextSelected.length > category.maxSelections) {
nextSelected = nextSelected.slice(-category.maxSelections);
}
return {
...current,
[category.id]: nextSelected,
};
});
},
[]
);
const quickFillRequired = useCallback(() => {
setSelections((current) => {
const next = { ...current };
for (const category of enabledCategories) {
if (!category.required || next[category.id]?.length) continue;
const firstId = firstEnabledOptionId(category);
if (firstId) next[category.id] = [firstId];
}
return next;
});
}, [enabledCategories]);
const handleGenerate = useCallback(async () => {
if (!sourceImageUrl) {
toast.error("请先上传一张图片");
return;
}
if (!config?.enabled) {
toast.error("风格化 PRO 暂未启用");
return;
}
if (missingRequiredCategories.length > 0) {
toast.error(`请完成必选项:${missingRequiredCategories[0].title}`);
return;
}
setGenerating(true);
try {
const response = await fetch("/api/style-pro/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
canvasId,
sourceImageUrl,
selections,
}),
});
const payload = (await response.json().catch(() => ({}))) as
| StyleProGenerateResponse
| undefined;
if (!response.ok || !payload?.success || !payload.data?.imageUrl) {
throw new Error(payload?.error || "生成失败");
}
const dimensions = await loadImageDimensions(payload.data.imageUrl);
const item: StyleProHistoryItem = {
id: getHistoryId(),
sourceImageUrl,
resultImageUrl: payload.data.imageUrl,
selections: payload.data.selections || selections,
optionLabels: payload.data.optionLabels || selectedOptionLabels,
createdAt: new Date().toISOString(),
model: payload.data.model,
modelRef: payload.data.modelRef,
width: dimensions.width,
height: dimensions.height,
};
const nextHistory = [item, ...history.filter((entry) => entry.id !== item.id)].slice(
0,
4
);
setHistory(nextHistory);
setCurrentResult(item);
await persistState({ sourceImageUrl, history: nextHistory });
toast.success("风格化 PRO 生成完成");
} catch (error) {
toast.error(error instanceof Error ? error.message : "生成失败");
} finally {
setGenerating(false);
}
}, [
canvasId,
config?.enabled,
history,
missingRequiredCategories,
persistState,
selectedOptionLabels,
selections,
sourceImageUrl,
]);
const restoreHistoryItem = useCallback((item: StyleProHistoryItem) => {
setSourceImageUrl(item.sourceImageUrl);
setSelections(item.selections || {});
setCurrentResult(item);
}, []);
const disabledReason = !sourceImageUrl
? "请先上传图片"
: missingRequiredCategories.length > 0
? `还差:${missingRequiredCategories[0].title}`
: "";
return (
<div className="flex h-dvh w-full overflow-hidden bg-[#050606] text-white">
<aside className="flex w-[76px] shrink-0 flex-col items-center border-white/10 border-r bg-black">
<button
className="mt-5 flex h-11 w-11 items-center justify-center rounded-lg bg-white/[0.06] text-white"
onClick={onBack}
title="返回画布"
type="button"
>
{logo ? (
<img alt={siteName} className="h-6 w-6 object-contain" src={logo} />
) : (
<Sparkles className="h-5 w-5 text-emerald-300" />
)}
</button>
<div className="mt-8 flex flex-col gap-3">
<button
className="flex h-11 w-11 items-center justify-center rounded-lg bg-emerald-400 text-black"
onClick={onBack}
title="返回画布"
type="button"
>
<Home className="h-5 w-5" />
</button>
<button
className="flex h-11 w-11 items-center justify-center rounded-lg bg-white/[0.05] text-white/75 hover:bg-white/[0.09] hover:text-white"
onClick={() => fileInputRef.current?.click()}
title="上传图片"
type="button"
>
<ImagePlus className="h-5 w-5" />
</button>
</div>
</aside>
<main className="flex min-w-0 flex-1 flex-col">
<header className="flex h-16 shrink-0 items-center justify-between border-white/10 border-b bg-black/85 px-6">
<div className="flex items-center gap-3">
<Button
className="rounded-lg border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]"
onClick={onBack}
size="sm"
variant="outline"
>
<ArrowLeft className="mr-2 h-4 w-4" />
返回画布
</Button>
<div>
<div className="text-sm text-white/45">{siteName}</div>
<h1 className="font-semibold text-xl">风格化 PRO</h1>
</div>
</div>
<button
className="flex items-center gap-2 rounded-lg border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white/80 hover:bg-white/[0.08] hover:text-white"
onClick={() => toast.info("个人中心入口已保留,可在首页打开完整账户面板")}
type="button"
>
<UserRound className="h-4 w-4" />
个人中心
</button>
</header>
<div className="grid min-h-0 flex-1 grid-cols-[360px_minmax(0,1fr)] overflow-hidden">
<section className="flex min-h-0 flex-col border-white/10 border-r bg-[#080909]">
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 p-5">
<div>
<div className="mb-3 flex items-center justify-between">
<div>
<h2 className="font-semibold text-base">上传图片</h2>
<p className="mt-1 text-sm text-white/45">
上传后选择按钮组合生成最终图。
</p>
</div>
<Badge className="border-emerald-400/30 bg-emerald-400/10 text-emerald-200">
Final
</Badge>
</div>
<button
className="group relative flex min-h-[230px] w-full items-center justify-center overflow-hidden rounded-lg border border-dashed border-white/15 bg-white/[0.03] transition hover:border-emerald-300/60 hover:bg-white/[0.05]"
onClick={() => fileInputRef.current?.click()}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
void handleFile(event.dataTransfer.files?.[0]);
}}
type="button"
>
{sourceImageUrl ? (
<img
alt="上传图片"
className="h-full max-h-[300px] w-full object-contain"
src={sourceImageUrl}
/>
) : (
<span className="flex flex-col items-center gap-3 text-white/55">
{uploading ? (
<Loader2 className="h-7 w-7 animate-spin" />
) : (
<Upload className="h-7 w-7" />
)}
<span className="text-sm">
点击上传,或拖入图片
</span>
</span>
)}
</button>
<input
accept="image/*"
className="hidden"
onChange={(event) => {
void handleFile(event.target.files?.[0]);
event.currentTarget.value = "";
}}
ref={fileInputRef}
type="file"
/>
</div>
<div>
<div className="mb-3 flex items-center justify-between">
<h2 className="font-semibold text-base">历史记录</h2>
<span className="text-xs text-white/40">最近 4 条</span>
</div>
<div className="grid grid-cols-2 gap-3">
{history.length > 0 ? (
history.map((item) => (
<button
className={cn(
"group overflow-hidden rounded-lg border bg-white/[0.03] text-left transition hover:border-emerald-300/70",
currentResult?.id === item.id
? "border-emerald-300/80"
: "border-white/10"
)}
key={item.id}
onClick={() => restoreHistoryItem(item)}
type="button"
>
<div className="aspect-square bg-black">
<img
alt="风格化历史"
className="h-full w-full object-cover"
src={item.resultImageUrl}
/>
</div>
<div className="space-y-1 p-2">
<div className="flex items-center gap-1 text-[11px] text-white/50">
<Clock3 className="h-3 w-3" />
{new Date(item.createdAt).toLocaleTimeString(
"zh-CN",
{
hour: "2-digit",
minute: "2-digit",
}
)}
</div>
<div className="line-clamp-1 text-xs text-white/80">
{item.optionLabels[0] || "风格化结果"}
</div>
</div>
</button>
))
) : (
<div className="col-span-2 rounded-lg border border-dashed border-white/12 p-5 text-center text-sm text-white/40">
生成后会在这里保存历史。
</div>
)}
</div>
</div>
</div>
</ScrollArea>
</section>
<section className="flex min-w-0 flex-col overflow-hidden bg-[#0b0c0c]">
<div className="border-white/10 border-b p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-emerald-300" />
<h2 className="font-semibold text-lg">提示词按钮组合</h2>
</div>
<p className="mt-1 text-sm text-white/45">
完成必选项后执行,后台隐藏提示词会参与最终生成。
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
className="rounded-lg border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]"
onClick={quickFillRequired}
size="sm"
variant="outline"
>
<Sparkles className="mr-2 h-4 w-4" />
自动补全必选
</Button>
<Button
className="rounded-lg border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]"
onClick={() => setSelections({})}
size="sm"
variant="outline"
>
<RotateCcw className="mr-2 h-4 w-4" />
清空选择
</Button>
</div>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-6 p-5">
{configLoading ? (
<div className="flex min-h-[180px] items-center justify-center rounded-lg border border-white/10 bg-white/[0.03] text-white/55">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
正在加载配置
</div>
) : !config?.enabled ? (
<div className="rounded-lg border border-amber-400/30 bg-amber-400/10 p-5 text-amber-100">
风格化 PRO 暂未启用,请联系管理员开启。
</div>
) : (
<div className="space-y-4">
{enabledCategories.map((category, index) => {
const selectedIds = selections[category.id] || [];
const complete =
selectedIds.length >=
Math.max(
category.required ? 1 : 0,
category.minSelections
);
return (
<div
className="rounded-lg border border-white/10 bg-white/[0.035] p-4"
key={category.id}
>
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="flex items-start gap-3">
<span
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-semibold",
complete
? "bg-emerald-300 text-black"
: "bg-white/[0.08] text-white/65"
)}
>
{index + 1}
</span>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-white">
{category.title}
</h3>
{category.required ? (
<Badge className="border-emerald-400/30 bg-emerald-400/10 text-emerald-200">
必选
</Badge>
) : (
<Badge
className="border-white/10 text-white/55"
variant="outline"
>
可选
</Badge>
)}
</div>
{category.description ? (
<p className="mt-1 text-sm text-white/45">
{category.description}
</p>
) : null}
</div>
</div>
<span className="rounded-full border border-white/10 px-3 py-1 text-xs text-white/45">
已选 {selectedIds.length}/{category.maxSelections}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{category.options.map((option) => {
const selected = selectedIds.includes(option.id);
return (
<button
className={cn(
"min-h-[96px] rounded-lg border p-4 text-left transition",
selected
? "border-emerald-300/80 bg-emerald-300/12 shadow-[0_0_0_1px_rgba(110,231,183,0.25)]"
: "border-white/10 bg-black/25 hover:border-white/25 hover:bg-white/[0.05]"
)}
key={option.id}
onClick={() => toggleOption(category, option.id)}
type="button"
>
<div className="flex items-start justify-between gap-3">
<span className="font-semibold text-sm text-white">
{option.label}
</span>
{selected ? (
<span className="rounded-full bg-emerald-300 px-2 py-0.5 text-[11px] font-semibold text-black">
已选
</span>
) : null}
</div>
{option.description ? (
<p className="mt-2 line-clamp-2 text-xs leading-relaxed text-white/45">
{option.description}
</p>
) : null}
</button>
);
})}
</div>
</div>
);
})}
</div>
)}
<div className="rounded-lg border border-white/10 bg-white/[0.035] p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-lg">图像对比</h2>
<p className="mt-1 text-sm text-white/45">
生成完成后拖动滑块查看前后差异。
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
className="rounded-lg bg-emerald-400 text-black hover:bg-emerald-300"
disabled={generating || Boolean(disabledReason)}
onClick={handleGenerate}
>
{generating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{generating ? "生成中" : "开始生成"}
</Button>
<Button
className="rounded-lg border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]"
disabled={!currentResult}
onClick={() => currentResult && onAddToCanvas?.(currentResult)}
variant="outline"
>
添加到画布
</Button>
</div>
</div>
{disabledReason ? (
<div className="mb-3 rounded-lg border border-white/10 bg-black/35 px-3 py-2 text-sm text-white/45">
{disabledReason}
</div>
) : null}
<BeforeAfterSlider
afterUrl={currentResult?.resultImageUrl}
beforeUrl={currentResult?.sourceImageUrl || sourceImageUrl}
generating={generating}
/>
{selectedOptionLabels.length > 0 ? (
<div className="mt-4 flex flex-wrap gap-2">
{selectedOptionLabels.map((label) => (
<Badge
className="border-white/10 bg-white/[0.04] text-white/70"
key={label}
variant="outline"
>
{label}
</Badge>
))}
</div>
) : null}
</div>
</div>
</ScrollArea>
</section>
</div>
</main>
</div>
);
}