ai-chatbot/components/agents/AgentPresetPanelDark.tsx
Your Name 8757f76b1f
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
chore: rename agent UI to stylized
2026-04-11 19:48:45 +08:00

279 lines
11 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Loader2, Plus, Search, Sparkles, User, Grid3X3, Wand2 } from "lucide-react";
import useSWR from "swr";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { agentService } from "@/services/agents";
import type { AgentPreset } from "@/types/agent";
import { AgentPresetCardDark } from "./AgentPresetCardDark";
import { AgentPresetDialog } from "./AgentPresetDialog";
interface AgentPresetPanelDarkProps {
selectedPresetId?: string | null;
onSelectPreset: (presetId: string | null) => void;
title?: string;
description?: string;
searchPlaceholder?: string;
publicTitle?: string;
privateTitle?: string;
}
export function AgentPresetPanelDark({
selectedPresetId,
onSelectPreset,
title = "风格化",
description = "选择生图模式开始创作",
searchPlaceholder = "搜索风格化...",
publicTitle = "公共风格化",
privateTitle = "我的风格化",
}: AgentPresetPanelDarkProps) {
const [keyword, setKeyword] = useState("");
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [editingPreset, setEditingPreset] = useState<AgentPreset | null>(null);
const { data, isLoading, mutate } = useSWR(
["agent-presets"],
async () => {
const result = await agentService.getPresets({
scope: "all",
sort: "latest",
});
if (!result.success || !result.data) {
throw new Error(result.error || "获取风格化失败");
}
return result.data;
}
);
const filteredPublicPresets = useMemo(() => {
return (data?.public || []).filter((preset) => {
const normalizedKeyword = keyword.trim().toLowerCase();
if (!normalizedKeyword) return true;
return [preset.name, preset.description || "", preset.type]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(normalizedKeyword));
});
}, [data?.public, keyword]);
const filteredPrivatePresets = useMemo(() => {
return (data?.private || []).filter((preset) => {
const normalizedKeyword = keyword.trim().toLowerCase();
if (!normalizedKeyword) return true;
return [preset.name, preset.description || "", preset.type]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(normalizedKeyword));
});
}, [data?.private, keyword]);
const handleForkPreset = async (preset: AgentPreset) => {
const result = await agentService.forkPreset(preset.id, `${preset.name}(副本)`);
if (!result.success || !result.data) {
toast.error(result.error || "复制风格化失败");
return;
}
toast.success("已另存为我的风格化");
await mutate();
onSelectPreset(result.data.id);
};
const handleDeletePreset = async (preset: AgentPreset) => {
if (!window.confirm(`确定删除风格化「${preset.name}」吗?`)) {
return;
}
const result = await agentService.deletePreset(preset.id);
if (!result.success) {
toast.error(result.error || "删除风格化失败");
return;
}
toast.success("风格化已删除");
if (selectedPresetId === preset.id) {
onSelectPreset(null);
}
await mutate();
};
return (
<>
<div className="min-h-screen bg-black">
{/* 顶部导航栏 */}
<header className="sticky top-0 z-50 border-b border-white/5 bg-black/80 backdrop-blur-xl">
<div className="mx-auto max-w-[1800px] px-8">
<div className="flex h-20 items-center justify-between">
{/* Logo & Title */}
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-purple-600">
<Wand2 className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-white">{title}</h1>
<p className="text-xs text-white/40">{description}</p>
</div>
</div>
{/* Search */}
<div className="flex-1 max-w-xl mx-8">
<div className="relative">
<Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-white/40" />
<Input
placeholder={searchPlaceholder}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className={cn(
"h-11 rounded-full border-0 pl-11 pr-4",
"bg-white/5 text-white placeholder:text-white/30",
"focus:bg-white/10 focus:ring-1 focus:ring-white/20",
"transition-all duration-300"
)}
/>
</div>
</div>
{/* Actions */}
<Button
onClick={() => setShowCreateDialog(true)}
className={cn(
"h-11 gap-2 rounded-full px-6",
"bg-white text-black hover:bg-white/90",
"font-medium transition-all duration-300",
"hover:shadow-[0_0_30px_rgba(255,255,255,0.3)]"
)}
>
<Plus className="h-4 w-4" />
新建风格化
</Button>
</div>
</div>
</header>
{/* Main Content */}
<main className="mx-auto max-w-[1800px] px-8 py-8">
<ScrollArea className="h-[calc(100vh-120px)]">
<div className="space-y-10 pb-20">
{/* 公共风格化区域 */}
<section>
<motion.div
className="mb-6 flex items-center gap-3"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500/20">
<Grid3X3 className="h-4 w-4 text-violet-400" />
</div>
<h2 className="text-lg font-semibold text-white">{publicTitle}</h2>
<span className="rounded-full bg-white/5 px-3 py-1 text-xs text-white/50">
{filteredPublicPresets.length}
</span>
</motion.div>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-violet-500" />
</div>
) : filteredPublicPresets.length > 0 ? (
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6">
<AnimatePresence mode="popLayout">
{filteredPublicPresets.map((preset, index) => (
<AgentPresetCardDark
key={preset.id}
preset={preset}
selected={selectedPresetId === preset.id}
onSelect={onSelectPreset}
onFork={handleForkPreset}
onEdit={setEditingPreset}
onDelete={handleDeletePreset}
index={index}
/>
))}
</AnimatePresence>
</div>
) : (
<EmptyState message="暂无公共风格化" />
)}
</section>
{/* 私有风格化区域 */}
<section>
<motion.div
className="mb-6 flex items-center gap-3"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500/20">
<User className="h-4 w-4 text-emerald-400" />
</div>
<h2 className="text-lg font-semibold text-white">{privateTitle}</h2>
<span className="rounded-full bg-white/5 px-3 py-1 text-xs text-white/50">
{filteredPrivatePresets.length}
</span>
</motion.div>
{isLoading ? null : filteredPrivatePresets.length > 0 ? (
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6">
<AnimatePresence mode="popLayout">
{filteredPrivatePresets.map((preset, index) => (
<AgentPresetCardDark
key={preset.id}
preset={preset}
selected={selectedPresetId === preset.id}
onSelect={onSelectPreset}
onEdit={setEditingPreset}
onDelete={handleDeletePreset}
index={index + filteredPublicPresets.length}
/>
))}
</AnimatePresence>
</div>
) : (
<EmptyState message="还没有私有风格化,点击右上角新建一个吧" />
)}
</section>
</div>
</ScrollArea>
</main>
</div>
<AgentPresetDialog
open={showCreateDialog || Boolean(editingPreset)}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setShowCreateDialog(false);
setEditingPreset(null);
return;
}
setShowCreateDialog(true);
}}
initialPreset={editingPreset}
onSaved={async (preset) => {
await mutate();
onSelectPreset(preset.id);
setEditingPreset(null);
setShowCreateDialog(false);
}}
/>
</>
);
}
function EmptyState({ message }: { message: string }) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center justify-center rounded-3xl border border-white/5 bg-white/[0.02] py-20"
>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5">
<Sparkles className="h-7 w-7 text-white/30" />
</div>
<p className="text-sm text-white/40">{message}</p>
</motion.div>
);
}