ai-chatbot/components/style-pro/StyleProWorkspace.tsx

2008 lines
71 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, Download, Loader2, LogOut, Sparkles, Upload } from "lucide-react";
import { signOut, useSession } from "next-auth/react";
import {
type CSSProperties,
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, BrandTopBarButton } from "@/components/layout/brand-top-bar";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { imageGenerationService } from "@/services/canvas";
import { downloadCanvasImageAsPng } from "@/lib/client/canvas-image-download";
import { cn, fetcher } from "@/lib/utils";
import type {
PublicStyleProCategory,
PublicStyleProCustomField,
PublicStyleProConfig,
StyleProCustomInputMap,
StyleProGenerationMode,
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;
availabilityStatus?: "available" | "maintenance" | "testing";
availabilityLabel?: string | null;
selectable?: boolean;
disabledReason?: string | null;
};
type StyleProModelsResponse = {
success: boolean;
defaultModelId?: string | null;
realisticUnitCost?: number | null;
models?: StyleProModelOption[];
error?: string;
};
type StyleProMembershipSummaryResponse = {
currentPlan?: {
name?: string;
monthlyCredits?: number;
dailyFreeCredits?: number;
};
summary?: {
currentCredits?: number;
dailyFreeCredits?: number;
};
};
type StyleProAccountCenterResponse = {
profile?: {
name?: string;
email?: string;
currentPlanName?: string;
availablePoints?: number;
monthlyCredits?: number;
dailyFreeCredits?: number;
};
};
type StyleProGenerateResponse = {
success: boolean;
error?: string;
taskId?: string;
status?: string;
queueStatus?: {
status?: "pending" | "running" | "succeeded" | "failed";
queuePosition?: number;
sameModelPendingCount?: number;
sameModelRunningCount?: number;
concurrencyLimit?: number;
estimatedWaitMs?: number;
source?: "model_queue" | "batch_queue";
};
task?: {
id: string;
status: "pending" | "running" | "succeeded" | "failed";
attempts: number;
error?: string | null;
createdAt?: string | Date | null;
startedAt?: string | Date | null;
completedAt?: string | Date | null;
queueStatus?: StyleProGenerateResponse["queueStatus"];
result?: {
imageUrl: string;
thumbnailUrl?: string;
model?: string;
modelRef?: string;
gallerySavedImageCount?: number;
gallerySaveError?: string;
generationMode?: StyleProGenerationMode;
chargedPoints?: number;
selections: StyleProSelectionMap;
customInputs?: StyleProCustomInputMap;
optionLabels: string[];
} | null;
};
};
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;
const STYLE_PRO_TASK_POLL_INTERVAL_MS = 1600;
const STYLE_PRO_TASK_TIMEOUT_MS = 10 * 60 * 1000;
type StyleProTaskState = {
taskId: string;
status: "pending" | "running" | "succeeded" | "failed";
queueStatus?: StyleProGenerateResponse["queueStatus"];
error?: string | null;
generationMode: StyleProGenerationMode;
};
function getHistoryId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `style-pro-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function createStyleProRequestId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return undefined;
}
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 sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
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 cloneCustomInputs(
inputs?: StyleProCustomInputMap | null
): StyleProCustomInputMap {
return {
global: { ...(inputs?.global || {}) },
categories: Object.fromEntries(
Object.entries(inputs?.categories || {}).map(([categoryId, values]) => [
categoryId,
{ ...(values || {}) },
])
),
};
}
function buildDefaultCustomInputs(
config?: PublicStyleProConfig | null,
current?: StyleProCustomInputMap | null
): StyleProCustomInputMap {
const next = cloneCustomInputs(current);
for (const field of config?.customFields || []) {
if (next.global?.[field.id] === undefined && field.defaultValue) {
next.global![field.id] = field.defaultValue;
}
}
for (const category of config?.categories || []) {
for (const field of category.customFields || []) {
if (!field.defaultValue) continue;
next.categories![category.id] = next.categories![category.id] || {};
if (next.categories![category.id][field.id] === undefined) {
next.categories![category.id][field.id] = field.defaultValue;
}
}
}
return next;
}
function hasCustomFields(config?: PublicStyleProConfig | null) {
return Boolean(
(config?.customFields || []).length ||
(config?.categories || []).some((category) => category.customFields?.length)
);
}
function getCustomFieldColorValue(value?: string | null) {
const trimmed = String(value || "").trim();
const shortHex = trimmed.match(/^#([0-9a-fA-F]{3})$/);
if (/^#[0-9a-fA-F]{6}$/.test(trimmed)) {
return trimmed;
}
if (shortHex) {
return `#${shortHex[1]
.split("")
.map((char) => `${char}${char}`)
.join("")}`;
}
return "#ffffff";
}
function StyleProCustomFieldInput({
field,
onChange,
value,
}: {
field: PublicStyleProCustomField;
onChange: (value: string) => void;
value?: string;
}) {
const inputValue = value || "";
const label = (
<label
className="mb-1.5 block text-[12px] font-semibold text-black/62"
htmlFor={`style-pro-custom-${field.id}`}
>
{field.label}
{field.required ? <span className="ml-0.5 text-red-500">*</span> : null}
</label>
);
if (field.type === "color") {
return (
<div className="min-w-[230px] flex-1">
{label}
<div className="flex h-10 items-center gap-2 rounded-[14px] border border-black/10 bg-white px-2 shadow-sm">
<input
aria-label={`${field.label}颜色`}
className="h-7 w-8 shrink-0 cursor-pointer rounded-md border-0 bg-transparent p-0"
onChange={(event) => onChange(event.target.value)}
type="color"
value={getCustomFieldColorValue(inputValue || field.defaultValue)}
/>
<input
className="min-w-0 flex-1 bg-transparent font-mono text-[13px] font-semibold text-black/72 outline-none"
id={`style-pro-custom-${field.id}`}
maxLength={7}
onChange={(event) => onChange(event.target.value)}
placeholder={field.placeholder || "#FFFFFF"}
value={inputValue}
/>
</div>
{field.description ? (
<div className="mt-1 text-[11px] text-black/40">{field.description}</div>
) : null}
</div>
);
}
if (field.type === "textarea") {
return (
<div className="min-w-[260px] flex-1 md:col-span-2">
{label}
<textarea
className="min-h-[76px] w-full resize-y rounded-[14px] border border-black/10 bg-white px-3 py-2 text-[13px] text-black/72 shadow-sm outline-none transition placeholder:text-black/35 focus:border-black/25"
id={`style-pro-custom-${field.id}`}
maxLength={field.maxLength || 800}
onChange={(event) => onChange(event.target.value)}
placeholder={field.placeholder || "请输入自定义要求..."}
value={inputValue}
/>
{field.description ? (
<div className="mt-1 text-[11px] text-black/40">{field.description}</div>
) : null}
</div>
);
}
return (
<div className="min-w-[230px] flex-1">
{label}
<input
className="h-10 w-full rounded-[14px] border border-black/10 bg-white px-3 text-[13px] text-black/72 shadow-sm outline-none transition placeholder:text-black/35 focus:border-black/25"
id={`style-pro-custom-${field.id}`}
maxLength={field.maxLength || 160}
onChange={(event) => onChange(event.target.value)}
placeholder={field.placeholder || "请输入自定义要求..."}
value={inputValue}
/>
{field.description ? (
<div className="mt-1 text-[11px] text-black/40">{field.description}</div>
) : null}
</div>
);
}
function getStyleProUserInitials(name?: string | null, email?: string | null) {
const source = String(name || email || "mu").trim();
return (source || "mu").slice(0, 2).toLowerCase();
}
function formatStyleProCredits(value?: number | null) {
const numeric = Number(value ?? 0);
if (!Number.isFinite(numeric)) return "0";
return Math.max(0, Math.round(numeric)).toLocaleString("zh-CN");
}
function formatStyleProHistoryDateTime(value?: string | null) {
const date = new Date(value || "");
if (Number.isNaN(date.getTime())) return "时间未知";
const dateText = date
.toLocaleDateString("zh-CN", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})
.replace(/\//g, "-");
const timeText = date.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
});
return `${dateText} ${timeText}`;
}
function buildStyleProDownloadName(item: StyleProHistoryItem) {
const mode =
item.generationMode === "realistic" ? "style-pro-realistic" : "style-pro";
const createdAt = item.createdAt
? item.createdAt.replace(/[T:.]/g, "-").slice(0, 19)
: String(Date.now());
return `${mode}-${createdAt}`;
}
function formatStyleProEstimatedWait(estimatedWaitMs?: number | null) {
const numeric = Number(estimatedWaitMs || 0);
if (!Number.isFinite(numeric) || numeric <= 0) {
return "预计即将开始";
}
const seconds = Math.max(1, Math.round(numeric / 1000));
if (seconds < 60) {
return `预计 ${seconds} 秒`;
}
const minutes = Math.floor(seconds / 60);
const remainSeconds = seconds % 60;
return remainSeconds > 0
? `预计 ${minutes} 分 ${remainSeconds} 秒`
: `预计 ${minutes} 分钟`;
}
function StyleProAccountActions() {
const { data: session, status } = useSession();
const [accountCenterOpen, setAccountCenterOpen] = useState(false);
const isAuthenticated = status === "authenticated" && Boolean(session?.user);
const { data: membershipData } = useSWR<StyleProMembershipSummaryResponse>(
isAuthenticated ? "/api/user/membership-summary" : null,
fetcher
);
const { data: accountCenterData, isLoading: accountCenterLoading } =
useSWR<StyleProAccountCenterResponse>(
isAuthenticated && accountCenterOpen ? "/api/user/account-center" : null,
fetcher
);
const accountProfile = useMemo(() => {
const profile = accountCenterData?.profile;
return {
name: profile?.name || session?.user?.name || "用户",
email: profile?.email || session?.user?.email || "",
planName:
profile?.currentPlanName || membershipData?.currentPlan?.name || "Free",
availablePoints:
profile?.availablePoints ??
membershipData?.summary?.currentCredits ??
0,
monthlyCredits:
profile?.monthlyCredits ??
membershipData?.currentPlan?.monthlyCredits ??
0,
dailyFreeCredits:
profile?.dailyFreeCredits ??
membershipData?.summary?.dailyFreeCredits ??
membershipData?.currentPlan?.dailyFreeCredits ??
0,
};
}, [
accountCenterData?.profile,
membershipData?.currentPlan?.dailyFreeCredits,
membershipData?.currentPlan?.monthlyCredits,
membershipData?.currentPlan?.name,
membershipData?.summary?.currentCredits,
membershipData?.summary?.dailyFreeCredits,
session?.user?.email,
session?.user?.name,
]);
const userInitials = getStyleProUserInitials(
session?.user?.name,
session?.user?.email
);
const openLogin = useCallback(() => {
if (typeof window === "undefined") {
return;
}
const callbackUrl = `${window.location.pathname}${window.location.search}`;
window.location.href = `/login?callbackUrl=${encodeURIComponent(callbackUrl)}`;
}, []);
const handleLogout = useCallback(async () => {
try {
await fetch("/api/auth/logout", { method: "POST" });
await signOut({ callbackUrl: "/login", redirect: false });
} finally {
window.location.replace("/login");
}
}, []);
if (status === "loading") {
return (
<div className="h-8 w-[96px] animate-pulse rounded-[14px] bg-black/8 dark:bg-white/8" />
);
}
if (!isAuthenticated) {
return (
<button
className="inline-flex h-8 items-center justify-center rounded-full border border-black/15 bg-white px-3 text-xs font-medium text-black/80 transition hover:bg-black/5 dark:border-white/12 dark:bg-[#161618] dark:text-white/80 dark:hover:bg-white/8"
onClick={openLogin}
type="button"
>
登录
</button>
);
}
return (
<>
<button
aria-label="个人中心"
className="inline-flex h-8 items-center gap-2 rounded-[14px] border border-black/10 bg-white px-2.5 text-[12px] font-medium text-black/72 shadow-sm transition hover:bg-black/5 hover:text-black dark:border-white/10 dark:bg-white/6 dark:text-white/72 dark:hover:bg-white/10 dark:hover:text-white"
onClick={() => setAccountCenterOpen(true)}
type="button"
>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[#d4551f] text-[11px] font-semibold text-white">
{userInitials}
</span>
<span className="hidden sm:inline">个人中心</span>
</button>
<Dialog open={accountCenterOpen} onOpenChange={setAccountCenterOpen}>
<DialogContent className="w-[92vw] max-w-[520px] rounded-[22px] border-black/10 bg-[#f6f6f7]/95 p-0 shadow-2xl dark:border-white/10 dark:bg-[#101014]/95 dark:text-white">
<DialogHeader className="sr-only">
<DialogTitle>个人中心</DialogTitle>
</DialogHeader>
<div className="p-4">
<div className="rounded-[18px] border border-black/10 bg-white p-4 dark:border-white/10 dark:bg-[#17171b]">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-[#d4551f] text-[14px] font-semibold text-white">
{userInitials}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-[15px] font-semibold text-black dark:text-white">
{accountProfile.name}
</div>
<div className="truncate text-[12px] text-black/45 dark:text-white/45">
{accountProfile.email || "登录后查看账户邮箱"}
</div>
</div>
<Button
className="h-8 rounded-xl border-black/10 bg-white px-3 text-[12px] text-black/75 hover:bg-black/5 dark:border-white/12 dark:bg-[#111115] dark:text-white/75 dark:hover:bg-white/6"
onClick={() => {
void handleLogout();
}}
variant="outline"
>
<LogOut className="mr-1.5 h-3.5 w-3.5" />
退出
</Button>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<div className="rounded-[16px] border border-black/10 bg-white p-3 dark:border-white/10 dark:bg-[#17171b]">
<div className="text-[12px] text-black/45 dark:text-white/45">
积分余额
</div>
<div className="mt-1 text-[20px] font-semibold text-black dark:text-white">
{accountCenterLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
formatStyleProCredits(accountProfile.availablePoints)
)}
</div>
</div>
<div className="rounded-[16px] border border-black/10 bg-white p-3 dark:border-white/10 dark:bg-[#17171b]">
<div className="text-[12px] text-black/45 dark:text-white/45">
当前套餐
</div>
<div className="mt-1 truncate text-[20px] font-semibold text-black dark:text-white">
{accountProfile.planName}
</div>
</div>
<div className="rounded-[16px] border border-black/10 bg-white p-3 dark:border-white/10 dark:bg-[#17171b]">
<div className="text-[12px] text-black/45 dark:text-white/45">
每月更新
</div>
<div className="mt-1 text-[20px] font-semibold text-black dark:text-white">
{formatStyleProCredits(accountProfile.monthlyCredits)}
</div>
</div>
<div className="rounded-[16px] border border-black/10 bg-white p-3 dark:border-white/10 dark:bg-[#17171b]">
<div className="text-[12px] text-black/45 dark:text-white/45">
每日赠送
</div>
<div className="mt-1 text-[20px] font-semibold text-black dark:text-white">
{formatStyleProCredits(accountProfile.dailyFreeCredits)}
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
function ImagePreview({
alt,
className,
fit = "contain",
imageUrl,
}: {
alt: string;
className?: string;
fit?: "contain" | "cover";
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(
"block h-full max-h-none w-full max-w-none rounded-lg",
fit === "cover" ? "object-cover" : "object-contain",
className
)}
draggable={false}
src={imageUrl}
/>
);
}
function GeneratingImagePlaceholder({
taskState,
}: {
taskState?: StyleProTaskState | null;
}) {
const queueStatus = taskState?.queueStatus;
const queuePosition = Math.max(0, Number(queueStatus?.queuePosition || 0));
const sameModelRunningCount = Math.max(
0,
Number(queueStatus?.sameModelRunningCount || 0)
);
const concurrencyLimit = Math.max(
0,
Number(queueStatus?.concurrencyLimit || 0)
);
const isQueued = taskState?.status === "pending";
const isFailed = taskState?.status === "failed";
const title = isFailed
? "本次生成失败"
: isQueued
? `排队中,前方 ${queuePosition} 个`
: taskState?.generationMode === "realistic"
? "正在生成实景化结果"
: "正在生成图片";
const description = isFailed
? taskState?.error || "请调整参数后重试。"
: isQueued
? `当前模型运行 ${sameModelRunningCount}/${concurrencyLimit || "?"},${formatStyleProEstimatedWait(
queueStatus?.estimatedWaitMs
)}`
: taskState?.generationMode === "realistic"
? "系统会先完成基础实景化,再合成最终风格结果。"
: "模型已开始执行,结果完成后会自动回填。";
return (
<div className="min-h-[620px] rounded-lg border border-white/10 bg-white p-5 text-neutral-800 lg:min-h-[720px]">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<div className="font-semibold text-[15px] text-neutral-700">
{title}
</div>
<div className="mt-1 text-xs text-neutral-400">
{description}
</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">
{!isFailed ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
{isFailed ? "失败" : isQueued ? "排队中" : "生成中"}
</span>
</div>
<div className="style-pro-generating-frame relative mx-auto h-[520px] max-h-[62vh] w-full max-w-[430px] 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,
downloadDisabled,
downloading,
onDownload,
taskState,
}: {
afterUrl?: string | null;
beforeUrl?: string | null;
downloadDisabled?: boolean;
downloading?: boolean;
onDownload?: () => void;
taskState?: StyleProTaskState | null;
}) {
const showTaskState =
taskState?.status === "pending" || taskState?.status === "running";
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(100, Math.max(0, 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(0, current - step));
} else if (event.key === "ArrowRight" || event.key === "ArrowUp") {
event.preventDefault();
setPosition((current) => Math.min(100, current + step));
} else if (event.key === "Home") {
event.preventDefault();
setPosition(0);
} else if (event.key === "End") {
event.preventDefault();
setPosition(100);
}
},
[]
);
if (showTaskState || taskState?.status === "failed") {
return <GeneratingImagePlaceholder taskState={taskState} />;
}
if (!beforeUrl && !afterUrl) {
return (
<div className="flex h-full min-h-[620px] items-center justify-center rounded-lg border border-dashed border-gray-200 bg-gray-50 text-gray-400 lg:min-h-[720px]">
上传图片后开始生成
</div>
);
}
if (!afterUrl) {
return (
<div className="h-full min-h-[620px] overflow-hidden rounded-lg border border-gray-200 bg-gray-50 lg:min-h-[720px]">
<ImagePreview alt="上传图片" fit="cover" imageUrl={beforeUrl} />
</div>
);
}
return (
<div
className="relative h-full min-h-[620px] select-none overflow-hidden rounded-lg border border-gray-200 bg-gray-50 lg:min-h-[720px]"
ref={sliderRef}
>
<ImagePreview
alt="生成后"
className="absolute inset-0 h-full w-full"
fit="cover"
imageUrl={afterUrl}
/>
<div
className="absolute inset-0 overflow-hidden"
style={{ clipPath: `inset(0 ${100 - position}% 0 0)` }}
>
<ImagePreview
alt="生成前"
className="h-full w-full"
fit="cover"
imageUrl={beforeUrl}
/>
</div>
<div
className="pointer-events-none absolute inset-y-0 w-px bg-white"
style={{ left: `${position}%` }}
/>
<div
aria-label="拖动查看原图和结果对比"
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={Math.round(position)}
className="-translate-x-1/2 absolute top-1/2 z-10 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"
style={{
left: `clamp(22px, ${position}%, calc(100% - 22px))`,
}}
tabIndex={0}
>
对比
</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>
{afterUrl ? (
<button
className="absolute bottom-4 right-4 z-20 inline-flex h-11 items-center gap-2 rounded-full border border-white/25 bg-black/72 px-4 text-[13px] font-semibold text-white shadow-[0_14px_32px_rgba(0,0,0,0.24)] backdrop-blur transition hover:bg-black disabled:cursor-not-allowed disabled:opacity-55"
disabled={downloadDisabled || downloading}
onClick={(event) => {
event.stopPropagation();
onDownload?.();
}}
type="button"
>
{downloading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
下载
</button>
) : null}
</div>
);
}
export function StyleProWorkspace({
canvasId,
initialSourceImageUrl,
onAddToCanvas,
onBack,
onPersistState,
persistedState,
}: StyleProWorkspaceProps) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const activeGenerateRequestRef = useRef<string | 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 selectableModelOptions = useMemo(
() => modelOptions.filter((model) => model.selectable !== false),
[modelOptions]
);
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 [customInputs, setCustomInputs] = useState<StyleProCustomInputMap>({
global: {},
categories: {},
});
const [generationMode, setGenerationMode] =
useState<StyleProGenerationMode>("style");
const [selectedModelId, setSelectedModelId] = useState("");
const [uploading, setUploading] = useState(false);
const [downloadingImageId, setDownloadingImageId] = useState<string | null>(
null
);
const [generationTaskState, setGenerationTaskState] =
useState<StyleProTaskState | null>(null);
const generating =
generationTaskState?.status === "pending" ||
generationTaskState?.status === "running";
useEffect(() => {
if (selectableModelOptions.length === 0) {
setSelectedModelId("");
return;
}
setSelectedModelId((current) => {
if (
current &&
selectableModelOptions.some((model) => model.id === current)
) {
return current;
}
const defaultModel = selectableModelOptions.find(
(model) => model.id === modelsData?.defaultModelId
);
return defaultModel?.id || selectableModelOptions[0]?.id || "";
});
}, [modelsData?.defaultModelId, selectableModelOptions]);
useEffect(() => {
setCustomInputs((current) => buildDefaultCustomInputs(config, current));
}, [config]);
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(
() =>
selectableModelOptions.find((model) => model.id === selectedModelId) ||
selectableModelOptions[0] ||
null,
[selectableModelOptions, selectedModelId]
);
const selectedModelCostLabel = selectedModel
? selectedModel.unitCost > 0
? `${selectedModel.unitCost} 积分/张`
: "未设置积分"
: modelsLoading
? "加载价格"
: "暂无模型";
const realisticUnitCost = Math.max(0, Number(modelsData?.realisticUnitCost || 0));
const currentCostLabel =
generationMode === "realistic"
? realisticUnitCost > 0
? `${realisticUnitCost} 积分/张`
: "后台模型未计费"
: selectedModelCostLabel;
const currentUnitCost =
generationMode === "realistic"
? realisticUnitCost
: selectedModel?.unitCost || 0;
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 missingRequiredCustomField = useMemo(() => {
for (const field of config?.customFields || []) {
if (!field.required) continue;
const value = String(customInputs.global?.[field.id] || "").trim();
if (!value) return field.label;
}
for (const category of enabledCategories) {
for (const field of category.customFields || []) {
if (!field.required) continue;
const value = String(
customInputs.categories?.[category.id]?.[field.id] || ""
).trim();
if (!value) return `${category.title} / ${field.label}`;
}
}
return "";
}, [config?.customFields, customInputs, enabledCategories]);
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);
setGenerationTaskState(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 updateCustomInput = useCallback(
({
categoryId,
fieldId,
scope,
value,
}: {
categoryId?: string;
fieldId: string;
scope: "global" | "category";
value: string;
}) => {
setCustomInputs((current) => {
const next = cloneCustomInputs(current);
if (scope === "global") {
next.global = {
...(next.global || {}),
[fieldId]: value,
};
return next;
}
if (!categoryId) return next;
next.categories = {
...(next.categories || {}),
[categoryId]: {
...(next.categories?.[categoryId] || {}),
[fieldId]: value,
},
};
return next;
});
},
[]
);
const handleGenerate = useCallback(async () => {
if (activeGenerateRequestRef.current || generating) {
toast.info("当前图片正在生成中,请稍候");
return;
}
if (!sourceImageUrl) {
toast.error("请先上传一张图片");
return;
}
if (!config?.enabled) {
toast.error("风格化 PRO 暂未启用");
return;
}
if (missingRequiredCategories.length > 0) {
toast.error(`请完成必选项:${missingRequiredCategories[0].title}`);
return;
}
if (missingRequiredCustomField) {
toast.error(`请填写:${missingRequiredCustomField}`);
return;
}
const requestId = createStyleProRequestId();
const localRequestKey = requestId || `local-${Date.now()}`;
activeGenerateRequestRef.current = localRequestKey;
setGenerationTaskState({
taskId: localRequestKey,
status: "pending",
error: null,
generationMode,
});
try {
const response = await fetch("/api/style-pro/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
canvasId,
...(requestId ? { requestId } : {}),
generationMode,
modelId:
generationMode === "style"
? selectedModel?.id || selectedModelId || undefined
: undefined,
sourceImageUrl,
selections,
customInputs,
}),
});
const payload = (await response.json().catch(() => ({}))) as
| StyleProGenerateResponse
| undefined;
if (!response.ok || !payload?.success) {
throw new Error(payload?.error || "生成失败");
}
const taskId = String(payload.taskId || "").trim();
if (!taskId) {
throw new Error("生成任务创建失败");
}
setGenerationTaskState({
taskId,
status: payload.status === "running" ? "running" : "pending",
queueStatus: payload.queueStatus,
error: null,
generationMode,
});
const deadline = Date.now() + STYLE_PRO_TASK_TIMEOUT_MS;
let taskResult:
| NonNullable<NonNullable<StyleProGenerateResponse["task"]>["result"]>
| null = null;
let lastError = "";
while (Date.now() < deadline) {
await sleep(STYLE_PRO_TASK_POLL_INTERVAL_MS);
let statusResponse: Response;
try {
statusResponse = await fetch(
`/api/style-pro/generate?taskId=${encodeURIComponent(taskId)}`,
{ cache: "no-store" }
);
} catch (pollError) {
lastError =
pollError instanceof Error ? pollError.message : "查询任务失败";
continue;
}
const statusPayload = (await statusResponse.json().catch(() => ({}))) as
| StyleProGenerateResponse
| undefined;
if (!statusResponse.ok || !statusPayload?.success || !statusPayload.task) {
lastError =
statusPayload?.error || `查询任务失败(${statusResponse.status})`;
continue;
}
const task = statusPayload.task;
setGenerationTaskState({
taskId: task.id,
status: task.status,
queueStatus: task.queueStatus,
error: task.error || null,
generationMode,
});
if (task.status === "succeeded") {
taskResult = task.result || null;
break;
}
if (task.status === "failed") {
throw new Error(task.error || "生成失败");
}
}
if (!taskResult?.imageUrl) {
throw new Error(lastError || "生成超时,请稍后重试");
}
const dimensions = await loadImageDimensions(taskResult.imageUrl);
const item: StyleProHistoryItem = {
id: getHistoryId(),
sourceImageUrl,
resultImageUrl: taskResult.imageUrl,
selections: taskResult.selections || selections,
optionLabels: taskResult.optionLabels || selectedOptionLabels,
customInputs: taskResult.customInputs || customInputs,
createdAt: new Date().toISOString(),
generationMode: taskResult.generationMode || generationMode,
model: taskResult.model,
modelRef: taskResult.modelRef,
width: dimensions.width,
height: dimensions.height,
};
const nextHistory = [
item,
...history.filter((entry) => entry.id !== item.id),
].slice(0, 4);
setHistory(nextHistory);
setCurrentResult(item);
try {
await persistState({ sourceImageUrl, history: nextHistory });
} catch (persistError) {
console.error(
"[Style Pro] Failed to persist generated result:",
persistError
);
toast.warning("结果已生成,历史记录稍后同步");
}
setGenerationTaskState(null);
if (taskResult.gallerySaveError) {
toast.warning(
`结果已生成,但个人相册同步失败:${taskResult.gallerySaveError}`
);
} else {
toast.success("风格化 PRO 生成完成,已保存到个人相册");
}
} catch (error) {
setGenerationTaskState((current) =>
current
? {
...current,
status: "failed",
error: error instanceof Error ? error.message : "生成失败",
}
: null
);
toast.error(error instanceof Error ? error.message : "生成失败");
} finally {
activeGenerateRequestRef.current = null;
}
}, [
canvasId,
config?.enabled,
generationMode,
generating,
history,
customInputs,
missingRequiredCategories,
missingRequiredCustomField,
persistState,
selectedOptionLabels,
selectedModel?.id,
selectedModelId,
selections,
sourceImageUrl,
]);
const restoreHistoryItem = useCallback(
(item: StyleProHistoryItem) => {
setSourceImageUrl(item.sourceImageUrl);
setSelections(item.selections || {});
setCustomInputs(buildDefaultCustomInputs(config, item.customInputs));
setGenerationMode(item.generationMode || "style");
setCurrentResult(item);
setGenerationTaskState(null);
},
[config]
);
const handleDownloadResult = useCallback(
async (item?: StyleProHistoryItem | null) => {
const target = item || currentResult;
if (!target?.resultImageUrl) {
toast.error("暂无可下载的结果图");
return;
}
const downloadId = target.id || target.resultImageUrl;
setDownloadingImageId(downloadId);
try {
await downloadCanvasImageAsPng({
url: target.resultImageUrl,
name: buildStyleProDownloadName(target),
candidates: [target.resultImageUrl],
});
toast.success("图片已开始下载");
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载失败");
} finally {
setDownloadingImageId((current) =>
current === downloadId ? null : current
);
}
},
[currentResult]
);
const disabledReason = !sourceImageUrl
? "请先上传图片"
: generationMode === "realistic" && config?.realisticModeAvailable === false
? "请先配置实景化模型"
: generationMode === "style" &&
modelsData?.success === true &&
selectableModelOptions.length === 0
? "暂无可用生图模型"
: missingRequiredCategories.length > 0
? `还差:${missingRequiredCategories[0].title}`
: missingRequiredCustomField
? `还差:${missingRequiredCustomField}`
: "";
const promptDisplayMode = config?.promptDisplayMode || "stacked";
const themeStyle = {
"--style-pro-title-color": config?.titleTextColor || "#2563eb",
"--style-pro-button-bg": config?.buttonBackgroundColor || "#ffffff",
"--style-pro-button-text": config?.buttonTextColor || "#374151",
"--style-pro-selected-button-bg":
config?.selectedButtonBackgroundColor || "#eff6ff",
"--style-pro-selected-button-text":
config?.selectedButtonTextColor || "#2563eb",
} as CSSProperties & Record<string, string>;
const renderPromptCategory = (
category: PublicStyleProCategory,
layout: "stacked" | "carousel" = "stacked"
) => {
const selectedIds = selections[category.id] || [];
const categoryCustomFields = category.customFields || [];
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>
{categoryCustomFields.length > 0 ? (
<div
className={cn(
"mt-3 grid gap-3 border-black/5 border-t pt-3",
layout === "carousel" ? "grid-cols-1" : "md:grid-cols-2"
)}
>
{categoryCustomFields.map((field) => (
<StyleProCustomFieldInput
field={field}
key={field.id}
onChange={(value) =>
updateCustomInput({
categoryId: category.id,
fieldId: field.id,
scope: "category",
value,
})
}
value={customInputs.categories?.[category.id]?.[field.id] || ""}
/>
))}
</div>
) : null}
</div>
);
};
return (
<div
className="style-pro-index-shell flex h-dvh w-full overflow-hidden bg-[#f4f4f4] text-[#171717]"
style={themeStyle}
>
<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: var(--style-pro-title-color);
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: var(--style-pro-button-bg);
color: var(--style-pro-button-text);
transition: all 0.18s ease;
}
.style-pro-index-shell .option-btn:hover {
background: var(--style-pro-selected-button-bg);
border-color: var(--style-pro-selected-button-text);
color: var(--style-pro-selected-button-text);
}
.style-pro-index-shell .option-btn.active {
background: var(--style-pro-selected-button-bg);
color: var(--style-pro-selected-button-text);
border-color: var(--style-pro-selected-button-text);
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12);
}
.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
actions={
<>
{onBack ? (
<BrandTopBarButton onClick={onBack}>
<ArrowLeft className="h-3.5 w-3.5" />
返回画布
</BrandTopBarButton>
) : null}
<StyleProAccountActions />
</>
}
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>
))
)}
{hasCustomFields(config) && (config?.customFields || []).length > 0 ? (
<section className="section-card p-6">
<div className="rounded-[20px] bg-black/[0.025] p-4">
<div className="mb-3 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>
<div className="grid gap-3 md:grid-cols-2">
{(config?.customFields || []).map((field) => (
<StyleProCustomFieldInput
field={field}
key={field.id}
onChange={(value) =>
updateCustomInput({
fieldId: field.id,
scope: "global",
value,
})
}
value={customInputs.global?.[field.id] || ""}
/>
))}
</div>
</div>
</section>
) : null}
<section className="section-card p-5">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_300px] 2xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="flex min-h-[calc(100vh-172px)] flex-col 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-[110px] bg-transparent font-semibold text-black/75 outline-none"
disabled={generating}
onChange={(event) =>
setGenerationMode(
event.target.value === "realistic"
? "realistic"
: "style"
)
}
value={generationMode}
>
<option value="style">风格化</option>
<option value="realistic">实景化</option>
</select>
</div>
{generationMode === "style" ? (
<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>
{modelOptions.length > 0 ? (
<Select
disabled={
modelsLoading ||
selectableModelOptions.length === 0 ||
generating
}
onValueChange={setSelectedModelId}
value={selectedModel?.id || selectedModelId}
>
<SelectTrigger
aria-label="选择生图模型"
className="h-7 w-[190px] rounded-[10px] border-0 bg-transparent px-1 py-0 text-[12px] font-semibold text-black/75 shadow-none ring-offset-0 focus:ring-0 focus:ring-offset-0 disabled:opacity-60 sm:w-[230px]"
>
<span className="min-w-0 flex-1 truncate text-left">
{selectedModel?.displayName || "选择模型"}
</span>
</SelectTrigger>
<SelectContent className="z-[50000] max-h-[360px] min-w-[340px] rounded-2xl border-black/10 bg-white p-1.5 text-black shadow-[0_16px_44px_rgba(15,23,42,0.16)]">
{modelOptions.map((model) => (
<SelectItem
className="min-h-10 rounded-xl py-2 pl-8 pr-3 text-[13px] focus:bg-black/[0.04]"
disabled={model.selectable === false}
key={model.id}
title={model.disabledReason || model.displayName}
value={model.id}
>
<div className="flex w-full min-w-0 items-center gap-2">
{model.avatarUrl ? (
<img
alt=""
className="h-5 w-5 shrink-0 rounded-full object-cover"
src={model.avatarUrl}
/>
) : null}
<span className="min-w-0 flex-1 whitespace-normal break-words font-semibold leading-snug text-black/80">
{model.displayName}
</span>
{model.availabilityLabel ? (
<span className="shrink-0 rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-semibold text-amber-600">
{model.availabilityLabel}
</span>
) : null}
{model.creditDiscountLabel ? (
<span className="shrink-0 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-semibold text-emerald-600">
{model.creditDiscountLabel}
</span>
) : null}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className="h-6 max-w-[210px] truncate bg-transparent font-semibold text-black/40">
{modelsLoading ? "加载模型" : "暂无模型"}
</span>
)}
<span className="rounded-full bg-black/[0.04] px-2 py-0.5 font-semibold text-black/55">
{currentCostLabel}
</span>
{selectedModel?.creditDiscountLabel ? (
<span className="rounded-full bg-emerald-50 px-2 py-0.5 font-semibold text-emerald-600">
{selectedModel.creditDiscountLabel}
</span>
) : null}
{selectedModel?.availabilityLabel ? (
<span className="rounded-full bg-amber-50 px-2 py-0.5 font-semibold text-amber-600">
{selectedModel.availabilityLabel}
</span>
) : null}
</div>
) : null}
{generationMode === "realistic" ? (
<div className="flex h-8 items-center rounded-[14px] border border-black/10 bg-white px-3 text-[12px] font-semibold text-black/55 shadow-sm">
{currentCostLabel}
</div>
) : null}
<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 || currentCostLabel}
>
{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
? generationTaskState?.status === "pending"
? "排队中"
: "生成中"
: currentUnitCost
? `执行 · ${currentUnitCost}积分`
: "执行"}
</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>
<div className="min-h-0 flex-1">
<BeforeAfterSlider
afterUrl={currentResult?.resultImageUrl}
beforeUrl={currentResult?.sourceImageUrl || sourceImageUrl}
downloadDisabled={!currentResult?.resultImageUrl}
downloading={Boolean(
currentResult &&
downloadingImageId ===
(currentResult.id || currentResult.resultImageUrl)
)}
onDownload={() => {
void handleDownloadResult(currentResult);
}}
taskState={generationTaskState}
/>
</div>
</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 xl:grid-cols-1">
{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-[4/3] 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" />
{formatStyleProHistoryDateTime(item.createdAt)}
</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 xl:col-span-1">
生成后会在这里保存历史。
</div>
)}
</div>
</div>
</div>
</section>
</div>
</main>
</div>
);
}