ai-chatbot/app/(admin)/settings/style-pro/style-pro-settings-client.tsx
Your Name 5387d6f1d2
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add style pro canvas workspace
2026-05-16 15:06:12 +08:00

652 lines
22 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}`;
}
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>
);
}