ai-chatbot/app/(admin)/settings/style-pro/style-pro-settings-client.tsx
Your Name c433b714cd
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: customize style pro button colors
2026-05-17 23:38:12 +08:00

868 lines
31 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 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 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,
options: category.options.map((option, optionIndex) => ({
...option,
order: (optionIndex + 1) * 10,
})),
}));
}
function createCategory(): StyleProCategory {
const id = getId("category");
return {
id,
title: "新分类",
description: "",
required: true,
minSelections: 1,
maxSelections: 1,
enabled: true,
order: 999,
options: [createOption(id)],
};
}
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>
);
}
export function StyleProSettingsClient() {
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 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 (
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-6 py-8">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<div className="mb-3">
<Button asChild variant="outline">
<Link href="/settings">
<ArrowLeft className="mr-2 h-4 w-4" />
返回设置
</Link>
</Button>
</div>
<h1 className="font-semibold text-3xl tracking-tight">风格化 PRO 设置</h1>
<p className="mt-2 max-w-2xl text-muted-foreground">
配置无限画布里的风格化 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-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">
<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>
);
}