ai-chatbot/components/style-pro/StyleProWorkspace.tsx
Your Name fa2f26bf97
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: align style pro UI with index prototype
2026-05-16 19:34:38 +08:00

954 lines
35 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 { 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-gray-200 bg-gray-50 text-gray-400 text-sm",
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-gray-200 bg-gray-50 text-gray-400">
上传图片后开始生成
</div>
);
}
if (!afterUrl) {
return (
<div className="min-h-[460px] rounded-lg border border-gray-200 bg-gray-50 p-3">
<ImagePreview alt="上传图片" imageUrl={beforeUrl} />
</div>
);
}
return (
<div className="relative min-h-[460px] overflow-hidden rounded-lg border border-gray-200 bg-gray-50">
<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="style-pro-index-shell flex h-dvh w-full overflow-hidden bg-gray-50 text-gray-900">
<style>{`
.style-pro-index-shell .section-card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 16px;
box-shadow: 0 12px 35px rgba(15, 23, 42, 0.06);
}
.style-pro-index-shell .subsection-title {
color: #2563eb;
font-size: 14px;
font-weight: 700;
white-space: nowrap;
}
.style-pro-index-shell .option-btn {
border: 1px solid #e5e7eb;
background: #f9fafb;
color: #374151;
transition: all 0.18s ease;
}
.style-pro-index-shell .option-btn:hover {
background: #1f2937;
border-color: #1f2937;
color: #fff;
}
.style-pro-index-shell .option-btn.active {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.15), rgba(37, 99, 235, 0.05));
color: #2563eb;
border-color: #60a5fa;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
}
.style-pro-index-shell .preset-btn {
border-radius: 9999px;
transition: all 0.18s ease;
}
.style-pro-index-shell .custom-input {
border: 1px solid #e5e7eb;
background: #fafafa;
color: #111827;
outline: none;
}
.style-pro-index-shell .custom-input:focus {
border-color: #60a5fa;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.18);
}
`}</style>
<aside className="flex w-[76px] shrink-0 flex-col items-center border-gray-200 border-r bg-white">
<button
className="mt-5 flex h-11 w-11 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-gray-800"
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-blue-600" />
)}
</button>
<div className="mt-8 flex flex-col gap-3">
<button
className="flex h-11 w-11 items-center justify-center rounded-lg bg-blue-600 text-white shadow-sm"
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 border border-gray-200 bg-gray-50 text-gray-600 hover:bg-gray-100 hover:text-gray-900"
onClick={() => fileInputRef.current?.click()}
title="上传图片"
type="button"
>
<ImagePlus className="h-5 w-5" />
</button>
</div>
</aside>
<main className="min-w-0 flex-1 overflow-y-auto">
<div className="sticky top-0 z-40 border-gray-200 border-b bg-white">
<div className="mx-auto flex max-w-[1920px] items-center justify-between gap-4 px-6 py-4">
<div className="flex items-center gap-3">
<Button
className="rounded-lg border-gray-200 bg-white text-gray-700 hover:bg-gray-100"
onClick={onBack}
size="sm"
variant="outline"
>
<ArrowLeft className="mr-2 h-4 w-4" />
返回画布
</Button>
<div className="flex items-center gap-2">
{logo ? (
<img alt={siteName} className="h-8 w-8 object-contain" src={logo} />
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-600 text-white">
<Sparkles className="h-4 w-4" />
</div>
)}
<div>
<div className="font-semibold text-gray-900 text-sm">{siteName}</div>
<div className="text-gray-500 text-xs">风格化 PRO</div>
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
<button
className="preset-btn flex items-center gap-2 border-2 border-blue-400 bg-gradient-to-r from-blue-500/20 to-blue-300/5 px-5 py-2 font-bold text-blue-700 text-sm"
onClick={quickFillRequired}
type="button"
>
<UserRound className="h-4 w-4" />
执行者
</button>
<button
className="preset-btn flex items-center gap-2 border-2 border-blue-400 bg-gradient-to-r from-blue-500/20 to-blue-300/5 px-5 py-2 font-bold text-blue-700 text-sm disabled:opacity-50"
disabled={generating || Boolean(disabledReason)}
onClick={handleGenerate}
type="button"
>
{generating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
执行
</button>
<button
className="preset-btn flex items-center gap-2 border-2 border-blue-400 bg-gradient-to-r from-blue-500/20 to-blue-300/5 px-5 py-2 font-bold text-blue-700 text-sm"
onClick={() => {
setSelections({});
setCurrentResult(null);
}}
type="button"
>
<RotateCcw className="h-4 w-4" />
重置
</button>
<button
className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-gray-700 text-sm hover:bg-gray-100"
onClick={() => toast.info("个人中心入口已保留,可在首页打开完整账户面板")}
type="button"
>
<UserRound className="h-4 w-4" />
个人中心
</button>
</div>
</div>
</div>
<div className="mx-auto max-w-[1920px] space-y-6 px-6 py-8">
<section className="section-card p-6">
<div className="mb-5 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="font-black text-4xl text-gray-900 lg:text-5xl">
风格化 PRO
</h1>
<p className="mt-2 text-gray-500 text-sm">
上传图片,选择下方按钮组合,按执行生成最终图片。
</p>
</div>
<div className="flex flex-wrap gap-2">
<Badge className="border-blue-200 bg-blue-50 text-blue-800">
{selectedOptionLabels.length
? `已选择 ${selectedOptionLabels.length} 项`
: "等待选择"}
</Badge>
{disabledReason ? (
<Badge className="border-amber-200 bg-amber-50 text-amber-700">
{disabledReason}
</Badge>
) : null}
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
<div className="space-y-4">
<div className="rounded-xl bg-gray-50 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<span className="subsection-title">上传图片</span>
<span className="text-gray-400 text-xs">支持拖拽上传</span>
</div>
<button
className="flex min-h-[260px] w-full items-center justify-center overflow-hidden rounded-lg border border-dashed border-gray-300 bg-white transition hover:border-blue-400"
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-[320px] w-full object-contain"
src={sourceImageUrl}
/>
) : (
<span className="flex flex-col items-center gap-3 text-gray-500">
{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 className="rounded-xl bg-gray-50 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<span className="subsection-title">历史记录</span>
<span className="text-gray-400 text-xs">最近 4 条</span>
</div>
<div className="grid grid-cols-2 gap-3">
{history.length > 0 ? (
history.map((item) => (
<button
className={cn(
"overflow-hidden rounded-lg border bg-white text-left transition hover:border-blue-400",
currentResult?.id === item.id
? "border-blue-400 shadow-[0_4px_12px_rgba(59,130,246,0.16)]"
: "border-gray-200"
)}
key={item.id}
onClick={() => restoreHistoryItem(item)}
type="button"
>
<div className="aspect-square bg-gray-100">
<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-gray-400">
<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-gray-700 text-xs">
{item.optionLabels[0] || "风格化结果"}
</div>
</div>
</button>
))
) : (
<div className="col-span-2 rounded-lg border border-dashed border-gray-300 bg-white p-5 text-center text-gray-400 text-sm">
生成后会在这里保存历史。
</div>
)}
</div>
</div>
</div>
<div className="rounded-xl bg-gray-50 p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<span className="subsection-title">生图结果</span>
<p className="mt-1 text-gray-500 text-sm">
生成后拖动滑块查看原图与结果图。
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
className="rounded-lg bg-blue-600 text-white hover:bg-blue-700"
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-gray-200 bg-white text-gray-700 hover:bg-gray-100"
disabled={!currentResult}
onClick={() => currentResult && onAddToCanvas?.(currentResult)}
variant="outline"
>
添加到画布
</Button>
</div>
</div>
<BeforeAfterSlider
afterUrl={currentResult?.resultImageUrl}
beforeUrl={currentResult?.sourceImageUrl || sourceImageUrl}
generating={generating}
/>
</div>
</div>
</section>
{configLoading ? (
<section className="section-card flex min-h-[180px] items-center justify-center p-6 text-gray-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
正在加载配置
</section>
) : !config?.enabled ? (
<section className="section-card border-amber-200 bg-amber-50 p-6 text-amber-800">
风格化 PRO 暂未启用,请联系管理员开启。
</section>
) : (
enabledCategories.map((category) => {
const selectedIds = selections[category.id] || [];
return (
<section className="section-card p-6" key={category.id}>
<div className="rounded-xl bg-gray-50 p-4">
<div className="mb-3 flex items-start gap-4">
<span className="subsection-title mt-2 w-20">
{category.title}
</span>
<div className="min-w-0 flex-1">
<div className="mb-3 flex flex-wrap items-center gap-2">
{category.required ? (
<Badge className="border-blue-200 bg-blue-50 text-blue-800">
必选
</Badge>
) : (
<Badge className="border-gray-200 bg-white text-gray-500">
可选
</Badge>
)}
<span className="text-gray-400 text-xs">
已选 {selectedIds.length}/{category.maxSelections}
</span>
{category.description ? (
<span className="text-gray-400 text-xs">
{category.description}
</span>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{category.options.map((option) => {
const selected = selectedIds.includes(option.id);
return (
<button
className={cn(
"option-btn rounded-full px-4 py-2 font-medium text-sm",
selected && "active"
)}
key={option.id}
onClick={() => toggleOption(category, option.id)}
title={option.description || option.label}
type="button"
>
{option.label}
</button>
);
})}
</div>
</div>
</div>
<input
className="custom-input w-full rounded-lg px-4 py-2 text-sm"
disabled
placeholder={`自定义${category.title}描述...`}
type="text"
/>
</div>
</section>
);
})
)}
<section className="section-card p-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h3 className="flex items-center gap-2 font-bold text-gray-900">
<Sparkles className="h-5 w-5 text-blue-600" />
已选组合
</h3>
<p className="mt-1 text-gray-500 text-sm">
按钮提示词只在服务端参与生成,前台仅展示按钮名称。
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
className="rounded-lg border-gray-200 bg-white text-gray-700 hover:bg-gray-100"
onClick={() => setSelections({})}
variant="outline"
>
<RotateCcw className="mr-2 h-4 w-4" />
清空选择
</Button>
<Button
className="rounded-lg bg-blue-600 text-white hover:bg-blue-700"
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" />
)}
执行
</Button>
</div>
</div>
{selectedOptionLabels.length > 0 ? (
<div className="mt-4 flex flex-wrap gap-2">
{selectedOptionLabels.map((label) => (
<Badge
className="border-blue-200 bg-blue-50 text-blue-800"
key={label}
variant="outline"
>
{label}
</Badge>
))}
</div>
) : (
<div className="mt-4 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-5 text-center text-gray-400 text-sm">
还没有选择按钮。
</div>
)}
</section>
</div>
</main>
</div>
);
}