773 lines
27 KiB
TypeScript
773 lines
27 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Clock3,
|
||
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 {
|
||
BrandTopBar,
|
||
BrandTopBarButton,
|
||
BrandTopBarIconButton,
|
||
} from "@/components/layout/brand-top-bar";
|
||
import { Button } from "@/components/ui/button";
|
||
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,
|
||
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-[#f4f4f4] text-[#171717]">
|
||
<style>{`
|
||
.style-pro-index-shell .section-card {
|
||
background: #fff;
|
||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||
border-radius: 24px;
|
||
box-shadow: 0 12px 35px rgba(15, 23, 42, 0.05);
|
||
}
|
||
.style-pro-index-shell .subsection-title {
|
||
color: #2563eb;
|
||
font-size: 13px;
|
||
font-weight: 650;
|
||
white-space: nowrap;
|
||
}
|
||
.style-pro-index-shell .option-btn {
|
||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||
background: #fff;
|
||
color: rgba(17, 24, 39, 0.78);
|
||
transition: all 0.18s ease;
|
||
}
|
||
.style-pro-index-shell .option-btn:hover {
|
||
background: #1c1c1e;
|
||
border-color: #1c1c1e;
|
||
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>
|
||
|
||
<main className="min-w-0 flex-1 overflow-y-auto">
|
||
<BrandTopBar
|
||
logo={logo}
|
||
onLogoClick={() => {
|
||
window.location.href = "/";
|
||
}}
|
||
position="sticky"
|
||
siteName={siteName}
|
||
>
|
||
<BrandTopBarButton onClick={quickFillRequired}>
|
||
<UserRound className="h-3.5 w-3.5" />
|
||
执行者
|
||
</BrandTopBarButton>
|
||
<BrandTopBarButton
|
||
disabled={generating || Boolean(disabledReason)}
|
||
onClick={handleGenerate}
|
||
variant="primary"
|
||
>
|
||
{generating ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<Sparkles className="h-3.5 w-3.5" />
|
||
)}
|
||
执行
|
||
</BrandTopBarButton>
|
||
<BrandTopBarButton
|
||
onClick={() => {
|
||
setSelections({});
|
||
setCurrentResult(null);
|
||
}}
|
||
>
|
||
<RotateCcw className="h-3.5 w-3.5" />
|
||
重置
|
||
</BrandTopBarButton>
|
||
<BrandTopBarIconButton
|
||
aria-label="个人中心"
|
||
onClick={() => toast.info("个人中心入口已保留,可在首页打开完整账户面板")}
|
||
title="个人中心"
|
||
>
|
||
<UserRound className="h-3.5 w-3.5" />
|
||
</BrandTopBarIconButton>
|
||
</BrandTopBar>
|
||
|
||
<div className="mx-auto max-w-[1920px] space-y-5 px-6 py-6">
|
||
<section className="section-card p-6">
|
||
<div className="rounded-[20px] bg-black/[0.025] p-4">
|
||
<div className="mb-3 flex items-center justify-between gap-3">
|
||
<span className="subsection-title">上传图片</span>
|
||
<span className="text-[12px] text-black/40">支持拖拽上传</span>
|
||
</div>
|
||
<button
|
||
className="flex min-h-[260px] w-full items-center justify-center overflow-hidden rounded-2xl border border-dashed border-black/15 bg-white transition hover:border-black/35"
|
||
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-[360px] w-full object-contain"
|
||
src={sourceImageUrl}
|
||
/>
|
||
) : (
|
||
<span className="flex flex-col items-center gap-3 text-gray-500">
|
||
{uploading ? (
|
||
<Loader2 className="h-6 w-6 animate-spin" />
|
||
) : (
|
||
<Upload className="h-6 w-6" />
|
||
)}
|
||
<span className="text-[13px]">点击上传,或拖入图片</span>
|
||
</span>
|
||
)}
|
||
</button>
|
||
<input
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={(event) => {
|
||
void handleFile(event.target.files?.[0]);
|
||
event.currentTarget.value = "";
|
||
}}
|
||
ref={fileInputRef}
|
||
type="file"
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
{configLoading ? (
|
||
<section className="section-card flex min-h-[180px] items-center justify-center p-6 text-[13px] text-black/45">
|
||
<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-[20px] bg-black/[0.025] 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="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 text-[14px] font-semibold",
|
||
selected && "active"
|
||
)}
|
||
key={option.id}
|
||
onClick={() => toggleOption(category, option.id)}
|
||
title={option.description || option.label}
|
||
type="button"
|
||
>
|
||
{option.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
})
|
||
)}
|
||
|
||
<section className="section-card p-6">
|
||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||
<div className="rounded-[20px] bg-black/[0.025] 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-[13px] text-black/45">
|
||
生成后拖动滑块查看原图与结果图。
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
className="h-8 rounded-[14px] bg-[#1c1c1e] px-4 text-[12px] text-white hover:bg-black"
|
||
disabled={generating || Boolean(disabledReason)}
|
||
onClick={handleGenerate}
|
||
>
|
||
{generating ? (
|
||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
||
)}
|
||
{generating ? "正在创建图片" : "执行"}
|
||
</Button>
|
||
<Button
|
||
className="h-8 rounded-[14px] border-black/10 bg-white px-4 text-[12px] text-black/70 shadow-sm hover:bg-black/5"
|
||
disabled={!currentResult}
|
||
onClick={() => currentResult && onAddToCanvas?.(currentResult)}
|
||
variant="outline"
|
||
>
|
||
添加到画布
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<BeforeAfterSlider
|
||
afterUrl={currentResult?.resultImageUrl}
|
||
beforeUrl={currentResult?.sourceImageUrl || sourceImageUrl}
|
||
generating={generating}
|
||
/>
|
||
</div>
|
||
|
||
<div className="rounded-[20px] bg-black/[0.025] p-4">
|
||
<div className="mb-3 flex items-center justify-between gap-3">
|
||
<span className="subsection-title">历史记录</span>
|
||
<span className="text-[12px] text-black/40">最近 4 条</span>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{history.length > 0 ? (
|
||
history.map((item) => (
|
||
<button
|
||
className={cn(
|
||
"overflow-hidden rounded-2xl border bg-white text-left transition hover:border-black/35",
|
||
currentResult?.id === item.id
|
||
? "border-black/30 shadow-[0_4px_12px_rgba(15,23,42,0.12)]"
|
||
: "border-black/10"
|
||
)}
|
||
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-[12px] text-black/70">
|
||
{item.optionLabels[0] || "风格化结果"}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
))
|
||
) : (
|
||
<div className="col-span-2 rounded-2xl border border-dashed border-black/15 bg-white p-5 text-center text-[13px] text-black/40">
|
||
生成后会在这里保存历史。
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|