From e8a3212b35dfd8cb4529f3cb8b460253c0fc3a2f Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 16 May 2026 21:28:00 +0800 Subject: [PATCH] feat: persist style pro history and model pricing --- app/api/style-pro/generate/route.ts | 53 +++- app/api/style-pro/model-options/route.ts | 58 ++++ app/canvas/[id]/style-pro/page.tsx | 68 ++++- components/style-pro/StyleProWorkspace.tsx | 294 ++++++++++++++++++--- lib/style-pro/model-options.ts | 92 +++++++ 5 files changed, 518 insertions(+), 47 deletions(-) create mode 100644 app/api/style-pro/model-options/route.ts create mode 100644 lib/style-pro/model-options.ts diff --git a/app/api/style-pro/generate/route.ts b/app/api/style-pro/generate/route.ts index e38d4b525..67f2279ef 100644 --- a/app/api/style-pro/generate/route.ts +++ b/app/api/style-pro/generate/route.ts @@ -2,7 +2,11 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; import { generateCanvasImage } from "@/lib/canvas/generate-image"; -import { getSystemSetting } from "@/lib/db/queries"; +import { getSystemSetting, getUserById } from "@/lib/db/queries"; +import { + findStyleProImageModelByRef, + getStyleProImageModelEntries, +} from "@/lib/style-pro/model-options"; import { STYLE_PRO_CONFIG_KEY, type StyleProCategory, @@ -16,6 +20,7 @@ export const dynamic = "force-dynamic"; const generateRequestSchema = z.object({ canvasId: z.string().trim().min(1).max(160).optional(), sourceImageUrl: z.string().trim().min(1).max(4000), + modelId: z.string().trim().min(1).max(160).optional(), selections: z.record(z.array(z.string().trim().min(1).max(120)).max(20)), }); @@ -79,7 +84,9 @@ function validateSelections( categories: StyleProCategory[], rawSelections: Record ) { - const categoryById = new Map(categories.map((category) => [category.id, category])); + const categoryById = new Map( + categories.map((category) => [category.id, category]) + ); const selectedOptions: SelectedOption[] = []; const normalizedSelections: Record = {}; @@ -121,7 +128,10 @@ function validateSelections( const missingCategory = categories.find((category) => { if (!category.required && category.minSelections <= 0) return false; const selectedCount = normalizedSelections[category.id]?.length || 0; - return selectedCount < Math.max(category.required ? 1 : 0, category.minSelections); + return ( + selectedCount < + Math.max(category.required ? 1 : 0, category.minSelections) + ); }); if (missingCategory) { return { @@ -141,7 +151,10 @@ export async function POST(request: Request) { try { const session = await auth(); if (!session?.user?.id) { - return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); + return NextResponse.json( + { success: false, error: "未登录" }, + { status: 401 } + ); } const body = generateRequestSchema.parse(await request.json()); @@ -170,7 +183,10 @@ export async function POST(request: Request) { options: category.options.filter((option) => option.enabled), })) .filter((category) => category.options.length > 0); - const selectionResult = validateSelections(enabledCategories, body.selections); + const selectionResult = validateSelections( + enabledCategories, + body.selections + ); if (!selectionResult.ok) { return NextResponse.json( { success: false, error: selectionResult.error }, @@ -178,6 +194,29 @@ export async function POST(request: Request) { ); } + let selectedModelRef = config.modelId || undefined; + if (body.modelId) { + const currentUser = await getUserById({ id: session.user.id }).catch( + () => null + ); + const includeHidden = + session.user.type === "admin" || Boolean(currentUser?.isHighVip); + const imageModelEntries = await getStyleProImageModelEntries({ + includeHidden, + }); + const selectedModel = findStyleProImageModelByRef( + imageModelEntries, + body.modelId + ); + if (!selectedModel) { + return NextResponse.json( + { success: false, error: "所选生图模型不可用" }, + { status: 400 } + ); + } + selectedModelRef = selectedModel.model.id; + } + const prompt = composePrompt({ defaultPrompt: config.defaultPrompt, selectedOptions: selectionResult.selectedOptions, @@ -185,7 +224,7 @@ export async function POST(request: Request) { const result = await generateCanvasImage({ prompt, negativePrompt: config.negativePrompt || undefined, - model: config.modelId || undefined, + model: selectedModelRef, aspectRatio: config.aspectRatio || "auto", imageSize: config.imageSize || "2K", imageCount: 1, @@ -194,6 +233,7 @@ export async function POST(request: Request) { debugLabel: "style-pro", debugMeta: { canvasId: body.canvasId || null, + requestedStyleProModelId: body.modelId || null, selectedOptionCount: selectionResult.selectedOptions.length, }, }); @@ -235,4 +275,3 @@ export async function POST(request: Request) { ); } } - diff --git a/app/api/style-pro/model-options/route.ts b/app/api/style-pro/model-options/route.ts new file mode 100644 index 000000000..0b363ae7c --- /dev/null +++ b/app/api/style-pro/model-options/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { getSystemSetting, getUserById } from "@/lib/db/queries"; +import { + findStyleProImageModelByRef, + getStyleProImageModelEntries, + toPublicStyleProModelOption, +} from "@/lib/style-pro/model-options"; +import { + STYLE_PRO_CONFIG_KEY, + normalizeStyleProConfig, +} from "@/lib/style-pro/config"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: "未登录" }, + { status: 401 } + ); + } + + const currentUser = await getUserById({ id: session.user.id }).catch( + () => null + ); + const includeHidden = + session.user.type === "admin" || Boolean(currentUser?.isHighVip); + const [entries, setting] = await Promise.all([ + getStyleProImageModelEntries({ includeHidden }), + getSystemSetting({ key: STYLE_PRO_CONFIG_KEY }).catch(() => null), + ]); + const config = normalizeStyleProConfig(setting?.value); + const configuredDefault = findStyleProImageModelByRef( + entries, + config.modelId + ); + const defaultEntry = + configuredDefault || + entries.find((entry) => entry.model.isDefault) || + entries[0] || + null; + + return NextResponse.json({ + success: true, + defaultModelId: defaultEntry?.model.id || null, + models: entries.map(toPublicStyleProModelOption), + }); + } catch (error) { + console.error("[Style Pro Model Options] Failed:", error); + return NextResponse.json( + { success: false, error: "获取风格化 PRO 生图模型失败" }, + { status: 500 } + ); + } +} diff --git a/app/canvas/[id]/style-pro/page.tsx b/app/canvas/[id]/style-pro/page.tsx index 056d5423c..0ba03f4aa 100644 --- a/app/canvas/[id]/style-pro/page.tsx +++ b/app/canvas/[id]/style-pro/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useParams, useRouter } from "next/navigation"; -import { useCallback } from "react"; +import { useCallback, useEffect } from "react"; import { toast } from "sonner"; import { StyleProWorkspace } from "@/components/style-pro/StyleProWorkspace"; import { canvasService } from "@/services/canvas"; @@ -10,6 +10,7 @@ import type { StyleProHistoryItem, StyleProPersistedState, } from "@/lib/style-pro/config"; +import { normalizeStyleProPersistedState } from "@/lib/style-pro/config"; export default function CanvasStyleProPage() { const router = useRouter(); @@ -21,9 +22,52 @@ export default function CanvasStyleProPage() { const nodes = useCanvasStore(canvasSelectors.nodes); const styleProState = useCanvasStore((state) => state.styleProState); const addNode = useCanvasStore((state) => state.addNode); + const loadCanvas = useCanvasStore((state) => state.loadCanvas); const selectNodes = useCanvasStore((state) => state.selectNodes); + const setProject = useCanvasStore((state) => state.setProject); + const setStyleProState = useCanvasStore((state) => state.setStyleProState); const getCanvasState = useCanvasStore((state) => state.getCanvasState); + useEffect(() => { + let cancelled = false; + + const loadPersistedStyleProState = async () => { + if (!id) return; + const currentState = useCanvasStore.getState(); + if ( + currentState.projectId === id && + (currentState.nodes.length > 0 || + currentState.styleProState.history.length > 0) + ) { + return; + } + + const result = await canvasService.getProject(id); + if (cancelled || !result.success || !result.data?.canvasData) return; + + const canvasData = result.data.canvasData; + const latestState = useCanvasStore.getState(); + setProject(result.data.id, result.data.title); + if (latestState.nodes.length === 0 && latestState.edges.length === 0) { + loadCanvas(canvasData); + return; + } + + const nextStyleProState = normalizeStyleProPersistedState( + canvasData.styleProState + ); + if (nextStyleProState.history.length > 0) { + setStyleProState(nextStyleProState); + } + }; + + void loadPersistedStyleProState(); + + return () => { + cancelled = true; + }; + }, [id, loadCanvas, setProject, setStyleProState]); + const selectedImageNode = selectedNodeIds.length === 1 ? nodes.find( @@ -46,7 +90,10 @@ export default function CanvasStyleProPage() { useCanvasStore.getState().getCanvasState() ); if (!result.success) { - console.error("[CanvasStyleProPage] 保存风格化 PRO 状态失败:", result.error); + console.error( + "[CanvasStyleProPage] 保存风格化 PRO 状态失败:", + result.error + ); } }, [id] @@ -62,8 +109,10 @@ export default function CanvasStyleProPage() { const viewportX = viewport.x || 0; const viewportY = viewport.y || 0; const viewportZoom = viewport.zoom || 1; - const stageWidth = typeof window !== "undefined" ? window.innerWidth : 1280; - const stageHeight = typeof window !== "undefined" ? window.innerHeight : 720; + const stageWidth = + typeof window !== "undefined" ? window.innerWidth : 1280; + const stageHeight = + typeof window !== "undefined" ? window.innerHeight : 720; const naturalWidth = Number(item.width || 0); const naturalHeight = Number(item.height || 0); const hasNaturalSize = naturalWidth > 0 && naturalHeight > 0; @@ -111,7 +160,15 @@ export default function CanvasStyleProPage() { } toast.success("风格化 PRO 结果已添加到画布"); }, - [addNode, getCanvasState, id, selectNodes, viewport.x, viewport.y, viewport.zoom] + [ + addNode, + getCanvasState, + id, + selectNodes, + viewport.x, + viewport.y, + viewport.zoom, + ] ); return ( @@ -125,4 +182,3 @@ export default function CanvasStyleProPage() { /> ); } - diff --git a/components/style-pro/StyleProWorkspace.tsx b/components/style-pro/StyleProWorkspace.tsx index 278078d7b..c09db3312 100644 --- a/components/style-pro/StyleProWorkspace.tsx +++ b/components/style-pro/StyleProWorkspace.tsx @@ -1,11 +1,6 @@ "use client"; -import { - Clock3, - Loader2, - Sparkles, - Upload, -} from "lucide-react"; +import { Clock3, Loader2, Sparkles, Upload } from "lucide-react"; import { type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, @@ -41,6 +36,25 @@ type StyleProConfigResponse = { 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; @@ -63,6 +77,8 @@ type StyleProWorkspaceProps = { onPersistState?: (state: StyleProPersistedState) => void | Promise; }; +const STYLE_PRO_HISTORY_LIMIT = 4; + function getHistoryId() { if (typeof crypto !== "undefined" && "randomUUID" in crypto) { return crypto.randomUUID(); @@ -89,7 +105,93 @@ function loadImageDimensions(url: string) { } function normalizeHistory(history: StyleProHistoryItem[] | undefined) { - return Array.isArray(history) ? history.slice(0, 4) : []; + 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({ @@ -361,7 +463,13 @@ export function StyleProWorkspace({ ); 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 || []) @@ -375,27 +483,73 @@ export function StyleProWorkspace({ ); const [sourceImageUrl, setSourceImageUrl] = useState( - persistedState?.sourceImageUrl || initialSourceImageUrl || "" + () => + mergeStyleProState( + persistedState, + readLocalStyleProState(canvasId), + initialSourceImageUrl + ? { sourceImageUrl: initialSourceImageUrl, history: [] } + : null + ).sourceImageUrl || "" ); const [currentResult, setCurrentResult] = - useState( - normalizeHistory(persistedState?.history)[0] || null - ); + useState(() => { + const merged = mergeStyleProState( + persistedState, + readLocalStyleProState(canvasId) + ); + return merged.history[0] || null; + }); const [history, setHistory] = useState( - normalizeHistory(persistedState?.history) + () => + 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 || initialSourceImageUrl || ""; + persistedState?.sourceImageUrl || + localState?.sourceImageUrl || + initialSourceImageUrl || + ""; setSourceImageUrl((current) => current || nextSource); - const nextHistory = normalizeHistory(persistedState?.history); - setHistory(nextHistory); - setCurrentResult((current) => current || nextHistory[0] || null); - }, [initialSourceImageUrl, persistedState?.history, persistedState?.sourceImageUrl]); + 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 || ""; @@ -414,6 +568,22 @@ export function StyleProWorkspace({ 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) => { @@ -429,12 +599,14 @@ export function StyleProWorkspace({ const persistState = useCallback( async (nextState: StyleProPersistedState) => { - await onPersistState?.({ + const normalizedState = { sourceImageUrl: nextState.sourceImageUrl, history: normalizeHistory(nextState.history), - }); + }; + writeLocalStyleProState(canvasId, normalizedState); + await onPersistState?.(normalizedState); }, - [onPersistState] + [canvasId, onPersistState] ); const updateSourceImage = useCallback( @@ -515,6 +687,7 @@ export function StyleProWorkspace({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ canvasId, + modelId: selectedModel?.id || selectedModelId || undefined, sourceImageUrl, selections, }), @@ -538,10 +711,10 @@ export function StyleProWorkspace({ width: dimensions.width, height: dimensions.height, }; - const nextHistory = [item, ...history.filter((entry) => entry.id !== item.id)].slice( - 0, - 4 - ); + const nextHistory = [ + item, + ...history.filter((entry) => entry.id !== item.id), + ].slice(0, 4); setHistory(nextHistory); setCurrentResult(item); await persistState({ sourceImageUrl, history: nextHistory }); @@ -558,6 +731,8 @@ export function StyleProWorkspace({ missingRequiredCategories, persistState, selectedOptionLabels, + selectedModel?.id, + selectedModelId, selections, sourceImageUrl, ]); @@ -570,9 +745,11 @@ export function StyleProWorkspace({ const disabledReason = !sourceImageUrl ? "请先上传图片" - : missingRequiredCategories.length > 0 - ? `还差:${missingRequiredCategories[0].title}` - : ""; + : modelsData?.success === true && modelOptions.length === 0 + ? "暂无可用生图模型" + : missingRequiredCategories.length > 0 + ? `还差:${missingRequiredCategories[0].title}` + : ""; const promptDisplayMode = config?.promptDisplayMode || "stacked"; const renderPromptCategory = ( category: PublicStyleProCategory, @@ -762,23 +939,69 @@ export function StyleProWorkspace({ 生成后拖动滑块查看原图与结果图。

-
+
+
+ 模型 + + + {selectedModelCostLabel} + + {selectedModel?.creditDiscountLabel ? ( + + {selectedModel.creditDiscountLabel} + + ) : null} +