"use client";
import Link from "next/link";
import { ArrowLeft, Plus, Save, Trash2, Wand2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { fetcher } from "@/lib/utils";
import {
STYLE_PRO_CONFIG_KEY,
defaultStyleProConfig,
normalizeStyleProConfig,
type StyleProCategory,
type StyleProConfig,
type StyleProCustomField,
type StyleProCustomFieldType,
type StyleProOption,
} from "@/lib/style-pro/config";
type SettingsResponse = {
setting?: {
value?: unknown;
};
};
type AiModelOption = {
id: string;
modelId: string;
displayName: string;
supportsImageGen?: boolean;
isEnabled?: boolean;
isHidden?: boolean;
};
type AiModelsResponse = {
models?: AiModelOption[];
};
const AUTO_MODEL_VALUE = "__auto__";
function getId(prefix: string) {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return `${prefix}-${crypto.randomUUID().slice(0, 8)}`;
}
return `${prefix}-${Date.now().toString(36)}`;
}
function reindexCategories(categories: StyleProCategory[]) {
return categories.map((category, index) => ({
...category,
order: (index + 1) * 10,
customFields: reindexCustomFields(category.customFields || []),
options: category.options.map((option, optionIndex) => ({
...option,
order: (optionIndex + 1) * 10,
})),
}));
}
function reindexCustomFields(fields: StyleProCustomField[]) {
return fields.map((field, index) => ({
...field,
order: (index + 1) * 10,
}));
}
function createCategory(): StyleProCategory {
const id = getId("category");
return {
id,
title: "新分类",
description: "",
required: true,
minSelections: 1,
maxSelections: 1,
enabled: true,
order: 999,
customFields: [],
options: [createOption(id)],
};
}
function createCustomField(prefix = "custom"): StyleProCustomField {
return {
id: getId(`${prefix}-field`),
label: "自定义要求",
description: "",
type: "text",
placeholder: "请输入自定义要求...",
defaultValue: "",
promptTemplate: "{{fieldLabel}}:{{value}}",
required: false,
enabled: true,
order: 999,
maxLength: 160,
};
}
function createOption(categoryId: string): StyleProOption {
return {
id: getId("option"),
label: "新按钮",
description: "",
prompt: "请填写这个按钮对应的隐藏提示词。",
jsonKey: categoryId,
enabled: true,
order: 999,
};
}
function modelLabel(model: AiModelOption) {
const name = model.displayName || model.modelId || model.id;
const id = model.modelId && model.modelId !== name ? ` · ${model.modelId}` : "";
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 (
);
}
function CustomFieldEditor({
field,
onChange,
onRemove,
}: {
field: StyleProCustomField;
onChange: (updates: Partial) => void;
onRemove: () => void;
}) {
const fieldTypeOptions: Array<{ value: StyleProCustomFieldType; label: string }> = [
{ value: "text", label: "单行文本" },
{ value: "textarea", label: "多行文本" },
{ value: "color", label: "颜色选择" },
];
return (
{field.label || "未命名自定义项"}
{field.type === "color"
? "颜色选择"
: field.type === "textarea"
? "多行文本"
: "单行文本"}
onChange({ enabled })}
/>
);
}
export function StyleProSettingsClient() {
const { data, mutate } = useSWR(
`/api/admin/settings?key=${STYLE_PRO_CONFIG_KEY}`,
fetcher
);
const { data: modelsData } = useSWR(
"/api/admin/ai-models",
fetcher
);
const [config, setConfig] = useState(defaultStyleProConfig);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!data) return;
setConfig(normalizeStyleProConfig(data.setting?.value));
}, [data]);
const allImageModels = useMemo(
() =>
(modelsData?.models || []).filter(
(model) => model.isEnabled !== false && model.supportsImageGen
),
[modelsData?.models]
);
const visibleImageModels = useMemo(
() =>
allImageModels.filter((model) => model.isHidden !== true),
[allImageModels]
);
const updateConfig = (updates: Partial) => {
setConfig((current) => ({ ...current, ...updates }));
};
const updateCategory = (
categoryId: string,
updates: Partial
) => {
setConfig((current) => ({
...current,
categories: current.categories.map((category) =>
category.id === categoryId ? { ...category, ...updates } : category
),
}));
};
const updateOption = (
categoryId: string,
optionId: string,
updates: Partial
) => {
setConfig((current) => ({
...current,
categories: current.categories.map((category) =>
category.id === categoryId
? {
...category,
options: category.options.map((option) =>
option.id === optionId ? { ...option, ...updates } : option
),
}
: category
),
}));
};
const addGlobalCustomField = () => {
setConfig((current) => ({
...current,
customFields: reindexCustomFields([
...(current.customFields || []),
createCustomField("global"),
]),
}));
};
const updateGlobalCustomField = (
fieldId: string,
updates: Partial
) => {
setConfig((current) => ({
...current,
customFields: (current.customFields || []).map((field) =>
field.id === fieldId ? { ...field, ...updates } : field
),
}));
};
const removeGlobalCustomField = (fieldId: string) => {
setConfig((current) => ({
...current,
customFields: reindexCustomFields(
(current.customFields || []).filter((field) => field.id !== fieldId)
),
}));
};
const addCategoryCustomField = (categoryId: string) => {
setConfig((current) => ({
...current,
categories: current.categories.map((category) =>
category.id === categoryId
? {
...category,
customFields: reindexCustomFields([
...(category.customFields || []),
createCustomField(category.id),
]),
}
: category
),
}));
};
const updateCategoryCustomField = (
categoryId: string,
fieldId: string,
updates: Partial
) => {
setConfig((current) => ({
...current,
categories: current.categories.map((category) =>
category.id === categoryId
? {
...category,
customFields: (category.customFields || []).map((field) =>
field.id === fieldId ? { ...field, ...updates } : field
),
}
: category
),
}));
};
const removeCategoryCustomField = (categoryId: string, fieldId: string) => {
setConfig((current) => ({
...current,
categories: current.categories.map((category) =>
category.id === categoryId
? {
...category,
customFields: reindexCustomFields(
(category.customFields || []).filter((field) => field.id !== fieldId)
),
}
: category
),
}));
};
const addCategory = () => {
setConfig((current) => ({
...current,
categories: reindexCategories([...current.categories, createCategory()]),
}));
};
const removeCategory = (categoryId: string) => {
setConfig((current) => ({
...current,
categories: reindexCategories(
current.categories.filter((category) => category.id !== categoryId)
),
}));
};
const moveCategory = (categoryId: string, direction: -1 | 1) => {
setConfig((current) => {
const categories = [...current.categories];
const index = categories.findIndex((category) => category.id === categoryId);
const nextIndex = index + direction;
if (index < 0 || nextIndex < 0 || nextIndex >= categories.length) {
return current;
}
const [category] = categories.splice(index, 1);
categories.splice(nextIndex, 0, category);
return { ...current, categories: reindexCategories(categories) };
});
};
const addOption = (categoryId: string) => {
setConfig((current) => ({
...current,
categories: current.categories.map((category) =>
category.id === categoryId
? {
...category,
options: [
...category.options,
{
...createOption(category.id),
order: (category.options.length + 1) * 10,
},
],
}
: category
),
}));
};
const removeOption = (categoryId: string, optionId: string) => {
setConfig((current) => ({
...current,
categories: current.categories.map((category) =>
category.id === categoryId
? {
...category,
options: category.options.filter((option) => option.id !== optionId),
}
: category
),
}));
};
const save = async () => {
setSaving(true);
try {
const normalized = normalizeStyleProConfig(config);
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: STYLE_PRO_CONFIG_KEY,
value: normalized,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.message || payload?.error || "保存失败");
}
setConfig(normalized);
await mutate();
toast.success("风格化 PRO 配置已保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
} finally {
setSaving(false);
}
};
return (
风格化 PRO 设置
配置无限画布里的风格化 PRO 子页面。用户只看到分类和按钮名称,隐藏提示词和模型只在服务端生成时使用。
基础设置
控制功能开关、风格化公开模型、实景化模型链路、默认提示词和生成规格。
关闭后前台页面会提示管理员暂未启用,生成接口也会拒绝执行。
updateConfig({ enabled })}
/>
前台只显示“实景化”模式,不展示模型名称;第一阶段只生成中间图,不保存到个人相册;
第二阶段最终图会保存到个人相册。两阶段会严格使用这里指定的模型,第一阶段失败时第二阶段不会继续执行。
左右滑动模式会把多个提示词分类收纳到同一行横向滑动区域,减少前台页面高度占用。
控制风格化 PRO 页面里的分类标题、提示词按钮和选中按钮颜色,保存后前台立即按配置渲染。
updateConfig({ titleTextColor })}
value={config.titleTextColor}
/>
updateConfig({ buttonBackgroundColor })
}
value={config.buttonBackgroundColor}
/>
updateConfig({ buttonTextColor })
}
value={config.buttonTextColor}
/>
updateConfig({ selectedButtonBackgroundColor })
}
value={config.selectedButtonBackgroundColor}
/>
updateConfig({ selectedButtonTextColor })
}
value={config.selectedButtonTextColor}
/>
适合墙面颜色、顶面颜色、材质说明等全局参数。前台会自动渲染,后台模板决定如何写入隐藏提示词。
{(config.customFields || []).length > 0 ? (
{(config.customFields || []).map((field) => (
updateGlobalCustomField(field.id, updates)
}
onRemove={() => removeGlobalCustomField(field.id)}
/>
))}
) : (
暂未配置全局自定义项。需要墙面/页面调色时,可添加“颜色选择”字段。
)}
分类与按钮
分类和按钮会按顺序显示在前台,按钮提示词只在服务端参与生成。
{config.categories.map((category, categoryIndex) => (
{categoryIndex + 1}
{category.title || "未命名分类"}
{category.required ? "必填分类" : "选填分类"} · {category.options.length} 个按钮
分类自定义输入项
用户可在这个分类下补充自定义要求,例如颜色、材质、光感或特殊说明。
{(category.customFields || []).length > 0 ? (
{(category.customFields || []).map((field) => (
updateCategoryCustomField(category.id, field.id, updates)
}
onRemove={() =>
removeCategoryCustomField(category.id, field.id)
}
/>
))}
) : (
这个分类暂未配置自定义输入项。
)}
按钮配置
{category.options.map((option) => (
{option.label || "未命名按钮"}
updateOption(category.id, option.id, { enabled })
}
/>
updateOption(category.id, option.id, {
label: event.target.value,
})
}
value={option.label}
/>
updateOption(category.id, option.id, {
id: event.target.value,
})
}
value={option.id}
/>
updateOption(category.id, option.id, {
jsonKey: event.target.value,
})
}
placeholder={category.id}
value={option.jsonKey || ""}
/>
updateOption(category.id, option.id, {
description: event.target.value,
})
}
value={option.description || ""}
/>
))}
))}
);
}