1932 lines
68 KiB
TypeScript
1932 lines
68 KiB
TypeScript
"use client";
|
||
|
||
import { ArrowLeft, Clock3, 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 { 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 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,
|
||
taskState,
|
||
}: {
|
||
afterUrl?: string | null;
|
||
beforeUrl?: string | null;
|
||
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(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 (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="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,
|
||
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 [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 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}
|
||
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>
|
||
);
|
||
}
|