"use client"; import { ArrowLeft, Clock3, Home, ImagePlus, Loader2, RotateCcw, Sparkles, Upload, UserRound, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import useSWR from "swr"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; 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 firstEnabledOptionId(category: PublicStyleProCategory) { return category.options.find((option) => option.enabled)?.id || ""; } function loadImageDimensions(url: string) { return new Promise<{ width?: number; height?: number }>((resolve) => { if (typeof window === "undefined" || !url) { resolve({}); return; } const image = new window.Image(); image.onload = () => { resolve({ width: image.naturalWidth || undefined, height: image.naturalHeight || undefined, }); }; image.onerror = () => resolve({}); image.src = url; }); } function normalizeHistory(history: StyleProHistoryItem[] | undefined) { return Array.isArray(history) ? history.slice(0, 4) : []; } function ImagePreview({ alt, className, imageUrl, }: { alt: string; className?: string; imageUrl?: string | null; }) { if (!imageUrl) { return (
等待图片
); } return ( {alt} ); } function GeneratingImagePlaceholder() { return (
正在创建图片
正在理解图片与提示词组合
生成中
); } function BeforeAfterSlider({ afterUrl, beforeUrl, generating = false, }: { afterUrl?: string | null; beforeUrl?: string | null; generating?: boolean; }) { const [position, setPosition] = useState(50); if (generating) { return ; } if (!beforeUrl && !afterUrl) { return (
上传图片后开始生成
); } if (!afterUrl) { return (
); } return (
对比
setPosition(Number(event.target.value))} type="range" value={position} />
原图
结果
); } export function StyleProWorkspace({ canvasId, initialSourceImageUrl, onAddToCanvas, onBack, 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 quickFillRequired = useCallback(() => { setSelections((current) => { const next = { ...current }; for (const category of enabledCategories) { if (!category.required || next[category.id]?.length) continue; const firstId = firstEnabledOptionId(category); if (firstId) next[category.id] = [firstId]; } return next; }); }, [enabledCategories]); const handleGenerate = useCallback(async () => { if (!sourceImageUrl) { toast.error("请先上传一张图片"); return; } if (!config?.enabled) { toast.error("风格化 PRO 暂未启用"); return; } if (missingRequiredCategories.length > 0) { toast.error(`请完成必选项:${missingRequiredCategories[0].title}`); return; } setGenerating(true); try { const response = await fetch("/api/style-pro/generate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ canvasId, sourceImageUrl, selections, }), }); const payload = (await response.json().catch(() => ({}))) as | StyleProGenerateResponse | undefined; if (!response.ok || !payload?.success || !payload.data?.imageUrl) { throw new Error(payload?.error || "生成失败"); } const dimensions = await loadImageDimensions(payload.data.imageUrl); const item: StyleProHistoryItem = { id: getHistoryId(), sourceImageUrl, resultImageUrl: payload.data.imageUrl, selections: payload.data.selections || selections, optionLabels: payload.data.optionLabels || selectedOptionLabels, createdAt: new Date().toISOString(), model: payload.data.model, modelRef: payload.data.modelRef, width: dimensions.width, height: dimensions.height, }; const nextHistory = [item, ...history.filter((entry) => entry.id !== item.id)].slice( 0, 4 ); setHistory(nextHistory); setCurrentResult(item); await persistState({ sourceImageUrl, history: nextHistory }); toast.success("风格化 PRO 生成完成"); } catch (error) { toast.error(error instanceof Error ? error.message : "生成失败"); } finally { setGenerating(false); } }, [ canvasId, config?.enabled, history, missingRequiredCategories, persistState, selectedOptionLabels, selections, sourceImageUrl, ]); const restoreHistoryItem = useCallback((item: StyleProHistoryItem) => { setSourceImageUrl(item.sourceImageUrl); setSelections(item.selections || {}); setCurrentResult(item); }, []); const disabledReason = !sourceImageUrl ? "请先上传图片" : missingRequiredCategories.length > 0 ? `还差:${missingRequiredCategories[0].title}` : ""; return (
{logo ? ( {siteName} ) : (
)}
{siteName}
风格化 PRO

风格化 PRO

上传图片,选择下方按钮组合,按执行生成最终图片。

{selectedOptionLabels.length ? `已选择 ${selectedOptionLabels.length} 项` : "等待选择"} {disabledReason ? ( {disabledReason} ) : null}
上传图片 支持拖拽上传
{ void handleFile(event.target.files?.[0]); event.currentTarget.value = ""; }} ref={fileInputRef} type="file" />
历史记录 最近 4 条
{history.length > 0 ? ( history.map((item) => ( )) ) : (
生成后会在这里保存历史。
)}
生图结果

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

{configLoading ? (
正在加载配置
) : !config?.enabled ? (
风格化 PRO 暂未启用,请联系管理员开启。
) : ( enabledCategories.map((category) => { const selectedIds = selections[category.id] || []; return (
{category.title}
{category.required ? ( 必选 ) : ( 可选 )} 已选 {selectedIds.length}/{category.maxSelections} {category.description ? ( {category.description} ) : null}
{category.options.map((option) => { const selected = selectedIds.includes(option.id); return ( ); })}
); }) )}

已选组合

按钮提示词只在服务端参与生成,前台仅展示按钮名称。

{selectedOptionLabels.length > 0 ? (
{selectedOptionLabels.map((label) => ( {label} ))}
) : (
还没有选择按钮。
)}
); }