"use client"; import { Clock3, Loader2, Sparkles, Upload } from "lucide-react"; import { type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import useSWR from "swr"; import { toast } from "sonner"; import { BrandTopBar } from "@/components/layout/brand-top-bar"; import { Button } from "@/components/ui/button"; import { imageGenerationService } from "@/services/canvas"; import { cn, fetcher } from "@/lib/utils"; import type { PublicStyleProCategory, PublicStyleProConfig, StyleProHistoryItem, StyleProPersistedState, StyleProSelectionMap, } from "@/lib/style-pro/config"; type BrandingSettings = { siteName?: string; siteLogo?: string | null; siteLogoDark?: string | null; }; type StyleProConfigResponse = { success: boolean; config?: PublicStyleProConfig; error?: string; }; type StyleProModelOption = { id: string; modelId: string; displayName: string; avatarUrl?: string | null; providerName: string; providerType: string; isDefault: boolean; unitCost: number; creditDiscountLabel?: string | null; }; type StyleProModelsResponse = { success: boolean; defaultModelId?: string | null; models?: StyleProModelOption[]; error?: string; }; type StyleProGenerateResponse = { success: boolean; error?: string; data?: { imageUrl: string; thumbnailUrl?: string; model?: string; modelRef?: string; selections: StyleProSelectionMap; optionLabels: string[]; }; }; type StyleProWorkspaceProps = { canvasId: string; initialSourceImageUrl?: string | null; persistedState?: StyleProPersistedState | null; onAddToCanvas?: (item: StyleProHistoryItem) => void | Promise; onBack?: () => void; onPersistState?: (state: StyleProPersistedState) => void | Promise; }; const STYLE_PRO_HISTORY_LIMIT = 4; function getHistoryId() { if (typeof crypto !== "undefined" && "randomUUID" in crypto) { return crypto.randomUUID(); } return `style-pro-${Date.now()}-${Math.random().toString(36).slice(2)}`; } function loadImageDimensions(url: string) { return new Promise<{ width?: number; height?: number }>((resolve) => { if (typeof window === "undefined" || !url) { resolve({}); return; } const image = new window.Image(); image.onload = () => { resolve({ width: image.naturalWidth || undefined, height: image.naturalHeight || undefined, }); }; image.onerror = () => resolve({}); image.src = url; }); } function normalizeHistory(history: StyleProHistoryItem[] | undefined) { return Array.isArray(history) ? history.slice(0, STYLE_PRO_HISTORY_LIMIT) : []; } function getLocalStyleProStateKey(canvasId: string) { return `style-pro-state:${canvasId || "draft"}`; } function readLocalStyleProState( canvasId: string ): StyleProPersistedState | null { if (typeof window === "undefined") return null; try { const raw = window.localStorage.getItem(getLocalStyleProStateKey(canvasId)); if (!raw) return null; const parsed = JSON.parse(raw) as StyleProPersistedState; return { sourceImageUrl: typeof parsed.sourceImageUrl === "string" ? parsed.sourceImageUrl : undefined, history: normalizeHistory( Array.isArray(parsed.history) ? parsed.history.filter( (item) => item && typeof item.sourceImageUrl === "string" && typeof item.resultImageUrl === "string" ) : [] ), }; } catch { return null; } } function writeLocalStyleProState( canvasId: string, state: StyleProPersistedState ) { if (typeof window === "undefined") return; try { window.localStorage.setItem( getLocalStyleProStateKey(canvasId), JSON.stringify({ sourceImageUrl: state.sourceImageUrl, history: normalizeHistory(state.history), }) ); } catch { // Local persistence is a safety net; server persistence remains authoritative. } } function mergeStyleProHistory( ...historyGroups: Array ) { const byKey = new Map(); 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 { return { sourceImageUrl: states.find((state) => String(state?.sourceImageUrl || "").trim()) ?.sourceImageUrl || undefined, history: mergeStyleProHistory(...states.map((state) => state?.history)), }; } function ImagePreview({ alt, className, imageUrl, }: { alt: string; className?: string; imageUrl?: string | null; }) { if (!imageUrl) { return (
等待图片
); } 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 { data: modelsData, isLoading: modelsLoading } = useSWR("/api/style-pro/model-options", fetcher); const config = configData?.config; const modelOptions = useMemo( () => (modelsData?.models || []).filter((model) => Boolean(model.id)), [modelsData?.models] ); const enabledCategories = useMemo( () => (config?.categories || []) .filter((category) => category.enabled) .map((category) => ({ ...category, options: category.options.filter((option) => option.enabled), })) .filter((category) => category.options.length > 0), [config?.categories] ); const [sourceImageUrl, setSourceImageUrl] = useState( () => mergeStyleProState( persistedState, readLocalStyleProState(canvasId), initialSourceImageUrl ? { sourceImageUrl: initialSourceImageUrl, history: [] } : null ).sourceImageUrl || "" ); const [currentResult, setCurrentResult] = useState(() => { const merged = mergeStyleProState( persistedState, readLocalStyleProState(canvasId) ); return merged.history[0] || null; }); const [history, setHistory] = useState( () => mergeStyleProState(persistedState, readLocalStyleProState(canvasId)) .history ); const [selections, setSelections] = useState({}); const [selectedModelId, setSelectedModelId] = useState(""); const [uploading, setUploading] = useState(false); const [generating, setGenerating] = useState(false); useEffect(() => { if (modelOptions.length === 0) { setSelectedModelId(""); return; } setSelectedModelId((current) => { if (current && modelOptions.some((model) => model.id === current)) { return current; } return modelsData?.defaultModelId || modelOptions[0]?.id || ""; }); }, [modelOptions, modelsData?.defaultModelId]); useEffect(() => { const localState = readLocalStyleProState(canvasId); const nextSource = persistedState?.sourceImageUrl || localState?.sourceImageUrl || initialSourceImageUrl || ""; setSourceImageUrl((current) => current || nextSource); setHistory((currentHistory) => mergeStyleProHistory( currentHistory, persistedState?.history, localState?.history ) ); setCurrentResult( (current) => current || mergeStyleProHistory(persistedState?.history, localState?.history)[0] || null ); }, [ canvasId, initialSourceImageUrl, persistedState?.history, persistedState?.sourceImageUrl, ]); const siteName = brandingData?.siteName || "由壹点AI"; const logo = brandingData?.siteLogoDark || brandingData?.siteLogo || ""; const selectedOptionLabels = useMemo(() => { const labels: string[] = []; for (const category of enabledCategories) { const selected = selections[category.id] || []; for (const optionId of selected) { const option = category.options.find((item) => item.id === optionId); if (option) { labels.push(`${category.title}:${option.label}`); } } } return labels; }, [enabledCategories, selections]); const selectedModel = useMemo( () => modelOptions.find((model) => model.id === selectedModelId) || modelOptions[0] || null, [modelOptions, selectedModelId] ); const selectedModelCostLabel = selectedModel ? selectedModel.unitCost > 0 ? `${selectedModel.unitCost} 积分/张` : "未设置积分" : modelsLoading ? "加载价格" : "暂无模型"; const missingRequiredCategories = useMemo( () => enabledCategories.filter((category) => { if (!category.required && category.minSelections <= 0) return false; const selectedCount = selections[category.id]?.length || 0; return ( selectedCount < Math.max(category.required ? 1 : 0, category.minSelections) ); }), [enabledCategories, selections] ); const persistState = useCallback( async (nextState: StyleProPersistedState) => { const normalizedState = { sourceImageUrl: nextState.sourceImageUrl, history: normalizeHistory(nextState.history), }; writeLocalStyleProState(canvasId, normalizedState); await onPersistState?.(normalizedState); }, [canvasId, onPersistState] ); const updateSourceImage = useCallback( async (url: string) => { const nextUrl = String(url || "").trim(); setSourceImageUrl(nextUrl); setCurrentResult(null); await persistState({ sourceImageUrl: nextUrl, history }); }, [history, persistState] ); const handleFile = useCallback( async (file: File | null | undefined) => { if (!file) return; if (!file.type.startsWith("image/")) { toast.error("请上传图片文件"); return; } setUploading(true); try { const result = await imageGenerationService.uploadImage(file); if (!result.success || !result.data?.url) { throw new Error(result.error || "上传失败"); } await updateSourceImage(result.data.url); toast.success("图片已上传"); } catch (error) { toast.error(error instanceof Error ? error.message : "上传失败"); } finally { setUploading(false); } }, [updateSourceImage] ); const toggleOption = useCallback( (category: PublicStyleProCategory, optionId: string) => { setSelections((current) => { const previous = current[category.id] || []; const selected = previous.includes(optionId); let nextSelected = selected ? previous.filter((id) => id !== optionId) : [...previous, optionId]; if (!selected && category.maxSelections <= 1) { nextSelected = [optionId]; } if (nextSelected.length > category.maxSelections) { nextSelected = nextSelected.slice(-category.maxSelections); } return { ...current, [category.id]: nextSelected, }; }); }, [] ); const handleGenerate = useCallback(async () => { if (!sourceImageUrl) { toast.error("请先上传一张图片"); return; } if (!config?.enabled) { toast.error("风格化 PRO 暂未启用"); return; } if (missingRequiredCategories.length > 0) { toast.error(`请完成必选项:${missingRequiredCategories[0].title}`); return; } setGenerating(true); try { const response = await fetch("/api/style-pro/generate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ canvasId, modelId: selectedModel?.id || selectedModelId || undefined, sourceImageUrl, selections, }), }); const payload = (await response.json().catch(() => ({}))) as | StyleProGenerateResponse | undefined; if (!response.ok || !payload?.success || !payload.data?.imageUrl) { throw new Error(payload?.error || "生成失败"); } const dimensions = await loadImageDimensions(payload.data.imageUrl); const item: StyleProHistoryItem = { id: getHistoryId(), sourceImageUrl, resultImageUrl: payload.data.imageUrl, selections: payload.data.selections || selections, optionLabels: payload.data.optionLabels || selectedOptionLabels, createdAt: new Date().toISOString(), model: payload.data.model, modelRef: payload.data.modelRef, width: dimensions.width, height: dimensions.height, }; const nextHistory = [ item, ...history.filter((entry) => entry.id !== item.id), ].slice(0, 4); setHistory(nextHistory); setCurrentResult(item); await persistState({ sourceImageUrl, history: nextHistory }); toast.success("风格化 PRO 生成完成"); } catch (error) { toast.error(error instanceof Error ? error.message : "生成失败"); } finally { setGenerating(false); } }, [ canvasId, config?.enabled, history, missingRequiredCategories, persistState, selectedOptionLabels, selectedModel?.id, selectedModelId, selections, sourceImageUrl, ]); const restoreHistoryItem = useCallback((item: StyleProHistoryItem) => { setSourceImageUrl(item.sourceImageUrl); setSelections(item.selections || {}); setCurrentResult(item); }, []); const disabledReason = !sourceImageUrl ? "请先上传图片" : modelsData?.success === true && modelOptions.length === 0 ? "暂无可用生图模型" : missingRequiredCategories.length > 0 ? `还差:${missingRequiredCategories[0].title}` : ""; const promptDisplayMode = config?.promptDisplayMode || "stacked"; const renderPromptCategory = ( category: PublicStyleProCategory, layout: "stacked" | "carousel" = "stacked" ) => { const selectedIds = selections[category.id] || []; return (
{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)}
)) )}
生图结果

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

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