ai-chatbot/app/(admin)/settings/style-pro/style-pro-settings-client.tsx

1200 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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