466 lines
16 KiB
TypeScript
466 lines
16 KiB
TypeScript
"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>
|
||
);
|
||
}
|