ai-chatbot/components/style-pro/StyleProWorkspace.tsx
Your Name e8a3212b35
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: persist style pro history and model pricing
2026-05-16 21:28:00 +08:00

1075 lines
37 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 { Clock3, Loader2, Sparkles, Upload } from "lucide-react";
import {
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import useSWR from "swr";
import { toast } from "sonner";
import { BrandTopBar } 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 StyleProModelOption = {
id: string;
modelId: string;
displayName: string;
avatarUrl?: string | null;
providerName: string;
providerType: string;
isDefault: boolean;
unitCost: number;
creditDiscountLabel?: string | null;
};
type StyleProModelsResponse = {
success: boolean;
defaultModelId?: string | null;
models?: StyleProModelOption[];
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>;
};
const STYLE_PRO_HISTORY_LIMIT = 4;
function getHistoryId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `style-pro-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
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, STYLE_PRO_HISTORY_LIMIT)
: [];
}
function getLocalStyleProStateKey(canvasId: string) {
return `style-pro-state:${canvasId || "draft"}`;
}
function readLocalStyleProState(
canvasId: string
): StyleProPersistedState | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(getLocalStyleProStateKey(canvasId));
if (!raw) return null;
const parsed = JSON.parse(raw) as StyleProPersistedState;
return {
sourceImageUrl:
typeof parsed.sourceImageUrl === "string"
? parsed.sourceImageUrl
: undefined,
history: normalizeHistory(
Array.isArray(parsed.history)
? parsed.history.filter(
(item) =>
item &&
typeof item.sourceImageUrl === "string" &&
typeof item.resultImageUrl === "string"
)
: []
),
};
} catch {
return null;
}
}
function writeLocalStyleProState(
canvasId: string,
state: StyleProPersistedState
) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
getLocalStyleProStateKey(canvasId),
JSON.stringify({
sourceImageUrl: state.sourceImageUrl,
history: normalizeHistory(state.history),
})
);
} catch {
// Local persistence is a safety net; server persistence remains authoritative.
}
}
function mergeStyleProHistory(
...historyGroups: Array<StyleProHistoryItem[] | undefined>
) {
const byKey = new Map<string, StyleProHistoryItem>();
for (const history of historyGroups) {
for (const item of history || []) {
if (!item?.sourceImageUrl || !item?.resultImageUrl) continue;
const key = item.id || `${item.sourceImageUrl}::${item.resultImageUrl}`;
if (!byKey.has(key)) {
byKey.set(key, item);
}
}
}
return Array.from(byKey.values())
.sort((left, right) => {
const leftTime = Date.parse(left.createdAt || "") || 0;
const rightTime = Date.parse(right.createdAt || "") || 0;
return rightTime - leftTime;
})
.slice(0, STYLE_PRO_HISTORY_LIMIT);
}
function mergeStyleProState(
...states: Array<StyleProPersistedState | null | undefined>
): StyleProPersistedState {
return {
sourceImageUrl:
states.find((state) => String(state?.sourceImageUrl || "").trim())
?.sourceImageUrl || undefined,
history: mergeStyleProHistory(...states.map((state) => state?.history)),
};
}
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-[28px] p-[2px]">
<div className="style-pro-generating-surface relative h-full overflow-hidden rounded-[26px] bg-[#f4f4f4]">
<div className="style-pro-dot-field absolute inset-0" />
<div className="style-pro-light-field absolute inset-0" />
</div>
</div>
<style>{`
@keyframes styleProBorderSpin {
to { transform: rotate(1turn); }
}
@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; }
}
.style-pro-generating-frame {
background: rgba(255,255,255,0.82);
box-shadow: 0 18px 50px rgba(0,0,0,0.08);
isolation: isolate;
}
.style-pro-generating-frame::before {
position: absolute;
content: "";
inset: -42%;
z-index: -1;
background:
conic-gradient(
from 0deg,
rgba(22, 163, 74, 0.18),
rgba(37, 99, 235, 0.46),
rgba(245, 158, 11, 0.32),
rgba(20, 184, 166, 0.42),
rgba(22, 163, 74, 0.18)
);
animation: styleProBorderSpin 5.2s linear infinite;
}
.style-pro-generating-frame::after {
position: absolute;
content: "";
inset: 2px;
border-radius: 26px;
pointer-events: none;
box-shadow:
inset 0 0 0 1px rgba(255,255,255,0.72),
inset 0 0 28px rgba(255,255,255,0.55);
}
.style-pro-generating-surface {
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.025);
}
.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(ellipse 52% 38% at 28% 28%, rgba(255,255,255,0.88) 0%, rgba(255,255,255,0.52) 38%, transparent 72%),
radial-gradient(ellipse 56% 42% at 72% 70%, rgba(255,255,255,0.78) 0%, rgba(255,255,255,0.42) 36%, transparent 74%),
radial-gradient(ellipse 90% 70% at 50% 50%, rgba(255,255,255,0.28) 0%, transparent 68%);
filter: blur(14px);
inset: -14%;
mix-blend-mode: screen;
animation: styleProLightFloat 4.4s ease-in-out infinite alternate;
will-change: transform, opacity;
}
`}</style>
</div>
);
}
function BeforeAfterSlider({
afterUrl,
beforeUrl,
generating = false,
}: {
afterUrl?: string | null;
beforeUrl?: string | null;
generating?: boolean;
}) {
const [position, setPosition] = useState(50);
const sliderRef = useRef<HTMLDivElement | null>(null);
const updatePositionFromClientX = useCallback((clientX: number) => {
const rect = sliderRef.current?.getBoundingClientRect();
if (!rect || rect.width <= 0) return;
const next = ((clientX - rect.left) / rect.width) * 100;
setPosition(Math.min(96, Math.max(4, Math.round(next * 10) / 10)));
}, []);
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.currentTarget.setPointerCapture(event.pointerId);
updatePositionFromClientX(event.clientX);
},
[updatePositionFromClientX]
);
const handlePointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
updatePositionFromClientX(event.clientX);
},
[updatePositionFromClientX]
);
const handlePointerEnd = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
},
[]
);
const handleSliderKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLDivElement>) => {
const step = event.shiftKey ? 10 : 3;
if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
event.preventDefault();
setPosition((current) => Math.max(4, current - step));
} else if (event.key === "ArrowRight" || event.key === "ArrowUp") {
event.preventDefault();
setPosition((current) => Math.min(96, current + step));
} else if (event.key === "Home") {
event.preventDefault();
setPosition(4);
} else if (event.key === "End") {
event.preventDefault();
setPosition(96);
}
},
[]
);
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] select-none overflow-hidden rounded-lg border border-gray-200 bg-gray-50"
ref={sliderRef}
>
<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="absolute inset-y-0 w-px bg-white"
style={{ left: `${position}%` }}
>
<div
aria-label="拖动查看原图和结果对比"
aria-valuemax={96}
aria-valuemin={4}
aria-valuenow={Math.round(position)}
className="-translate-x-1/2 absolute top-1/2 flex h-11 w-11 -translate-y-1/2 cursor-ew-resize touch-none select-none items-center justify-center rounded-full border border-white/80 bg-black/75 text-[11px] font-semibold text-white shadow-lg outline-none transition hover:bg-black focus-visible:ring-2 focus-visible:ring-black/40 focus-visible:ring-offset-2"
onKeyDown={handleSliderKeyDown}
onPointerCancel={handlePointerEnd}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
role="slider"
tabIndex={0}
>
对比
</div>
</div>
<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 { data: modelsData, isLoading: modelsLoading } =
useSWR<StyleProModelsResponse>("/api/style-pro/model-options", fetcher);
const config = configData?.config;
const modelOptions = useMemo(
() => (modelsData?.models || []).filter((model) => Boolean(model.id)),
[modelsData?.models]
);
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(
() =>
mergeStyleProState(
persistedState,
readLocalStyleProState(canvasId),
initialSourceImageUrl
? { sourceImageUrl: initialSourceImageUrl, history: [] }
: null
).sourceImageUrl || ""
);
const [currentResult, setCurrentResult] =
useState<StyleProHistoryItem | null>(() => {
const merged = mergeStyleProState(
persistedState,
readLocalStyleProState(canvasId)
);
return merged.history[0] || null;
});
const [history, setHistory] = useState<StyleProHistoryItem[]>(
() =>
mergeStyleProState(persistedState, readLocalStyleProState(canvasId))
.history
);
const [selections, setSelections] = useState<StyleProSelectionMap>({});
const [selectedModelId, setSelectedModelId] = useState("");
const [uploading, setUploading] = useState(false);
const [generating, setGenerating] = useState(false);
useEffect(() => {
if (modelOptions.length === 0) {
setSelectedModelId("");
return;
}
setSelectedModelId((current) => {
if (current && modelOptions.some((model) => model.id === current)) {
return current;
}
return modelsData?.defaultModelId || modelOptions[0]?.id || "";
});
}, [modelOptions, modelsData?.defaultModelId]);
useEffect(() => {
const localState = readLocalStyleProState(canvasId);
const nextSource =
persistedState?.sourceImageUrl ||
localState?.sourceImageUrl ||
initialSourceImageUrl ||
"";
setSourceImageUrl((current) => current || nextSource);
setHistory((currentHistory) =>
mergeStyleProHistory(
currentHistory,
persistedState?.history,
localState?.history
)
);
setCurrentResult(
(current) =>
current ||
mergeStyleProHistory(persistedState?.history, localState?.history)[0] ||
null
);
}, [
canvasId,
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 selectedModel = useMemo(
() =>
modelOptions.find((model) => model.id === selectedModelId) ||
modelOptions[0] ||
null,
[modelOptions, selectedModelId]
);
const selectedModelCostLabel = selectedModel
? selectedModel.unitCost > 0
? `${selectedModel.unitCost} 积分/张`
: "未设置积分"
: modelsLoading
? "加载价格"
: "暂无模型";
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) => {
const normalizedState = {
sourceImageUrl: nextState.sourceImageUrl,
history: normalizeHistory(nextState.history),
};
writeLocalStyleProState(canvasId, normalizedState);
await onPersistState?.(normalizedState);
},
[canvasId, 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 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,
modelId: selectedModel?.id || selectedModelId || undefined,
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,
selectedModel?.id,
selectedModelId,
selections,
sourceImageUrl,
]);
const restoreHistoryItem = useCallback((item: StyleProHistoryItem) => {
setSourceImageUrl(item.sourceImageUrl);
setSelections(item.selections || {});
setCurrentResult(item);
}, []);
const disabledReason = !sourceImageUrl
? "请先上传图片"
: modelsData?.success === true && modelOptions.length === 0
? "暂无可用生图模型"
: missingRequiredCategories.length > 0
? `还差:${missingRequiredCategories[0].title}`
: "";
const promptDisplayMode = config?.promptDisplayMode || "stacked";
const renderPromptCategory = (
category: PublicStyleProCategory,
layout: "stacked" | "carousel" = "stacked"
) => {
const selectedIds = selections[category.id] || [];
return (
<div
className={cn(
"rounded-[20px] bg-black/[0.025] p-4",
layout === "carousel" && "h-full min-h-[126px]"
)}
>
<div className="mb-3 flex items-start gap-4">
<span className="subsection-title mt-2 w-20 shrink-0">
{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>
);
};
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-pro-index-shell .prompt-carousel-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(15, 23, 42, 0.18) transparent;
scroll-padding-inline: 8px;
}
.style-pro-index-shell .prompt-carousel-scroll::-webkit-scrollbar {
height: 8px;
}
.style-pro-index-shell .prompt-carousel-scroll::-webkit-scrollbar-track {
background: transparent;
}
.style-pro-index-shell .prompt-carousel-scroll::-webkit-scrollbar-thumb {
background: rgba(15, 23, 42, 0.16);
border-radius: 999px;
}
`}</style>
<main className="min-w-0 flex-1 overflow-y-auto">
<BrandTopBar
logo={logo}
onLogoClick={() => {
window.location.href = "/";
}}
position="sticky"
siteName={siteName}
/>
<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>
) : promptDisplayMode === "carousel" ? (
<section className="section-card p-6">
<div className="prompt-carousel-scroll -mx-2 flex snap-x gap-4 overflow-x-auto px-2 pb-2">
{enabledCategories.map((category) => (
<div
className="w-[min(760px,calc(100vw-96px))] shrink-0 snap-start"
key={category.id}
>
{renderPromptCategory(category, "carousel")}
</div>
))}
</div>
</section>
) : (
enabledCategories.map((category) => (
<section className="section-card p-6" key={category.id}>
{renderPromptCategory(category)}
</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 items-center gap-2">
<div className="flex h-8 items-center gap-2 rounded-[14px] border border-black/10 bg-white px-2 text-[12px] text-black/65 shadow-sm">
<span className="text-black/45">模型</span>
<select
aria-label="选择生图模型"
className="h-6 max-w-[210px] bg-transparent font-semibold text-black/75 outline-none"
disabled={
modelsLoading ||
modelOptions.length === 0 ||
generating
}
onChange={(event) =>
setSelectedModelId(event.target.value)
}
value={selectedModel?.id || selectedModelId}
>
{modelOptions.length > 0 ? (
modelOptions.map((model) => (
<option key={model.id} value={model.id}>
{model.displayName} ·{" "}
{model.unitCost > 0
? `${model.unitCost}积分/张`
: "未设置积分"}
</option>
))
) : (
<option value="">
{modelsLoading ? "加载模型" : "暂无模型"}
</option>
)}
</select>
<span className="rounded-full bg-black/[0.04] px-2 py-0.5 font-semibold text-black/55">
{selectedModelCostLabel}
</span>
{selectedModel?.creditDiscountLabel ? (
<span className="rounded-full bg-emerald-50 px-2 py-0.5 font-semibold text-emerald-600">
{selectedModel.creditDiscountLabel}
</span>
) : null}
</div>
<Button
className="h-8 rounded-[14px] bg-[#1c1c1e] px-4 text-[12px] text-white hover:bg-black"
disabled={generating || Boolean(disabledReason)}
onClick={handleGenerate}
title={disabledReason || selectedModelCostLabel}
>
{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
? "正在创建图片"
: selectedModel?.unitCost
? `执行 · ${selectedModel.unitCost}积分`
: "执行"}
</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>
);
}