"use client"; import { Clock3, Loader2, Sparkles, Upload, } from "lucide-react"; import { type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import useSWR from "swr"; import { toast } from "sonner"; import { BrandTopBar } from "@/components/layout/brand-top-bar"; import { Button } from "@/components/ui/button"; import { imageGenerationService } from "@/services/canvas"; import { cn, fetcher } from "@/lib/utils"; import type { PublicStyleProCategory, PublicStyleProConfig, StyleProHistoryItem, StyleProPersistedState, StyleProSelectionMap, } from "@/lib/style-pro/config"; type BrandingSettings = { siteName?: string; siteLogo?: string | null; siteLogoDark?: string | null; }; type StyleProConfigResponse = { success: boolean; config?: PublicStyleProConfig; error?: string; }; type StyleProGenerateResponse = { success: boolean; error?: string; data?: { imageUrl: string; thumbnailUrl?: string; model?: string; modelRef?: string; selections: StyleProSelectionMap; optionLabels: string[]; }; }; type StyleProWorkspaceProps = { canvasId: string; initialSourceImageUrl?: string | null; persistedState?: StyleProPersistedState | null; onAddToCanvas?: (item: StyleProHistoryItem) => void | Promise; onBack?: () => void; onPersistState?: (state: StyleProPersistedState) => void | Promise; }; function getHistoryId() { if (typeof crypto !== "undefined" && "randomUUID" in crypto) { return crypto.randomUUID(); } return `style-pro-${Date.now()}-${Math.random().toString(36).slice(2)}`; } function loadImageDimensions(url: string) { return new Promise<{ width?: number; height?: number }>((resolve) => { if (typeof window === "undefined" || !url) { resolve({}); return; } const image = new window.Image(); image.onload = () => { resolve({ width: image.naturalWidth || undefined, height: image.naturalHeight || undefined, }); }; image.onerror = () => resolve({}); image.src = url; }); } function normalizeHistory(history: StyleProHistoryItem[] | undefined) { return Array.isArray(history) ? history.slice(0, 4) : []; } function ImagePreview({ alt, className, imageUrl, }: { alt: string; className?: string; imageUrl?: string | null; }) { if (!imageUrl) { return (
等待图片
); } return ( {alt} ); } function GeneratingImagePlaceholder() { return (
正在创建图片
正在理解图片与提示词组合
生成中
); } function BeforeAfterSlider({ afterUrl, beforeUrl, generating = false, }: { afterUrl?: string | null; beforeUrl?: string | null; generating?: boolean; }) { const [position, setPosition] = useState(50); const sliderRef = useRef(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) => { event.currentTarget.setPointerCapture(event.pointerId); updatePositionFromClientX(event.clientX); }, [updatePositionFromClientX] ); const handlePointerMove = useCallback( (event: ReactPointerEvent) => { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return; updatePositionFromClientX(event.clientX); }, [updatePositionFromClientX] ); const handlePointerEnd = useCallback( (event: ReactPointerEvent) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } }, [] ); const handleSliderKeyDown = useCallback( (event: ReactKeyboardEvent) => { const step = event.shiftKey ? 10 : 3; if (event.key === "ArrowLeft" || event.key === "ArrowDown") { event.preventDefault(); setPosition((current) => Math.max(4, current - step)); } else if (event.key === "ArrowRight" || event.key === "ArrowUp") { event.preventDefault(); setPosition((current) => Math.min(96, current + step)); } else if (event.key === "Home") { event.preventDefault(); setPosition(4); } else if (event.key === "End") { event.preventDefault(); setPosition(96); } }, [] ); if (generating) { return ; } if (!beforeUrl && !afterUrl) { return (
上传图片后开始生成
); } if (!afterUrl) { return (
); } return (
对比
原图
结果
); } export function StyleProWorkspace({ canvasId, initialSourceImageUrl, onAddToCanvas, onPersistState, persistedState, }: StyleProWorkspaceProps) { const fileInputRef = useRef(null); const { data: brandingData } = useSWR( "/api/site-settings?scope=branding", fetcher ); const { data: configData, isLoading: configLoading } = useSWR("/api/style-pro/config", fetcher); const config = configData?.config; const enabledCategories = useMemo( () => (config?.categories || []) .filter((category) => category.enabled) .map((category) => ({ ...category, options: category.options.filter((option) => option.enabled), })) .filter((category) => category.options.length > 0), [config?.categories] ); const [sourceImageUrl, setSourceImageUrl] = useState( persistedState?.sourceImageUrl || initialSourceImageUrl || "" ); const [currentResult, setCurrentResult] = useState( normalizeHistory(persistedState?.history)[0] || null ); const [history, setHistory] = useState( normalizeHistory(persistedState?.history) ); const [selections, setSelections] = useState({}); const [uploading, setUploading] = useState(false); const [generating, setGenerating] = useState(false); useEffect(() => { const nextSource = persistedState?.sourceImageUrl || initialSourceImageUrl || ""; setSourceImageUrl((current) => current || nextSource); const nextHistory = normalizeHistory(persistedState?.history); setHistory(nextHistory); setCurrentResult((current) => current || nextHistory[0] || null); }, [initialSourceImageUrl, persistedState?.history, persistedState?.sourceImageUrl]); const siteName = brandingData?.siteName || "由壹点AI"; const logo = brandingData?.siteLogoDark || brandingData?.siteLogo || ""; const selectedOptionLabels = useMemo(() => { const labels: string[] = []; for (const category of enabledCategories) { const selected = selections[category.id] || []; for (const optionId of selected) { const option = category.options.find((item) => item.id === optionId); if (option) { labels.push(`${category.title}:${option.label}`); } } } return labels; }, [enabledCategories, selections]); const missingRequiredCategories = useMemo( () => enabledCategories.filter((category) => { if (!category.required && category.minSelections <= 0) return false; const selectedCount = selections[category.id]?.length || 0; return ( selectedCount < Math.max(category.required ? 1 : 0, category.minSelections) ); }), [enabledCategories, selections] ); const persistState = useCallback( async (nextState: StyleProPersistedState) => { await onPersistState?.({ sourceImageUrl: nextState.sourceImageUrl, history: normalizeHistory(nextState.history), }); }, [onPersistState] ); const updateSourceImage = useCallback( async (url: string) => { const nextUrl = String(url || "").trim(); setSourceImageUrl(nextUrl); setCurrentResult(null); await persistState({ sourceImageUrl: nextUrl, history }); }, [history, persistState] ); const handleFile = useCallback( async (file: File | null | undefined) => { if (!file) return; if (!file.type.startsWith("image/")) { toast.error("请上传图片文件"); return; } setUploading(true); try { const result = await imageGenerationService.uploadImage(file); if (!result.success || !result.data?.url) { throw new Error(result.error || "上传失败"); } await updateSourceImage(result.data.url); toast.success("图片已上传"); } catch (error) { toast.error(error instanceof Error ? error.message : "上传失败"); } finally { setUploading(false); } }, [updateSourceImage] ); const toggleOption = useCallback( (category: PublicStyleProCategory, optionId: string) => { setSelections((current) => { const previous = current[category.id] || []; const selected = previous.includes(optionId); let nextSelected = selected ? previous.filter((id) => id !== optionId) : [...previous, optionId]; if (!selected && category.maxSelections <= 1) { nextSelected = [optionId]; } if (nextSelected.length > category.maxSelections) { nextSelected = nextSelected.slice(-category.maxSelections); } return { ...current, [category.id]: nextSelected, }; }); }, [] ); const handleGenerate = useCallback(async () => { if (!sourceImageUrl) { toast.error("请先上传一张图片"); return; } if (!config?.enabled) { toast.error("风格化 PRO 暂未启用"); return; } if (missingRequiredCategories.length > 0) { toast.error(`请完成必选项:${missingRequiredCategories[0].title}`); return; } setGenerating(true); try { const response = await fetch("/api/style-pro/generate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ canvasId, sourceImageUrl, selections, }), }); const payload = (await response.json().catch(() => ({}))) as | StyleProGenerateResponse | undefined; if (!response.ok || !payload?.success || !payload.data?.imageUrl) { throw new Error(payload?.error || "生成失败"); } const dimensions = await loadImageDimensions(payload.data.imageUrl); const item: StyleProHistoryItem = { id: getHistoryId(), sourceImageUrl, resultImageUrl: payload.data.imageUrl, selections: payload.data.selections || selections, optionLabels: payload.data.optionLabels || selectedOptionLabels, createdAt: new Date().toISOString(), model: payload.data.model, modelRef: payload.data.modelRef, width: dimensions.width, height: dimensions.height, }; const nextHistory = [item, ...history.filter((entry) => entry.id !== item.id)].slice( 0, 4 ); setHistory(nextHistory); setCurrentResult(item); await persistState({ sourceImageUrl, history: nextHistory }); toast.success("风格化 PRO 生成完成"); } catch (error) { toast.error(error instanceof Error ? error.message : "生成失败"); } finally { setGenerating(false); } }, [ canvasId, config?.enabled, history, missingRequiredCategories, persistState, selectedOptionLabels, selections, sourceImageUrl, ]); const restoreHistoryItem = useCallback((item: StyleProHistoryItem) => { setSourceImageUrl(item.sourceImageUrl); setSelections(item.selections || {}); setCurrentResult(item); }, []); const disabledReason = !sourceImageUrl ? "请先上传图片" : missingRequiredCategories.length > 0 ? `还差:${missingRequiredCategories[0].title}` : ""; const promptDisplayMode = config?.promptDisplayMode || "stacked"; const renderPromptCategory = ( category: PublicStyleProCategory, layout: "stacked" | "carousel" = "stacked" ) => { const selectedIds = selections[category.id] || []; return (
{category.title}
{category.options.map((option) => { const selected = selectedIds.includes(option.id); return ( ); })}
); }; return (
{ window.location.href = "/"; }} position="sticky" siteName={siteName} />
上传图片 支持拖拽上传
{ void handleFile(event.target.files?.[0]); event.currentTarget.value = ""; }} ref={fileInputRef} type="file" />
{configLoading ? (
正在加载配置
) : !config?.enabled ? (
风格化 PRO 暂未启用,请联系管理员开启。
) : promptDisplayMode === "carousel" ? (
{enabledCategories.map((category) => (
{renderPromptCategory(category, "carousel")}
))}
) : ( enabledCategories.map((category) => (
{renderPromptCategory(category)}
)) )}
生图结果

生成后拖动滑块查看原图与结果图。

历史记录 最近 4 条
{history.length > 0 ? ( history.map((item) => ( )) ) : (
生成后会在这里保存历史。
)}
); }