feat: persist style pro history and model pricing
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-16 21:28:00 +08:00
parent 0ac796b9ff
commit e8a3212b35
5 changed files with 518 additions and 47 deletions

View File

@ -2,7 +2,11 @@ import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { generateCanvasImage } from "@/lib/canvas/generate-image"; 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 { import {
STYLE_PRO_CONFIG_KEY, STYLE_PRO_CONFIG_KEY,
type StyleProCategory, type StyleProCategory,
@ -16,6 +20,7 @@ export const dynamic = "force-dynamic";
const generateRequestSchema = z.object({ const generateRequestSchema = z.object({
canvasId: z.string().trim().min(1).max(160).optional(), canvasId: z.string().trim().min(1).max(160).optional(),
sourceImageUrl: z.string().trim().min(1).max(4000), 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)), selections: z.record(z.array(z.string().trim().min(1).max(120)).max(20)),
}); });
@ -79,7 +84,9 @@ function validateSelections(
categories: StyleProCategory[], categories: StyleProCategory[],
rawSelections: Record<string, string[]> rawSelections: Record<string, string[]>
) { ) {
const categoryById = new Map(categories.map((category) => [category.id, category])); const categoryById = new Map(
categories.map((category) => [category.id, category])
);
const selectedOptions: SelectedOption[] = []; const selectedOptions: SelectedOption[] = [];
const normalizedSelections: Record<string, string[]> = {}; const normalizedSelections: Record<string, string[]> = {};
@ -121,7 +128,10 @@ function validateSelections(
const missingCategory = categories.find((category) => { const missingCategory = categories.find((category) => {
if (!category.required && category.minSelections <= 0) return false; if (!category.required && category.minSelections <= 0) return false;
const selectedCount = normalizedSelections[category.id]?.length || 0; 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) { if (missingCategory) {
return { return {
@ -141,7 +151,10 @@ export async function POST(request: Request) {
try { try {
const session = await auth(); const session = await auth();
if (!session?.user?.id) { 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()); const body = generateRequestSchema.parse(await request.json());
@ -170,7 +183,10 @@ export async function POST(request: Request) {
options: category.options.filter((option) => option.enabled), options: category.options.filter((option) => option.enabled),
})) }))
.filter((category) => category.options.length > 0); .filter((category) => category.options.length > 0);
const selectionResult = validateSelections(enabledCategories, body.selections); const selectionResult = validateSelections(
enabledCategories,
body.selections
);
if (!selectionResult.ok) { if (!selectionResult.ok) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: selectionResult.error }, { 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({ const prompt = composePrompt({
defaultPrompt: config.defaultPrompt, defaultPrompt: config.defaultPrompt,
selectedOptions: selectionResult.selectedOptions, selectedOptions: selectionResult.selectedOptions,
@ -185,7 +224,7 @@ export async function POST(request: Request) {
const result = await generateCanvasImage({ const result = await generateCanvasImage({
prompt, prompt,
negativePrompt: config.negativePrompt || undefined, negativePrompt: config.negativePrompt || undefined,
model: config.modelId || undefined, model: selectedModelRef,
aspectRatio: config.aspectRatio || "auto", aspectRatio: config.aspectRatio || "auto",
imageSize: config.imageSize || "2K", imageSize: config.imageSize || "2K",
imageCount: 1, imageCount: 1,
@ -194,6 +233,7 @@ export async function POST(request: Request) {
debugLabel: "style-pro", debugLabel: "style-pro",
debugMeta: { debugMeta: {
canvasId: body.canvasId || null, canvasId: body.canvasId || null,
requestedStyleProModelId: body.modelId || null,
selectedOptionCount: selectionResult.selectedOptions.length, selectedOptionCount: selectionResult.selectedOptions.length,
}, },
}); });
@ -235,4 +275,3 @@ export async function POST(request: Request) {
); );
} }
} }

View File

@ -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 }
);
}
}

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useCallback } from "react"; import { useCallback, useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { StyleProWorkspace } from "@/components/style-pro/StyleProWorkspace"; import { StyleProWorkspace } from "@/components/style-pro/StyleProWorkspace";
import { canvasService } from "@/services/canvas"; import { canvasService } from "@/services/canvas";
@ -10,6 +10,7 @@ import type {
StyleProHistoryItem, StyleProHistoryItem,
StyleProPersistedState, StyleProPersistedState,
} from "@/lib/style-pro/config"; } from "@/lib/style-pro/config";
import { normalizeStyleProPersistedState } from "@/lib/style-pro/config";
export default function CanvasStyleProPage() { export default function CanvasStyleProPage() {
const router = useRouter(); const router = useRouter();
@ -21,9 +22,52 @@ export default function CanvasStyleProPage() {
const nodes = useCanvasStore(canvasSelectors.nodes); const nodes = useCanvasStore(canvasSelectors.nodes);
const styleProState = useCanvasStore((state) => state.styleProState); const styleProState = useCanvasStore((state) => state.styleProState);
const addNode = useCanvasStore((state) => state.addNode); const addNode = useCanvasStore((state) => state.addNode);
const loadCanvas = useCanvasStore((state) => state.loadCanvas);
const selectNodes = useCanvasStore((state) => state.selectNodes); const selectNodes = useCanvasStore((state) => state.selectNodes);
const setProject = useCanvasStore((state) => state.setProject);
const setStyleProState = useCanvasStore((state) => state.setStyleProState);
const getCanvasState = useCanvasStore((state) => state.getCanvasState); 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 = const selectedImageNode =
selectedNodeIds.length === 1 selectedNodeIds.length === 1
? nodes.find( ? nodes.find(
@ -46,7 +90,10 @@ export default function CanvasStyleProPage() {
useCanvasStore.getState().getCanvasState() useCanvasStore.getState().getCanvasState()
); );
if (!result.success) { if (!result.success) {
console.error("[CanvasStyleProPage] 保存风格化 PRO 状态失败:", result.error); console.error(
"[CanvasStyleProPage] 保存风格化 PRO 状态失败:",
result.error
);
} }
}, },
[id] [id]
@ -62,8 +109,10 @@ export default function CanvasStyleProPage() {
const viewportX = viewport.x || 0; const viewportX = viewport.x || 0;
const viewportY = viewport.y || 0; const viewportY = viewport.y || 0;
const viewportZoom = viewport.zoom || 1; const viewportZoom = viewport.zoom || 1;
const stageWidth = typeof window !== "undefined" ? window.innerWidth : 1280; const stageWidth =
const stageHeight = typeof window !== "undefined" ? window.innerHeight : 720; typeof window !== "undefined" ? window.innerWidth : 1280;
const stageHeight =
typeof window !== "undefined" ? window.innerHeight : 720;
const naturalWidth = Number(item.width || 0); const naturalWidth = Number(item.width || 0);
const naturalHeight = Number(item.height || 0); const naturalHeight = Number(item.height || 0);
const hasNaturalSize = naturalWidth > 0 && naturalHeight > 0; const hasNaturalSize = naturalWidth > 0 && naturalHeight > 0;
@ -111,7 +160,15 @@ export default function CanvasStyleProPage() {
} }
toast.success("风格化 PRO 结果已添加到画布"); toast.success("风格化 PRO 结果已添加到画布");
}, },
[addNode, getCanvasState, id, selectNodes, viewport.x, viewport.y, viewport.zoom] [
addNode,
getCanvasState,
id,
selectNodes,
viewport.x,
viewport.y,
viewport.zoom,
]
); );
return ( return (
@ -125,4 +182,3 @@ export default function CanvasStyleProPage() {
/> />
); );
} }

View File

@ -1,11 +1,6 @@
"use client"; "use client";
import { import { Clock3, Loader2, Sparkles, Upload } from "lucide-react";
Clock3,
Loader2,
Sparkles,
Upload,
} from "lucide-react";
import { import {
type KeyboardEvent as ReactKeyboardEvent, type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent, type PointerEvent as ReactPointerEvent,
@ -41,6 +36,25 @@ type StyleProConfigResponse = {
error?: string; 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 = { type StyleProGenerateResponse = {
success: boolean; success: boolean;
error?: string; error?: string;
@ -63,6 +77,8 @@ type StyleProWorkspaceProps = {
onPersistState?: (state: StyleProPersistedState) => void | Promise<void>; onPersistState?: (state: StyleProPersistedState) => void | Promise<void>;
}; };
const STYLE_PRO_HISTORY_LIMIT = 4;
function getHistoryId() { function getHistoryId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) { if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID(); return crypto.randomUUID();
@ -89,7 +105,93 @@ function loadImageDimensions(url: string) {
} }
function normalizeHistory(history: StyleProHistoryItem[] | undefined) { 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<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 ImagePreview({ function ImagePreview({
@ -361,7 +463,13 @@ export function StyleProWorkspace({
); );
const { data: configData, isLoading: configLoading } = const { data: configData, isLoading: configLoading } =
useSWR<StyleProConfigResponse>("/api/style-pro/config", fetcher); 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 config = configData?.config;
const modelOptions = useMemo(
() => (modelsData?.models || []).filter((model) => Boolean(model.id)),
[modelsData?.models]
);
const enabledCategories = useMemo( const enabledCategories = useMemo(
() => () =>
(config?.categories || []) (config?.categories || [])
@ -375,27 +483,73 @@ export function StyleProWorkspace({
); );
const [sourceImageUrl, setSourceImageUrl] = useState( const [sourceImageUrl, setSourceImageUrl] = useState(
persistedState?.sourceImageUrl || initialSourceImageUrl || "" () =>
mergeStyleProState(
persistedState,
readLocalStyleProState(canvasId),
initialSourceImageUrl
? { sourceImageUrl: initialSourceImageUrl, history: [] }
: null
).sourceImageUrl || ""
); );
const [currentResult, setCurrentResult] = const [currentResult, setCurrentResult] =
useState<StyleProHistoryItem | null>( useState<StyleProHistoryItem | null>(() => {
normalizeHistory(persistedState?.history)[0] || null const merged = mergeStyleProState(
); persistedState,
readLocalStyleProState(canvasId)
);
return merged.history[0] || null;
});
const [history, setHistory] = useState<StyleProHistoryItem[]>( const [history, setHistory] = useState<StyleProHistoryItem[]>(
normalizeHistory(persistedState?.history) () =>
mergeStyleProState(persistedState, readLocalStyleProState(canvasId))
.history
); );
const [selections, setSelections] = useState<StyleProSelectionMap>({}); const [selections, setSelections] = useState<StyleProSelectionMap>({});
const [selectedModelId, setSelectedModelId] = useState("");
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
useEffect(() => { 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 = const nextSource =
persistedState?.sourceImageUrl || initialSourceImageUrl || ""; persistedState?.sourceImageUrl ||
localState?.sourceImageUrl ||
initialSourceImageUrl ||
"";
setSourceImageUrl((current) => current || nextSource); setSourceImageUrl((current) => current || nextSource);
const nextHistory = normalizeHistory(persistedState?.history); setHistory((currentHistory) =>
setHistory(nextHistory); mergeStyleProHistory(
setCurrentResult((current) => current || nextHistory[0] || null); currentHistory,
}, [initialSourceImageUrl, persistedState?.history, persistedState?.sourceImageUrl]); 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 siteName = brandingData?.siteName || "由壹点AI";
const logo = brandingData?.siteLogoDark || brandingData?.siteLogo || ""; const logo = brandingData?.siteLogoDark || brandingData?.siteLogo || "";
@ -414,6 +568,22 @@ export function StyleProWorkspace({
return labels; return labels;
}, [enabledCategories, selections]); }, [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( const missingRequiredCategories = useMemo(
() => () =>
enabledCategories.filter((category) => { enabledCategories.filter((category) => {
@ -429,12 +599,14 @@ export function StyleProWorkspace({
const persistState = useCallback( const persistState = useCallback(
async (nextState: StyleProPersistedState) => { async (nextState: StyleProPersistedState) => {
await onPersistState?.({ const normalizedState = {
sourceImageUrl: nextState.sourceImageUrl, sourceImageUrl: nextState.sourceImageUrl,
history: normalizeHistory(nextState.history), history: normalizeHistory(nextState.history),
}); };
writeLocalStyleProState(canvasId, normalizedState);
await onPersistState?.(normalizedState);
}, },
[onPersistState] [canvasId, onPersistState]
); );
const updateSourceImage = useCallback( const updateSourceImage = useCallback(
@ -515,6 +687,7 @@ export function StyleProWorkspace({
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
canvasId, canvasId,
modelId: selectedModel?.id || selectedModelId || undefined,
sourceImageUrl, sourceImageUrl,
selections, selections,
}), }),
@ -538,10 +711,10 @@ export function StyleProWorkspace({
width: dimensions.width, width: dimensions.width,
height: dimensions.height, height: dimensions.height,
}; };
const nextHistory = [item, ...history.filter((entry) => entry.id !== item.id)].slice( const nextHistory = [
0, item,
4 ...history.filter((entry) => entry.id !== item.id),
); ].slice(0, 4);
setHistory(nextHistory); setHistory(nextHistory);
setCurrentResult(item); setCurrentResult(item);
await persistState({ sourceImageUrl, history: nextHistory }); await persistState({ sourceImageUrl, history: nextHistory });
@ -558,6 +731,8 @@ export function StyleProWorkspace({
missingRequiredCategories, missingRequiredCategories,
persistState, persistState,
selectedOptionLabels, selectedOptionLabels,
selectedModel?.id,
selectedModelId,
selections, selections,
sourceImageUrl, sourceImageUrl,
]); ]);
@ -570,9 +745,11 @@ export function StyleProWorkspace({
const disabledReason = !sourceImageUrl const disabledReason = !sourceImageUrl
? "请先上传图片" ? "请先上传图片"
: missingRequiredCategories.length > 0 : modelsData?.success === true && modelOptions.length === 0
? `还差:${missingRequiredCategories[0].title}` ? "暂无可用生图模型"
: ""; : missingRequiredCategories.length > 0
? `还差:${missingRequiredCategories[0].title}`
: "";
const promptDisplayMode = config?.promptDisplayMode || "stacked"; const promptDisplayMode = config?.promptDisplayMode || "stacked";
const renderPromptCategory = ( const renderPromptCategory = (
category: PublicStyleProCategory, category: PublicStyleProCategory,
@ -762,23 +939,69 @@ export function StyleProWorkspace({
生成后拖动滑块查看原图与结果图。 生成后拖动滑块查看原图与结果图。
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-2"> <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-[210px] bg-transparent font-semibold text-black/75 outline-none"
disabled={
modelsLoading ||
modelOptions.length === 0 ||
generating
}
onChange={(event) =>
setSelectedModelId(event.target.value)
}
value={selectedModel?.id || selectedModelId}
>
{modelOptions.length > 0 ? (
modelOptions.map((model) => (
<option key={model.id} value={model.id}>
{model.displayName} ·{" "}
{model.unitCost > 0
? `${model.unitCost}积分/张`
: "未设置积分"}
</option>
))
) : (
<option value="">
{modelsLoading ? "加载模型" : "暂无模型"}
</option>
)}
</select>
<span className="rounded-full bg-black/[0.04] px-2 py-0.5 font-semibold text-black/55">
{selectedModelCostLabel}
</span>
{selectedModel?.creditDiscountLabel ? (
<span className="rounded-full bg-emerald-50 px-2 py-0.5 font-semibold text-emerald-600">
{selectedModel.creditDiscountLabel}
</span>
) : null}
</div>
<Button <Button
className="h-8 rounded-[14px] bg-[#1c1c1e] px-4 text-[12px] text-white hover:bg-black" className="h-8 rounded-[14px] bg-[#1c1c1e] px-4 text-[12px] text-white hover:bg-black"
disabled={generating || Boolean(disabledReason)} disabled={generating || Boolean(disabledReason)}
onClick={handleGenerate} onClick={handleGenerate}
title={disabledReason || selectedModelCostLabel}
> >
{generating ? ( {generating ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : ( ) : (
<Sparkles className="mr-1.5 h-3.5 w-3.5" /> <Sparkles className="mr-1.5 h-3.5 w-3.5" />
)} )}
{generating ? "正在创建图片" : "执行"} {generating
? "正在创建图片"
: selectedModel?.unitCost
? `执行 · ${selectedModel.unitCost}积分`
: "执行"}
</Button> </Button>
<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" 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} disabled={!currentResult}
onClick={() => currentResult && onAddToCanvas?.(currentResult)} onClick={() =>
currentResult && onAddToCanvas?.(currentResult)
}
variant="outline" variant="outline"
> >
添加到画布 添加到画布
@ -821,10 +1044,13 @@ export function StyleProWorkspace({
<div className="space-y-1 p-2"> <div className="space-y-1 p-2">
<div className="flex items-center gap-1 text-[11px] text-gray-400"> <div className="flex items-center gap-1 text-[11px] text-gray-400">
<Clock3 className="h-3 w-3" /> <Clock3 className="h-3 w-3" />
{new Date(item.createdAt).toLocaleTimeString("zh-CN", { {new Date(item.createdAt).toLocaleTimeString(
hour: "2-digit", "zh-CN",
minute: "2-digit", {
})} hour: "2-digit",
minute: "2-digit",
}
)}
</div> </div>
<div className="line-clamp-1 text-[12px] text-black/70"> <div className="line-clamp-1 text-[12px] text-black/70">
{item.optionLabels[0] || "风格化结果"} {item.optionLabels[0] || "风格化结果"}

View File

@ -0,0 +1,92 @@
import "server-only";
import type { DiscountableImageGenConfigLike } from "@/lib/ai/image-model-pricing";
import {
getImageCreditCostFromConfig,
getImageModelCreditDiscountLabel,
} from "@/lib/ai/image-model-pricing";
import { isImageGenerationModelDescriptor } from "@/lib/ai/model-classification";
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
const STYLE_PRO_SUPPORTED_PROVIDER_TYPES = new Set([
"gemini",
"grsai-gpt-image",
"alibaba-image",
"bfl-image",
"ruxa-image",
"fal-image",
"openai-image",
"stepfun-image",
"midjourney",
"runway",
"krea",
]);
export type StyleProModelEntry = Awaited<
ReturnType<typeof getEnabledAiModelsWithProvider>
>[number];
export type PublicStyleProModelOption = {
id: string;
modelId: string;
displayName: string;
avatarUrl: string | null;
providerName: string;
providerType: string;
isDefault: boolean;
unitCost: number;
creditDiscountLabel: string | null;
};
export function isStyleProImageModel(entry: StyleProModelEntry) {
const { model, provider } = entry;
if (!model.supportsImageGen) return false;
if (!STYLE_PRO_SUPPORTED_PROVIDER_TYPES.has(provider.type)) return false;
return isImageGenerationModelDescriptor({
providerType: provider.type,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
});
}
export async function getStyleProImageModelEntries(options?: {
includeHidden?: boolean;
}) {
const models = await getEnabledAiModelsWithProvider(
false,
options?.includeHidden === true
);
return models.filter(isStyleProImageModel);
}
export function findStyleProImageModelByRef(
entries: StyleProModelEntry[],
modelRef?: string | null
) {
const normalized = String(modelRef || "").trim();
if (!normalized) return null;
return (
entries.find(
({ model }) => model.id === normalized || model.modelId === normalized
) || null
);
}
export function toPublicStyleProModelOption(
entry: StyleProModelEntry
): PublicStyleProModelOption {
const imageGenConfig = (entry.model.imageGenConfig ||
null) as DiscountableImageGenConfigLike | null;
return {
id: entry.model.id,
modelId: entry.model.modelId,
displayName: entry.model.displayName,
avatarUrl: entry.model.avatarUrl || null,
providerName: entry.provider.name,
providerType: entry.provider.type,
isDefault: entry.model.isDefault,
unitCost: getImageCreditCostFromConfig(imageGenConfig),
creditDiscountLabel: getImageModelCreditDiscountLabel(imageGenConfig),
};
}