1200 lines
42 KiB
TypeScript
1200 lines
42 KiB
TypeScript
"use client";
|
||
|
||
import { Plus, Save, Trash2, Wand2 } from "lucide-react";
|
||
import { useRouter } from "next/navigation";
|
||
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 { AdminSettingsNavigation } from "../_components/admin-settings-navigation";
|
||
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 (
|
||
<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>
|
||
);
|
||
}
|
||
|
||
function CustomFieldEditor({
|
||
field,
|
||
onChange,
|
||
onRemove,
|
||
}: {
|
||
field: StyleProCustomField;
|
||
onChange: (updates: Partial<StyleProCustomField>) => void;
|
||
onRemove: () => void;
|
||
}) {
|
||
const fieldTypeOptions: Array<{ value: StyleProCustomFieldType; label: string }> = [
|
||
{ value: "text", label: "单行文本" },
|
||
{ value: "textarea", label: "多行文本" },
|
||
{ value: "color", label: "颜色选择" },
|
||
];
|
||
|
||
return (
|
||
<div className="rounded-lg border bg-muted/20 p-4">
|
||
<div className="mb-3 flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="font-medium text-sm">{field.label || "未命名自定义项"}</div>
|
||
<div className="text-muted-foreground text-xs">
|
||
{field.type === "color"
|
||
? "颜色选择"
|
||
: field.type === "textarea"
|
||
? "多行文本"
|
||
: "单行文本"}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Switch
|
||
checked={field.enabled}
|
||
onCheckedChange={(enabled) => onChange({ enabled })}
|
||
/>
|
||
<Button onClick={onRemove} size="sm" variant="ghost">
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label>前台名称</Label>
|
||
<Input
|
||
onChange={(event) => onChange({ label: event.target.value })}
|
||
value={field.label}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>字段 ID</Label>
|
||
<Input
|
||
onChange={(event) => onChange({ id: event.target.value })}
|
||
value={field.id}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>字段类型</Label>
|
||
<Select
|
||
onValueChange={(value) =>
|
||
onChange({ type: value as StyleProCustomFieldType })
|
||
}
|
||
value={field.type}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{fieldTypeOptions.map((option) => (
|
||
<SelectItem key={option.value} value={option.value}>
|
||
{option.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>最大长度</Label>
|
||
<Input
|
||
min={1}
|
||
onChange={(event) =>
|
||
onChange({ maxLength: Number(event.target.value) || 160 })
|
||
}
|
||
type="number"
|
||
value={field.maxLength || (field.type === "textarea" ? 800 : 160)}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>默认值</Label>
|
||
<div className="flex gap-2">
|
||
{field.type === "color" ? (
|
||
<Input
|
||
className="h-10 w-14 shrink-0 cursor-pointer p-1"
|
||
onChange={(event) => onChange({ defaultValue: event.target.value })}
|
||
type="color"
|
||
value={
|
||
/^#[0-9a-fA-F]{6}$/.test(field.defaultValue || "")
|
||
? field.defaultValue
|
||
: "#ffffff"
|
||
}
|
||
/>
|
||
) : null}
|
||
<Input
|
||
className={field.type === "color" ? "font-mono" : undefined}
|
||
maxLength={field.type === "color" ? 7 : undefined}
|
||
onChange={(event) => onChange({ defaultValue: event.target.value })}
|
||
placeholder={field.type === "color" ? "#FFFFFF" : "可留空"}
|
||
value={field.defaultValue || ""}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>占位提示</Label>
|
||
<Input
|
||
onChange={(event) => onChange({ placeholder: event.target.value })}
|
||
placeholder="请输入自定义要求..."
|
||
value={field.placeholder || ""}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2 md:col-span-2">
|
||
<Label>说明</Label>
|
||
<Input
|
||
onChange={(event) => onChange({ description: event.target.value })}
|
||
value={field.description || ""}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2 md:col-span-2">
|
||
<Label>隐藏提示词模板</Label>
|
||
<Textarea
|
||
className="min-h-[86px]"
|
||
onChange={(event) => onChange({ promptTemplate: event.target.value })}
|
||
placeholder="{{fieldLabel}}:{{value}}"
|
||
value={field.promptTemplate || ""}
|
||
/>
|
||
<p className="text-muted-foreground text-xs">
|
||
可用变量:{"{{value}}"}、{"{{fieldLabel}}"}、{"{{categoryTitle}}"}。
|
||
</p>
|
||
</div>
|
||
<label className="flex items-center justify-between rounded-lg border bg-background p-3 md:col-span-2">
|
||
<span className="text-sm">前台必填</span>
|
||
<Switch
|
||
checked={field.required}
|
||
onCheckedChange={(required) => onChange({ required })}
|
||
/>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function StyleProSettingsClient() {
|
||
const router = useRouter();
|
||
const { data, mutate } = useSWR<SettingsResponse>(
|
||
`/api/admin/settings?key=${STYLE_PRO_CONFIG_KEY}`,
|
||
fetcher
|
||
);
|
||
const { data: modelsData } = useSWR<AiModelsResponse>(
|
||
"/api/admin/ai-models",
|
||
fetcher
|
||
);
|
||
const [config, setConfig] = useState<StyleProConfig>(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<StyleProConfig>) => {
|
||
setConfig((current) => ({ ...current, ...updates }));
|
||
};
|
||
|
||
const updateCategory = (
|
||
categoryId: string,
|
||
updates: Partial<StyleProCategory>
|
||
) => {
|
||
setConfig((current) => ({
|
||
...current,
|
||
categories: current.categories.map((category) =>
|
||
category.id === categoryId ? { ...category, ...updates } : category
|
||
),
|
||
}));
|
||
};
|
||
|
||
const updateOption = (
|
||
categoryId: string,
|
||
optionId: string,
|
||
updates: Partial<StyleProOption>
|
||
) => {
|
||
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<StyleProCustomField>
|
||
) => {
|
||
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<StyleProCustomField>
|
||
) => {
|
||
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 (
|
||
<div className="settings-workspace w-full px-3 py-3 sm:px-4 xl:px-5">
|
||
<AdminSettingsNavigation
|
||
activeTab="style-pro"
|
||
onTabChange={(value) => router.push(`/settings?tab=${value}`)}
|
||
/>
|
||
|
||
<main className="mt-6 flex w-full flex-col gap-4">
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||
<div>
|
||
<h1 className="font-semibold text-xl">风格化 PRO 设置</h1>
|
||
<p className="mt-1 max-w-2xl text-muted-foreground text-xs leading-5">
|
||
配置无限画布里的风格化 PRO 子页面。用户只看到分类和按钮名称,隐藏提示词和模型只在服务端生成时使用。
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
onClick={() => {
|
||
setConfig(defaultStyleProConfig);
|
||
toast.info("已恢复 index (25) 预设,保存后生效");
|
||
}}
|
||
variant="outline"
|
||
>
|
||
恢复 index (25) 预设
|
||
</Button>
|
||
<Button disabled={saving} onClick={save}>
|
||
<Save className="mr-2 h-4 w-4" />
|
||
{saving ? "保存中" : "保存配置"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>基础设置</CardTitle>
|
||
<CardDescription>
|
||
控制功能开关、风格化公开模型、实景化模型链路、默认提示词和生成规格。
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="grid gap-5 md:grid-cols-2">
|
||
<div className="flex items-center justify-between rounded-lg border p-4 md:col-span-2">
|
||
<div>
|
||
<Label>启用风格化 PRO</Label>
|
||
<p className="mt-1 text-muted-foreground text-sm">
|
||
关闭后前台页面会提示管理员暂未启用,生成接口也会拒绝执行。
|
||
</p>
|
||
</div>
|
||
<Switch
|
||
checked={config.enabled}
|
||
onCheckedChange={(enabled) => updateConfig({ enabled })}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label>风格化默认生图模型</Label>
|
||
<Select
|
||
onValueChange={(value) =>
|
||
updateConfig({
|
||
modelId: value === AUTO_MODEL_VALUE ? "" : value,
|
||
})
|
||
}
|
||
value={config.modelId || AUTO_MODEL_VALUE}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="选择模型" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value={AUTO_MODEL_VALUE}>自动选择可用图片模型</SelectItem>
|
||
{config.modelId &&
|
||
!visibleImageModels.some((model) => model.modelId === config.modelId || model.id === config.modelId) ? (
|
||
<SelectItem value={config.modelId}>
|
||
当前保存值:{config.modelId}
|
||
</SelectItem>
|
||
) : null}
|
||
{visibleImageModels.map((model) => {
|
||
const value = model.modelId || model.id;
|
||
return (
|
||
<SelectItem key={model.id} value={value}>
|
||
{modelLabel(model)}
|
||
</SelectItem>
|
||
);
|
||
})}
|
||
</SelectContent>
|
||
</Select>
|
||
</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">
|
||
前台只显示“实景化”模式,不展示模型名称;第一阶段只生成中间图,不保存到个人相册;
|
||
第二阶段最终图会保存到个人相册。两阶段会严格使用这里指定的模型,第一阶段失败时第二阶段不会继续执行。
|
||
</p>
|
||
</div>
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label>第一阶段模型(用户不可见)</Label>
|
||
<Select
|
||
onValueChange={(value) =>
|
||
updateConfig({
|
||
realisticFirstModelId:
|
||
value === AUTO_MODEL_VALUE ? "" : value,
|
||
})
|
||
}
|
||
value={config.realisticFirstModelId || AUTO_MODEL_VALUE}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="选择第一阶段模型" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value={AUTO_MODEL_VALUE}>未配置</SelectItem>
|
||
{config.realisticFirstModelId &&
|
||
!allImageModels.some(
|
||
(model) =>
|
||
model.modelId === config.realisticFirstModelId ||
|
||
model.id === config.realisticFirstModelId
|
||
) ? (
|
||
<SelectItem value={config.realisticFirstModelId}>
|
||
当前保存值(未在模型列表中):{config.realisticFirstModelId}
|
||
</SelectItem>
|
||
) : null}
|
||
{allImageModels.map((model) => {
|
||
const value = model.modelId || model.id;
|
||
return (
|
||
<SelectItem key={model.id} value={value}>
|
||
{modelLabel(model)}
|
||
</SelectItem>
|
||
);
|
||
})}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>第二阶段最终模型(用户不可见)</Label>
|
||
<Select
|
||
onValueChange={(value) =>
|
||
updateConfig({
|
||
realisticSecondModelId:
|
||
value === AUTO_MODEL_VALUE ? "" : value,
|
||
})
|
||
}
|
||
value={config.realisticSecondModelId || AUTO_MODEL_VALUE}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="选择第二阶段模型" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value={AUTO_MODEL_VALUE}>未配置</SelectItem>
|
||
{config.realisticSecondModelId &&
|
||
!allImageModels.some(
|
||
(model) =>
|
||
model.modelId === config.realisticSecondModelId ||
|
||
model.id === config.realisticSecondModelId
|
||
) ? (
|
||
<SelectItem value={config.realisticSecondModelId}>
|
||
当前保存值(未在模型列表中):{config.realisticSecondModelId}
|
||
</SelectItem>
|
||
) : null}
|
||
{allImageModels.map((model) => {
|
||
const value = model.modelId || model.id;
|
||
return (
|
||
<SelectItem key={model.id} value={value}>
|
||
{modelLabel(model)}
|
||
</SelectItem>
|
||
);
|
||
})}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label>第一阶段默认提示词</Label>
|
||
<Textarea
|
||
className="min-h-[120px]"
|
||
onChange={(event) =>
|
||
updateConfig({ realisticFirstPrompt: event.target.value })
|
||
}
|
||
value={config.realisticFirstPrompt}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>第二阶段最终提示词</Label>
|
||
<Textarea
|
||
className="min-h-[120px]"
|
||
onChange={(event) =>
|
||
updateConfig({ realisticSecondPrompt: event.target.value })
|
||
}
|
||
value={config.realisticSecondPrompt}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="space-y-2">
|
||
<Label>图片比例</Label>
|
||
<Select
|
||
onValueChange={(aspectRatio) => updateConfig({ aspectRatio })}
|
||
value={config.aspectRatio || "auto"}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="auto">自动延续原图</SelectItem>
|
||
<SelectItem value="1:1">1:1</SelectItem>
|
||
<SelectItem value="4:3">4:3</SelectItem>
|
||
<SelectItem value="3:4">3:4</SelectItem>
|
||
<SelectItem value="16:9">16:9</SelectItem>
|
||
<SelectItem value="9:16">9:16</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>图片尺寸</Label>
|
||
<Select
|
||
onValueChange={(imageSize) => updateConfig({ imageSize })}
|
||
value={config.imageSize || "2K"}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="1K">1K</SelectItem>
|
||
<SelectItem value="2K">2K</SelectItem>
|
||
<SelectItem value="4K">4K</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2 md:col-span-2">
|
||
<Label>提示词模块展示方式</Label>
|
||
<Select
|
||
onValueChange={(promptDisplayMode) =>
|
||
updateConfig({
|
||
promptDisplayMode:
|
||
promptDisplayMode === "carousel" ? "carousel" : "stacked",
|
||
})
|
||
}
|
||
value={config.promptDisplayMode || "stacked"}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="stacked">纵向模块列表</SelectItem>
|
||
<SelectItem value="carousel">模块左右滑动模式</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<p className="text-muted-foreground text-xs">
|
||
左右滑动模式会把多个提示词分类收纳到同一行横向滑动区域,减少前台页面高度占用。
|
||
</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-4 rounded-xl border p-4 md:col-span-2">
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||
<div>
|
||
<Label>全局自定义输入项</Label>
|
||
<p className="mt-1 text-muted-foreground text-xs">
|
||
适合墙面颜色、顶面颜色、材质说明等全局参数。前台会自动渲染,后台模板决定如何写入隐藏提示词。
|
||
</p>
|
||
</div>
|
||
<Button onClick={addGlobalCustomField} size="sm" variant="outline">
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
添加自定义项
|
||
</Button>
|
||
</div>
|
||
{(config.customFields || []).length > 0 ? (
|
||
<div className="space-y-3">
|
||
{(config.customFields || []).map((field) => (
|
||
<CustomFieldEditor
|
||
field={field}
|
||
key={field.id}
|
||
onChange={(updates) =>
|
||
updateGlobalCustomField(field.id, updates)
|
||
}
|
||
onRemove={() => removeGlobalCustomField(field.id)}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-lg border border-dashed p-4 text-muted-foreground text-sm">
|
||
暂未配置全局自定义项。需要墙面/页面调色时,可添加“颜色选择”字段。
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-2 md:col-span-2">
|
||
<Label>默认系统提示词</Label>
|
||
<Textarea
|
||
className="min-h-[140px]"
|
||
onChange={(event) =>
|
||
updateConfig({ defaultPrompt: event.target.value })
|
||
}
|
||
value={config.defaultPrompt}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2 md:col-span-2">
|
||
<Label>负向提示词</Label>
|
||
<Textarea
|
||
className="min-h-[90px]"
|
||
onChange={(event) =>
|
||
updateConfig({ negativePrompt: event.target.value })
|
||
}
|
||
value={config.negativePrompt}
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||
<div>
|
||
<CardTitle>分类与按钮</CardTitle>
|
||
<CardDescription>
|
||
分类和按钮会按顺序显示在前台,按钮提示词只在服务端参与生成。
|
||
</CardDescription>
|
||
</div>
|
||
<Button onClick={addCategory}>
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
添加分类
|
||
</Button>
|
||
</CardHeader>
|
||
<CardContent className="space-y-5">
|
||
{config.categories.map((category, categoryIndex) => (
|
||
<div className="rounded-xl border p-4" key={category.id}>
|
||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 font-semibold text-primary">
|
||
{categoryIndex + 1}
|
||
</span>
|
||
<div>
|
||
<div className="font-medium">{category.title || "未命名分类"}</div>
|
||
<div className="text-muted-foreground text-xs">
|
||
{category.required ? "必填分类" : "选填分类"} · {category.options.length} 个按钮
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
disabled={categoryIndex === 0}
|
||
onClick={() => moveCategory(category.id, -1)}
|
||
size="sm"
|
||
variant="outline"
|
||
>
|
||
上移
|
||
</Button>
|
||
<Button
|
||
disabled={categoryIndex === config.categories.length - 1}
|
||
onClick={() => moveCategory(category.id, 1)}
|
||
size="sm"
|
||
variant="outline"
|
||
>
|
||
下移
|
||
</Button>
|
||
<Button
|
||
onClick={() => removeCategory(category.id)}
|
||
size="sm"
|
||
variant="destructive"
|
||
>
|
||
<Trash2 className="mr-2 h-4 w-4" />
|
||
删除分类
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label>分类名称</Label>
|
||
<Input
|
||
onChange={(event) =>
|
||
updateCategory(category.id, { title: event.target.value })
|
||
}
|
||
value={category.title}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>分类 ID</Label>
|
||
<Input
|
||
onChange={(event) =>
|
||
updateCategory(category.id, { id: event.target.value })
|
||
}
|
||
value={category.id}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2 md:col-span-2">
|
||
<Label>分类说明</Label>
|
||
<Input
|
||
onChange={(event) =>
|
||
updateCategory(category.id, {
|
||
description: event.target.value,
|
||
})
|
||
}
|
||
value={category.description || ""}
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="space-y-2">
|
||
<Label>最少选择</Label>
|
||
<Input
|
||
min={0}
|
||
onChange={(event) =>
|
||
updateCategory(category.id, {
|
||
minSelections: Number(event.target.value) || 0,
|
||
})
|
||
}
|
||
type="number"
|
||
value={category.minSelections}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>最多选择</Label>
|
||
<Input
|
||
min={1}
|
||
onChange={(event) =>
|
||
updateCategory(category.id, {
|
||
maxSelections: Number(event.target.value) || 1,
|
||
})
|
||
}
|
||
type="number"
|
||
value={category.maxSelections}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<label className="flex items-center justify-between rounded-lg border p-3">
|
||
<span className="text-sm">必填</span>
|
||
<Switch
|
||
checked={category.required}
|
||
onCheckedChange={(required) =>
|
||
updateCategory(category.id, { required })
|
||
}
|
||
/>
|
||
</label>
|
||
<label className="flex items-center justify-between rounded-lg border p-3">
|
||
<span className="text-sm">启用</span>
|
||
<Switch
|
||
checked={category.enabled}
|
||
onCheckedChange={(enabled) =>
|
||
updateCategory(category.id, { enabled })
|
||
}
|
||
/>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-5 space-y-3 rounded-lg border border-dashed p-4">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<h3 className="font-medium text-sm">分类自定义输入项</h3>
|
||
<p className="mt-1 text-muted-foreground text-xs">
|
||
用户可在这个分类下补充自定义要求,例如颜色、材质、光感或特殊说明。
|
||
</p>
|
||
</div>
|
||
<Button
|
||
onClick={() => addCategoryCustomField(category.id)}
|
||
size="sm"
|
||
variant="outline"
|
||
>
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
添加自定义项
|
||
</Button>
|
||
</div>
|
||
{(category.customFields || []).length > 0 ? (
|
||
<div className="space-y-3">
|
||
{(category.customFields || []).map((field) => (
|
||
<CustomFieldEditor
|
||
field={field}
|
||
key={field.id}
|
||
onChange={(updates) =>
|
||
updateCategoryCustomField(category.id, field.id, updates)
|
||
}
|
||
onRemove={() =>
|
||
removeCategoryCustomField(category.id, field.id)
|
||
}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-lg bg-muted/40 p-3 text-muted-foreground text-sm">
|
||
这个分类暂未配置自定义输入项。
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-5 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="font-medium text-sm">按钮配置</h3>
|
||
<Button onClick={() => addOption(category.id)} size="sm" variant="outline">
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
添加按钮
|
||
</Button>
|
||
</div>
|
||
|
||
{category.options.map((option) => (
|
||
<div className="rounded-lg border bg-muted/20 p-4" key={option.id}>
|
||
<div className="mb-3 flex items-center justify-between gap-3">
|
||
<div className="flex items-center gap-2">
|
||
<Wand2 className="h-4 w-4 text-primary" />
|
||
<span className="font-medium text-sm">
|
||
{option.label || "未命名按钮"}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Switch
|
||
checked={option.enabled}
|
||
onCheckedChange={(enabled) =>
|
||
updateOption(category.id, option.id, { enabled })
|
||
}
|
||
/>
|
||
<Button
|
||
onClick={() => removeOption(category.id, option.id)}
|
||
size="sm"
|
||
variant="ghost"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label>前台按钮名</Label>
|
||
<Input
|
||
onChange={(event) =>
|
||
updateOption(category.id, option.id, {
|
||
label: event.target.value,
|
||
})
|
||
}
|
||
value={option.label}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>按钮 ID</Label>
|
||
<Input
|
||
onChange={(event) =>
|
||
updateOption(category.id, option.id, {
|
||
id: event.target.value,
|
||
})
|
||
}
|
||
value={option.id}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>JSON Key</Label>
|
||
<Input
|
||
onChange={(event) =>
|
||
updateOption(category.id, option.id, {
|
||
jsonKey: event.target.value,
|
||
})
|
||
}
|
||
placeholder={category.id}
|
||
value={option.jsonKey || ""}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>说明</Label>
|
||
<Input
|
||
onChange={(event) =>
|
||
updateOption(category.id, option.id, {
|
||
description: event.target.value,
|
||
})
|
||
}
|
||
value={option.description || ""}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2 md:col-span-2">
|
||
<Label>隐藏提示词</Label>
|
||
<Textarea
|
||
className="min-h-[110px]"
|
||
onChange={(event) =>
|
||
updateOption(category.id, option.id, {
|
||
prompt: event.target.value,
|
||
})
|
||
}
|
||
value={option.prompt}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|