feat: customize style pro button colors
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-17 23:38:12 +08:00
parent 0869eefee3
commit c433b714cd
7 changed files with 166 additions and 66 deletions

View File

@ -106,6 +106,47 @@ function modelLabel(model: AiModelOption) {
return `${name}${id}${model.isHidden ? " · 前端隐藏" : ""}`;
}
function ColorSettingField({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (value: string) => void;
}) {
const shortHex = value.match(/^#([0-9a-fA-F]{3})$/);
const pickerValue = /^#[0-9a-fA-F]{6}$/.test(value)
? value
: shortHex
? `#${shortHex[1]
.split("")
.map((char) => `${char}${char}`)
.join("")}`
: "#000000";
return (
<div className="space-y-2">
<Label>{label}</Label>
<div className="flex gap-2">
<Input
className="h-10 w-14 shrink-0 cursor-pointer p-1"
onChange={(event) => onChange(event.target.value)}
type="color"
value={pickerValue}
/>
<Input
className="font-mono"
maxLength={7}
onChange={(event) => onChange(event.target.value)}
placeholder="#2563eb"
value={value}
/>
</div>
</div>
);
}
export function StyleProSettingsClient() {
const { data, mutate } = useSWR<SettingsResponse>(
`/api/admin/settings?key=${STYLE_PRO_CONFIG_KEY}`,
@ -135,11 +176,6 @@ export function StyleProSettingsClient() {
allImageModels.filter((model) => model.isHidden !== true),
[allImageModels]
);
const hiddenImageModels = useMemo(
() => allImageModels.filter((model) => model.isHidden === true),
[allImageModels]
);
const updateConfig = (updates: Partial<StyleProConfig>) => {
setConfig((current) => ({ ...current, ...updates }));
};
@ -304,7 +340,7 @@ export function StyleProSettingsClient() {
<CardHeader>
<CardTitle>基础设置</CardTitle>
<CardDescription>
控制功能开关、风格化公开模型、实景化隐藏模型、默认提示词和生成规格。
控制功能开关、风格化公开模型、实景化模型链路、默认提示词和生成规格。
</CardDescription>
</CardHeader>
<CardContent className="grid gap-5 md:grid-cols-2">
@ -356,9 +392,9 @@ export function StyleProSettingsClient() {
<div className="space-y-4 rounded-xl border p-4 md:col-span-2">
<div>
<Label>实景化隐藏模型链路</Label>
<Label>实景化模型链路</Label>
<p className="mt-1 text-muted-foreground text-xs">
前台只显示“实景化”模式,不展示模型名称;这里仅可选择已在 AI 模型中设置为“前端隐藏”的图片模型。第一阶段失败时第二阶段不会继续执行。
前台只显示“实景化”模式,不展示模型名称;这里可选择所有已启用的生图模型。第一阶段失败时第二阶段不会继续执行。
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
@ -379,16 +415,16 @@ export function StyleProSettingsClient() {
<SelectContent>
<SelectItem value={AUTO_MODEL_VALUE}>未配置</SelectItem>
{config.realisticFirstModelId &&
!hiddenImageModels.some(
!allImageModels.some(
(model) =>
model.modelId === config.realisticFirstModelId ||
model.id === config.realisticFirstModelId
) ? (
<SelectItem value={config.realisticFirstModelId}>
当前保存值(未在隐藏模型列表中):{config.realisticFirstModelId}
当前保存值(未在模型列表中):{config.realisticFirstModelId}
</SelectItem>
) : null}
{hiddenImageModels.map((model) => {
{allImageModels.map((model) => {
const value = model.modelId || model.id;
return (
<SelectItem key={model.id} value={value}>
@ -416,16 +452,16 @@ export function StyleProSettingsClient() {
<SelectContent>
<SelectItem value={AUTO_MODEL_VALUE}>未配置</SelectItem>
{config.realisticSecondModelId &&
!hiddenImageModels.some(
!allImageModels.some(
(model) =>
model.modelId === config.realisticSecondModelId ||
model.id === config.realisticSecondModelId
) ? (
<SelectItem value={config.realisticSecondModelId}>
当前保存值(未在隐藏模型列表中):{config.realisticSecondModelId}
当前保存值(未在模型列表中):{config.realisticSecondModelId}
</SelectItem>
) : null}
{hiddenImageModels.map((model) => {
{allImageModels.map((model) => {
const value = model.modelId || model.id;
return (
<SelectItem key={model.id} value={value}>
@ -523,6 +559,50 @@ export function StyleProSettingsClient() {
</p>
</div>
<div className="space-y-4 rounded-xl border p-4 md:col-span-2">
<div>
<Label>前台颜色</Label>
<p className="mt-1 text-muted-foreground text-xs">
控制风格化 PRO 页面里的分类标题、提示词按钮和选中按钮颜色,保存后前台立即按配置渲染。
</p>
</div>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
<ColorSettingField
label="标题文字颜色"
onChange={(titleTextColor) => updateConfig({ titleTextColor })}
value={config.titleTextColor}
/>
<ColorSettingField
label="按钮颜色"
onChange={(buttonBackgroundColor) =>
updateConfig({ buttonBackgroundColor })
}
value={config.buttonBackgroundColor}
/>
<ColorSettingField
label="按钮字体颜色"
onChange={(buttonTextColor) =>
updateConfig({ buttonTextColor })
}
value={config.buttonTextColor}
/>
<ColorSettingField
label="选中按钮颜色"
onChange={(selectedButtonBackgroundColor) =>
updateConfig({ selectedButtonBackgroundColor })
}
value={config.selectedButtonBackgroundColor}
/>
<ColorSettingField
label="选中按钮字体颜色"
onChange={(selectedButtonTextColor) =>
updateConfig({ selectedButtonTextColor })
}
value={config.selectedButtonTextColor}
/>
</div>
</div>
<div className="space-y-2 md:col-span-2">
<Label>默认系统提示词</Label>
<Textarea

View File

@ -18,11 +18,11 @@ export async function GET() {
() => null
);
const config = normalizeStyleProConfig(setting?.value);
const hiddenEntries = (
const realisticModelEntries = (
await getStyleProImageModelEntries({ includeHidden: true }).catch(
() => []
)
).filter((entry) => entry.model.isHidden === true);
);
const publicConfig = toPublicStyleProConfig(config);
return NextResponse.json({
@ -31,11 +31,11 @@ export async function GET() {
...publicConfig,
realisticModeAvailable: Boolean(
findStyleProImageModelByRef(
hiddenEntries,
realisticModelEntries,
config.realisticFirstModelId
) &&
findStyleProImageModelByRef(
hiddenEntries,
realisticModelEntries,
config.realisticSecondModelId
)
),

View File

@ -202,13 +202,9 @@ export async function POST(request: Request) {
);
}
const imageModelEntries = (
await getStyleProImageModelEntries({
includeHidden: generationMode === "realistic",
})
).filter((entry) =>
generationMode === "realistic" ? entry.model.isHidden === true : true
);
const imageModelEntries = await getStyleProImageModelEntries({
includeHidden: generationMode === "realistic",
});
const selectedModels =
generationMode === "realistic"

View File

@ -24,14 +24,11 @@ export async function GET() {
);
}
const [entries, allHiddenCapableEntries, setting] = await Promise.all([
const [entries, realisticModelEntries, setting] = await Promise.all([
getStyleProImageModelEntries({ includeHidden: false }),
getStyleProImageModelEntries({ includeHidden: true }),
getSystemSetting({ key: STYLE_PRO_CONFIG_KEY }).catch(() => null),
]);
const hiddenEntries = allHiddenCapableEntries.filter(
(entry) => entry.model.isHidden === true
);
const config = normalizeStyleProConfig(setting?.value);
const configuredDefault = findStyleProImageModelByRef(
entries,
@ -43,11 +40,11 @@ export async function GET() {
entries[0] ||
null;
const realisticFirstModel = findStyleProImageModelByRef(
hiddenEntries,
realisticModelEntries,
config.realisticFirstModelId
);
const realisticSecondModel = findStyleProImageModelByRef(
hiddenEntries,
realisticModelEntries,
config.realisticSecondModelId
);
const realisticUnitCost =

View File

@ -3,6 +3,7 @@
import { Clock3, Loader2, LogOut, Sparkles, Upload } from "lucide-react";
import { signOut, useSession } from "next-auth/react";
import {
type CSSProperties,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
useCallback,
@ -1014,6 +1015,15 @@ export function StyleProWorkspace({
? `还差:${missingRequiredCategories[0].title}`
: "";
const promptDisplayMode = config?.promptDisplayMode || "stacked";
const themeStyle = {
"--style-pro-title-color": config?.titleTextColor || "#2563eb",
"--style-pro-button-bg": config?.buttonBackgroundColor || "#ffffff",
"--style-pro-button-text": config?.buttonTextColor || "#374151",
"--style-pro-selected-button-bg":
config?.selectedButtonBackgroundColor || "#eff6ff",
"--style-pro-selected-button-text":
config?.selectedButtonTextColor || "#2563eb",
} as CSSProperties & Record<string, string>;
const renderPromptCategory = (
category: PublicStyleProCategory,
layout: "stacked" | "carousel" = "stacked"
@ -1057,7 +1067,10 @@ export function StyleProWorkspace({
};
return (
<div className="style-pro-index-shell flex h-dvh w-full overflow-hidden bg-[#f4f4f4] text-[#171717]">
<div
className="style-pro-index-shell flex h-dvh w-full overflow-hidden bg-[#f4f4f4] text-[#171717]"
style={themeStyle}
>
<style>{`
.style-pro-index-shell .section-card {
background: #fff;
@ -1066,27 +1079,27 @@ export function StyleProWorkspace({
box-shadow: 0 12px 35px rgba(15, 23, 42, 0.05);
}
.style-pro-index-shell .subsection-title {
color: #2563eb;
color: var(--style-pro-title-color);
font-size: 13px;
font-weight: 650;
white-space: nowrap;
}
.style-pro-index-shell .option-btn {
border: 1px solid rgba(0, 0, 0, 0.1);
background: #fff;
color: rgba(17, 24, 39, 0.78);
background: var(--style-pro-button-bg);
color: var(--style-pro-button-text);
transition: all 0.18s ease;
}
.style-pro-index-shell .option-btn:hover {
background: #1c1c1e;
border-color: #1c1c1e;
color: #fff;
background: var(--style-pro-selected-button-bg);
border-color: var(--style-pro-selected-button-text);
color: var(--style-pro-selected-button-text);
}
.style-pro-index-shell .option-btn.active {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.15), rgba(37, 99, 235, 0.05));
color: #2563eb;
border-color: #60a5fa;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
background: var(--style-pro-selected-button-bg);
color: var(--style-pro-selected-button-text);
border-color: var(--style-pro-selected-button-text);
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12);
}
.style-pro-index-shell .prompt-carousel-scroll {
scrollbar-width: thin;

View File

@ -32,6 +32,11 @@ export type StyleProConfig = {
realisticSecondModelId: string;
realisticFirstPrompt: string;
realisticSecondPrompt: string;
titleTextColor: string;
buttonBackgroundColor: string;
buttonTextColor: string;
selectedButtonBackgroundColor: string;
selectedButtonTextColor: string;
defaultPrompt: string;
negativePrompt: string;
aspectRatio: string;
@ -436,6 +441,11 @@ export const defaultStyleProConfig: StyleProConfig = {
realisticSecondModelId: "",
realisticFirstPrompt: DEFAULT_REALISTIC_FIRST_PROMPT,
realisticSecondPrompt: DEFAULT_REALISTIC_SECOND_PROMPT,
titleTextColor: "#2563eb",
buttonBackgroundColor: "#ffffff",
buttonTextColor: "#374151",
selectedButtonBackgroundColor: "#eff6ff",
selectedButtonTextColor: "#2563eb",
defaultPrompt: DEFAULT_PROMPT,
negativePrompt: "低清晰度,畸变,文字乱码,主体变形,产品结构错误,多余物体,水印",
aspectRatio: "auto",
@ -455,6 +465,11 @@ function cleanString(value: unknown, fallback = "", maxLength = 4000) {
return (text || fallback).slice(0, maxLength);
}
function cleanHexColor(value: unknown, fallback: string) {
const text = cleanString(value, fallback, 32);
return /^#(?:[0-9a-fA-F]{3}){1,2}$/.test(text) ? text : fallback;
}
function cleanId(value: unknown, fallback: string) {
const text = cleanString(value, fallback, 80)
.toLowerCase()
@ -563,6 +578,23 @@ export function normalizeStyleProConfig(value: unknown): StyleProConfig {
fallback.realisticSecondPrompt,
8000
),
titleTextColor: cleanHexColor(raw.titleTextColor, fallback.titleTextColor),
buttonBackgroundColor: cleanHexColor(
raw.buttonBackgroundColor,
fallback.buttonBackgroundColor
),
buttonTextColor: cleanHexColor(
raw.buttonTextColor,
fallback.buttonTextColor
),
selectedButtonBackgroundColor: cleanHexColor(
raw.selectedButtonBackgroundColor,
fallback.selectedButtonBackgroundColor
),
selectedButtonTextColor: cleanHexColor(
raw.selectedButtonTextColor,
fallback.selectedButtonTextColor
),
defaultPrompt: cleanString(raw.defaultPrompt, fallback.defaultPrompt, 8000),
negativePrompt: cleanString(raw.negativePrompt, fallback.negativePrompt, 4000),
aspectRatio: cleanString(raw.aspectRatio, fallback.aspectRatio, 32),
@ -637,6 +669,11 @@ export function toPublicStyleProConfig(
aspectRatio: config.aspectRatio,
imageSize: config.imageSize,
promptDisplayMode: config.promptDisplayMode,
titleTextColor: config.titleTextColor,
buttonBackgroundColor: config.buttonBackgroundColor,
buttonTextColor: config.buttonTextColor,
selectedButtonBackgroundColor: config.selectedButtonBackgroundColor,
selectedButtonTextColor: config.selectedButtonTextColor,
realisticModeAvailable: Boolean(
config.realisticFirstModelId && config.realisticSecondModelId
),

View File

@ -5,23 +5,8 @@ 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];
@ -39,15 +24,7 @@ export type PublicStyleProModelOption = {
};
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,
});
return Boolean(entry.model.supportsImageGen);
}
export async function getStyleProImageModelEntries(options?: {