ai-chatbot/components/agents/AgentPresetCardDark.tsx
2026-05-28 19:05:25 +08:00

466 lines
16 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 { motion } from "framer-motion";
import { Play, Copy, Pencil, Sparkles, Trash2, Video, Wand2, FileText, Clock, User } from "lucide-react";
import { cn } from "@/lib/utils";
import {
getAgentPresetModeTypeFromDefaultParams,
normalizeAgentPresetModeConfig,
} from "@/lib/agents/preset-mode-config";
import type { AgentPreset } from "@/types/agent";
import {
AGENT_PRESET_PREVIEW_IMAGE_SIZES,
getAgentPresetPreviewImageUrl,
} from "./agent-preset-preview";
interface AgentPresetCardDarkProps {
preset: AgentPreset;
selected?: boolean;
onSelect: (presetId: string) => void;
onFork?: (preset: AgentPreset) => void;
onEdit?: (preset: AgentPreset) => void;
onDelete?: (preset: AgentPreset) => void;
index?: number;
compact?: boolean;
}
export function AgentPresetCardDark({
preset,
selected = false,
onSelect,
onFork,
onEdit,
onDelete,
index = 0,
compact = false,
}: AgentPresetCardDarkProps) {
const rawPreviewUrl =
preset.avatarUrl ||
preset.referenceAssets?.[0]?.thumbnailUrl ||
preset.referenceAssets?.[0]?.originalUrl ||
"";
const previewUrl = getAgentPresetPreviewImageUrl(rawPreviewUrl);
const ownerName =
String(preset.owner?.name || "").trim() ||
String(preset.owner?.email || "").trim() ||
"未知";
const publishedAt = (() => {
const raw = preset.createdAt ? new Date(preset.createdAt) : null;
if (!raw || Number.isNaN(raw.getTime())) return "";
return raw.toLocaleDateString("zh-CN", {
timeZone: "Asia/Shanghai",
month: "short",
day: "numeric",
});
})();
const typeConfig = {
image: {
icon: Wand2,
label: "生图",
gradient: "from-violet-500 to-purple-600",
bg: "bg-violet-500/20",
text: "text-violet-300"
},
video: {
icon: Video,
label: "视频",
gradient: "from-amber-500 to-orange-600",
bg: "bg-amber-500/20",
text: "text-amber-300"
},
text: {
icon: FileText,
label: "生文",
gradient: "from-emerald-500 to-teal-600",
bg: "bg-emerald-500/20",
text: "text-emerald-300"
},
};
const typeInfo = typeConfig[preset.type] || typeConfig.image;
const TypeIcon = typeInfo.icon;
const imageModeType = getAgentPresetModeTypeFromDefaultParams(preset.defaultParams);
const modeConfig = normalizeAgentPresetModeConfig(preset.defaultParams);
const imageModeLabel =
imageModeType === "detail_replacement"
? "详情替换"
: imageModeType === "sku_replacement"
? "SKU替换"
: imageModeType === "scene_generation"
? "场景图"
: null;
const requiresImageCount =
preset.type === "image" ? Math.max(0, preset.requiredUploadCount || preset.slotCount || 0) : 0;
const includesModelSlot =
preset.type === "image"
? imageModeType === "scene_generation"
? modeConfig.scene.includeModelSlot
: imageModeType === "detail_replacement"
? modeConfig.detail.includeModelSlot
: imageModeType === "sku_replacement"
? modeConfig.sku.includeModelSlot
: preset.requiredUploadCount > preset.slotCount
: false;
const modelControlLabel =
preset.type === "image"
? imageModeType === "scene_generation" && modeConfig.scene.allowModelOverride
? "可切模型"
: "锁定模型"
: null;
const categoryHint =
preset.categoryPath?.level3Name ||
preset.categoryPath?.level2Name ||
preset.categoryPath?.level1Name ||
preset.categoryPath?.sectionName ||
null;
const compactSummaryBadges = [
imageModeLabel || typeInfo.label,
preset.type === "image" && requiresImageCount > 0 ? `需${requiresImageCount}张图` : null,
preset.type === "image" ? (includesModelSlot ? "含模特槽" : "无模特槽") : null,
modelControlLabel,
].filter((item): item is string => Boolean(item));
const showEdit = Boolean(onEdit);
const showDelete = Boolean(onDelete);
const showFork = Boolean(onFork) && !showEdit && !showDelete;
if (compact) {
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.35,
delay: index * 0.03,
ease: [0.25, 0.46, 0.45, 0.94],
}}
whileHover={{ y: -3, scale: 1.01 }}
whileTap={{ scale: 0.98 }}
className={cn(
"group relative cursor-pointer overflow-hidden rounded-2xl border p-2.5 transition-all duration-300",
selected
? "border-emerald-400/40 bg-emerald-500/[0.08] shadow-[0_0_24px_rgba(16,185,129,0.14)]"
: "border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.06]"
)}
onClick={() => onSelect(preset.id)}
>
<div className="absolute right-2 top-2 z-10 flex gap-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
{showEdit ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onEdit?.(preset);
}}
className="flex h-6 w-6 items-center justify-center rounded-full bg-black/65 text-white/80 backdrop-blur-md transition-all hover:bg-white/20 hover:text-white"
>
<Pencil className="h-3 w-3" />
</button>
) : null}
{showDelete ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete?.(preset);
}}
className="flex h-6 w-6 items-center justify-center rounded-full bg-black/65 text-white/80 backdrop-blur-md transition-all hover:bg-red-500/80 hover:text-white"
>
<Trash2 className="h-3 w-3" />
</button>
) : null}
{showFork ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onFork?.(preset);
}}
className="flex h-6 w-6 items-center justify-center rounded-full bg-black/65 text-white/80 backdrop-blur-md transition-all hover:bg-white/20 hover:text-white"
>
<Copy className="h-3 w-3" />
</button>
) : null}
</div>
<div className="flex items-stretch gap-3">
<div className="relative w-[88px] min-h-[112px] shrink-0 overflow-hidden rounded-[20px] border border-white/10 bg-black/30">
{previewUrl ? (
<img
src={previewUrl}
alt={preset.name}
loading="lazy"
decoding="async"
fetchPriority="low"
sizes="88px"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : (
<div className={cn("flex h-full w-full items-center justify-center bg-gradient-to-br", typeInfo.gradient)}>
<TypeIcon className="h-6 w-6 text-white" />
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/35 to-transparent" />
<div className="absolute left-1.5 top-1.5 rounded-full border border-white/10 bg-black/45 px-1.5 py-0.5 text-[9px] text-white/78">
{imageModeLabel || typeInfo.label}
</div>
</div>
<div className="flex min-h-[112px] min-w-0 flex-1 flex-col justify-between pr-7">
<div className="line-clamp-2 text-[13px] font-semibold leading-5 text-white">
{preset.name}
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{compactSummaryBadges.map((badge, badgeIndex) => (
<span
key={`${preset.id}-badge-${badgeIndex}-${badge}`}
className={cn(
"rounded-full border px-2 py-1 text-[9px] leading-none",
badgeIndex === 0
? "border-emerald-400/25 bg-emerald-500/[0.12] text-emerald-100"
: "border-white/10 bg-white/[0.04] text-white/62"
)}
>
{badge}
</span>
))}
</div>
<div className="mt-2 text-[10px] leading-4 text-white/45">
<div className="truncate">
默认模型:{preset.defaultModel?.displayName || "未配置"}
</div>
{preset.description ? (
<div className="line-clamp-1">{preset.description}</div>
) : categoryHint ? (
<div className="truncate">适用分类:{categoryHint}</div>
) : null}
</div>
<div className="mt-2 flex flex-col gap-1 text-[10px] text-white/50">
<div className="flex items-center gap-1.5">
<User className="h-3 w-3 text-white/34" />
<span className="truncate">贡献者:{ownerName}</span>
</div>
<div className="flex items-center gap-1.5">
<Clock className="h-3 w-3 text-white/34" />
<span className="truncate">贡献时间:{publishedAt || "未记录"}</span>
</div>
</div>
</div>
</div>
{selected ? (
<div className="pointer-events-none absolute inset-0 rounded-2xl ring-1 ring-inset ring-emerald-300/40" />
) : null}
</motion.div>
);
}
return (
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.5,
delay: index * 0.08,
ease: [0.25, 0.46, 0.45, 0.94]
}}
whileHover={{ y: -8, scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={cn(
"group relative mx-auto cursor-pointer",
"aspect-[4/3] w-full max-w-[230px]",
"rounded-2xl overflow-hidden",
"bg-zinc-900",
"transition-all duration-500",
selected
? "ring-2 ring-white/30 shadow-[0_0_40px_rgba(255,255,255,0.1)]"
: "ring-1 ring-white/5 hover:ring-white/20"
)}
onClick={() => onSelect(preset.id)}
>
{/* 背景图片 */}
<div className="absolute inset-0">
{previewUrl ? (
<>
<img
src={previewUrl}
alt={preset.name}
loading="lazy"
decoding="async"
fetchPriority="low"
sizes={AGENT_PRESET_PREVIEW_IMAGE_SIZES}
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-110"
/>
{/* 渐变遮罩 */}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-black/10" />
<div className="absolute inset-0 bg-gradient-to-b from-black/30 via-transparent to-transparent" />
</>
) : (
<div className="h-full w-full bg-gradient-to-br from-zinc-800 to-zinc-900">
<div className="flex h-full items-center justify-center">
<div className={cn(
"flex h-20 w-20 items-center justify-center rounded-3xl",
"bg-gradient-to-br",
typeInfo.gradient,
"opacity-60"
)}>
<TypeIcon className="h-10 w-10 text-white" />
</div>
</div>
</div>
)}
</div>
{/* 顶部信息栏 */}
<div className="absolute left-0 right-0 top-0 flex items-center justify-between p-4">
{/* 类型标签 */}
<div className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium backdrop-blur-md",
"bg-black/40",
typeInfo.text
)}>
<TypeIcon className="h-3.5 w-3.5" />
{imageModeLabel || typeInfo.label}
</div>
{/* 可见性标签 */}
<div className={cn(
"rounded-full px-2.5 py-1 text-[10px] font-medium backdrop-blur-md",
preset.visibility === "public"
? "bg-emerald-500/20 text-emerald-300"
: "bg-zinc-500/20 text-zinc-400"
)}>
{preset.visibility === "public" ? "公共" : "私有"}
</div>
</div>
{/* 中央播放/使用按钮 */}
<motion.div
className="absolute inset-0 flex items-center justify-center"
initial={{ opacity: 0, scale: 0.8 }}
whileHover={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
>
<div className={cn(
"flex h-16 w-16 items-center justify-center rounded-full",
"bg-white/20 backdrop-blur-md",
"ring-1 ring-white/30",
"transition-all duration-300",
"group-hover:bg-white/30 group-hover:scale-110",
selected && "bg-white/40 scale-110"
)}>
<Play className={cn(
"h-7 w-7 text-white fill-white",
selected && "scale-110"
)} />
</div>
</motion.div>
{/* 悬浮操作按钮 */}
<motion.div
className="absolute right-4 top-16 flex flex-col gap-2"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 0 }}
whileHover={{ opacity: 1 }}
>
<div className="flex flex-col gap-2 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
{showEdit && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onEdit?.(preset);
}}
className="flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white/80 backdrop-blur-md transition-all hover:bg-white/20 hover:text-white"
>
<Pencil className="h-4 w-4" />
</button>
)}
{showDelete && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete?.(preset);
}}
className="flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white/80 backdrop-blur-md transition-all hover:bg-red-500/80 hover:text-white"
>
<Trash2 className="h-4 w-4" />
</button>
)}
{showFork && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onFork?.(preset);
}}
className="flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white/80 backdrop-blur-md transition-all hover:bg-white/20 hover:text-white"
>
<Copy className="h-4 w-4" />
</button>
)}
</div>
</motion.div>
{/* 底部信息 */}
<div className="absolute bottom-0 left-0 right-0 p-4">
{/* 标题 */}
<h3 className="mb-2 line-clamp-1 text-base font-semibold text-white drop-shadow-lg">
{preset.name}
</h3>
<div className="mb-2 flex flex-wrap gap-1.5">
{compactSummaryBadges.slice(0, 4).map((badge, badgeIndex) => (
<span
key={`${preset.id}-full-badge-${badgeIndex}-${badge}`}
className={cn(
"rounded-full border px-2.5 py-1 text-[10px] font-medium backdrop-blur-md",
badgeIndex === 0
? "border-emerald-300/35 bg-emerald-500/[0.16] text-emerald-100"
: "border-white/10 bg-black/35 text-white/72"
)}
>
{badge}
</span>
))}
</div>
{/* 元信息 */}
<div className="flex items-center justify-between text-xs text-white/60">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<User className="h-3 w-3" />
{ownerName}
</span>
{preset.defaultModel?.displayName ? (
<span className="flex items-center gap-1">
<Sparkles className="h-3 w-3" />
{preset.defaultModel.displayName}
</span>
) : null}
</div>
{publishedAt && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{publishedAt}
</span>
)}
</div>
</div>
{/* 选中指示器 */}
{selected && (
<motion.div
className="absolute inset-0 ring-2 ring-inset ring-white/30 rounded-2xl"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
/>
)}
</motion.div>
);
}