279 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|