652 lines
22 KiB
TypeScript
652 lines
22 KiB
TypeScript
"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}`;
|
||
}
|
||
|
||
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 imageModels = useMemo(
|
||
() =>
|
||
(modelsData?.models || []).filter(
|
||
(model) =>
|
||
model.isEnabled !== false &&
|
||
model.isHidden !== true &&
|
||
model.supportsImageGen
|
||
),
|
||
[modelsData?.models]
|
||
);
|
||
|
||
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("已恢复默认配置,保存后生效");
|
||
}}
|
||
variant="outline"
|
||
>
|
||
恢复默认
|
||
</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 &&
|
||
!imageModels.some((model) => model.modelId === config.modelId || model.id === config.modelId) ? (
|
||
<SelectItem value={config.modelId}>
|
||
当前保存值:{config.modelId}
|
||
</SelectItem>
|
||
) : null}
|
||
{imageModels.map((model) => {
|
||
const value = model.modelId || model.id;
|
||
return (
|
||
<SelectItem key={model.id} value={value}>
|
||
{modelLabel(model)}
|
||
</SelectItem>
|
||
);
|
||
})}
|
||
</SelectContent>
|
||
</Select>
|
||
</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>
|
||
<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>
|
||
);
|
||
}
|
||
|