feat(components): 优化卡片组件交互效果与动画

重构 AggregateCard、DemoCard 和 VideoCard 组件的交互效果:
1. 统一卡片悬停动画,使用 cubic-bezier 实现平滑过渡
2. 改进播放按钮和收藏按钮的视觉反馈
3. 优化集数显示样式为矩形框
4. 添加卡片删除时的淡出动画
5. 统一外部链接按钮样式
6. 调整标题悬停效果和位置
This commit is contained in:
SongPro
2025-07-02 00:47:30 +08:00
parent bb2b37a078
commit b1aa925a08
3 changed files with 145 additions and 164 deletions

View File

@@ -121,32 +121,25 @@ const AggregateCard: React.FC<AggregateCardProps> = ({
year ? `&year=${encodeURIComponent(year)}` : ''
}&type=${mostFrequentEpisodes > 1 ? 'tv' : 'movie'}`}
>
<div className='group relative w-full rounded-lg bg-transparent shadow-none flex flex-col'>
<div className='group relative w-full rounded-lg bg-transparent flex flex-col cursor-pointer transition-all duration-500 ease-in-out'>
{/* 封面图片 2:3 */}
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-md'>
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-md group-hover:scale-[1.02] transition-all duration-700 cubic-bezier(0.4,0,0.2,1)'>
<Image
src={first.poster}
alt={first.title}
fill
className='object-cover'
unoptimized
className='object-cover transition-transform duration-1000 cubic-bezier(0.4,0,0.2,1) group-hover:scale-110'
referrerPolicy='no-referrer'
priority={false}
/>
{/* 集数指示器 - 绿色小圆球 */}
{mostFrequentEpisodes && mostFrequentEpisodes > 1 && (
<div className='absolute top-2 right-2 w-4 h-4 sm:w-7 sm:h-7 bg-green-500 rounded-full flex items-center justify-center'>
<span className='text-white text-[0.5rem] sm:text-xs font-bold'>
{mostFrequentEpisodes}
</span>
</div>
)}
{/* Hover 层 & 播放按钮 */}
<div className='absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center pointer-events-none'>
{/* Hover 效果层 */}
<div className='absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-all duration-500 cubic-bezier(0.4,0,0.2,1) flex items-center justify-center overflow-hidden'>
{/* 播放按钮 */}
<div className='absolute inset-0 flex items-center justify-center pointer-events-auto'>
<div
className={`transition-all duration-200 pointer-events-auto ${
playHover ? 'scale-110' : ''
className={`transition-all duration-500 cubic-bezier(0.4,0,0.2,1) ${
playHover ? 'scale-100 opacity-100' : 'scale-90 opacity-70'
}`}
style={{ cursor: 'pointer' }}
onClick={(e) => {
@@ -168,15 +161,25 @@ const AggregateCard: React.FC<AggregateCardProps> = ({
</div>
</div>
{/* 集数矩形展示框 - 不透明无hover效果 */}
{mostFrequentEpisodes && mostFrequentEpisodes > 1 && (
<div className='absolute top-2 right-2 min-w-[1.875rem] h-5 sm:h-7 sm:min-w-[2.5rem] bg-green-500 dark:bg-green-600 rounded-md flex items-center justify-center px-2 shadow-md text-[0.55rem] sm:text-xs'>
<span className='text-white font-bold leading-none'>
{mostFrequentEpisodes}
</span>
</div>
)}
{/* 豆瓣链接按钮 */}
{mostFrequentDoubanId && (
<a
href={`https://movie.douban.com/subject/${mostFrequentDoubanId}`}
target='_blank'
rel='noopener noreferrer'
onClick={(e) => e.stopPropagation()}
className='absolute top-2 left-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200'
className='absolute top-2 left-2 scale-90 group-hover:scale-100 opacity-0 group-hover:opacity-100 transition-all duration-500 cubic-bezier(0.4,0,0.2,1)'
>
<div className='w-4 h-4 sm:w-7 sm:h-7 rounded-full bg-green-500 flex items-center justify-center transition-all duration-200 hover:scale-110'>
<div className='w-4 h-4 sm:w-7 sm:h-7 rounded-full bg-[#22c55e] flex items-center justify-center shadow-md opacity-70 hover:opacity-100 transition-all duration-300 ease-in-out hover:scale-110 hover:bg-[#16a34a]'>
<LinkIcon className='w-4 h-4 text-white' strokeWidth={2} />
</div>
</a>
@@ -184,13 +187,9 @@ const AggregateCard: React.FC<AggregateCardProps> = ({
</div>
{/* 标题 */}
<div className='absolute top-[calc(100%+0.2rem)] left-0 right-0'>
<div className='flex flex-col items-center justify-center'>
<span className='text-gray-900 font-semibold truncate w-full text-center text-xs sm:text-sm dark:text-gray-200'>
{first.title}
</span>
</div>
</div>
<span className='mt-2 px-1 block text-gray-900 font-semibold truncate w-full text-center text-xs sm:text-sm dark:text-gray-200 transition-all duration-700 cubic-bezier(0.4,0,0.2,1) group-hover:translate-y-[-2px] translate-y-1 opacity-80 group-hover:opacity-100 group-hover:text-green-600 dark:group-hover:text-green-400'>
{first.title}
</span>
</div>
</Link>
);

View File

@@ -25,8 +25,7 @@ function SearchCircle({
viewBox='0 0 44 44'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={className}
style={{ display: 'block' }}
className={`${className} block relative`}
>
<circle
cx='22'
@@ -36,21 +35,11 @@ function SearchCircle({
strokeWidth='1.5'
fill={fillColor}
/>
<g>
<foreignObject x='12' y='12' width='20' height='20'>
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
}}
>
<Search className='h-7 w-7 text-white' strokeWidth={2} />
</div>
</foreignObject>
</g>
<foreignObject x='0' y='0' width='44' height='44'>
<div className='w-full h-full flex items-center justify-center'>
<Search className='h-7 w-7 text-white' strokeWidth={2} />
</div>
</foreignObject>
</svg>
);
}
@@ -65,62 +54,60 @@ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => {
return (
<div
className='group relative w-full rounded-lg bg-transparent shadow-none flex flex-col cursor-pointer'
className='group relative w-full rounded-lg bg-transparent flex flex-col cursor-pointer transition-all duration-500 ease-in-out'
onClick={handleClick}
>
{/* 海报图片 - 2:3 比例 */}
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-md'>
{/* 海报图片区域 */}
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-md group-hover:scale-[1.02] transition-all duration-700 cubic-bezier(0.4, 0, 0.2, 1)'>
<Image
src={poster}
alt={title}
fill
className='object-cover'
className='object-cover transition-transform duration-1000 cubic-bezier(0.4, 0, 0.2, 1) group-hover:scale-110'
referrerPolicy='no-referrer'
unoptimized
priority={false}
/>
{/* 评分徽章 */}
{rate && (
<div className='absolute top-2 right-2 min-w-[1.25rem] h-4 w-4 sm:h-7 sm:w-7 sm:min-w-[1.5rem] bg-pink-500 rounded-full flex items-center justify-center px-1'>
<div className='absolute top-2 right-2 min-w-[1.25rem] h-4 w-4 sm:h-7 sm:w-7 sm:min-w-[1.5rem] bg-pink-500 rounded-full flex items-center justify-center px-1 shadow-md transform transition-all duration-500 cubic-bezier(0.4, 0, 0.2, 1) group-hover:scale-110 group-hover:rotate-3'>
<span className='text-white text-[0.5rem] sm:text-xs font-bold leading-none'>
{rate}
</span>
</div>
)}
{/* Hover 效果 */}
<div className='absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center group'>
<div className='absolute inset-0 flex items-center justify-center'>
<div
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
className={`transition-all duration-200 ${
hover ? 'scale-110' : ''
}`}
>
<SearchCircle fillColor={hover ? '#22c55e' : 'none'} />
</div>
{/* 悬浮层 - 搜索按钮 */}
<div className='absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-all duration-500 cubic-bezier(0.4, 0, 0.2, 1) flex items-center justify-center'>
<div
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
className={`transition-all duration-500 cubic-bezier(0.4, 0, 0.2, 1) ${
hover ? 'scale-110 rotate-12' : 'scale-90'
}`}
>
<SearchCircle fillColor={hover ? '#22c55e' : 'none'} />
</div>
</div>
{/* 顶部左侧 🔗 链接按钮 */}
{/* 外部链接按钮 */}
<a
href={`https://movie.douban.com/subject/${id}`}
target='_blank'
rel='noopener noreferrer'
onClick={(e) => e.stopPropagation()}
className='absolute top-2 left-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200'
className='absolute top-2 left-2 scale-90 group-hover:scale-100 opacity-0 group-hover:opacity-100 transition-all duration-500 cubic-bezier(0.4, 0, 0.2, 1)'
>
<div className='w-4 h-4 sm:w-7 sm:h-7 rounded-full bg-green-500 flex items-center justify-center transition-all duration-200 hover:scale-110'>
<div className='w-4 h-4 sm:w-7 sm:h-7 rounded-full bg-[#22c55e] flex items-center justify-center shadow-md opacity-70 hover:opacity-100 transition-all duration-300 ease-in-out hover:scale-110 hover:bg-[#16a34a]'>
<LinkIcon className='w-4 h-4 text-white' strokeWidth={2} />
</div>
</a>
</div>
{/* 信息层 */}
<div className='absolute top-[calc(100%+0.2rem)] left-0 right-0'>
<div className='flex flex-col items-center justify-center'>
<span className='text-gray-900 font-semibold truncate w-full text-center text-xs sm:text-sm dark:text-gray-200'>
{title}
</span>
</div>
</div>
<span className='mt-2 px-1 block text-gray-900 font-semibold truncate w-full text-center text-xs sm:text-sm dark:text-gray-200 transition-all duration-700 cubic-bezier(0.4, 0, 0.2, 1) group-hover:translate-y-[-2px] translate-y-1 opacity-80 group-hover:opacity-100 group-hover:text-green-600 dark:group-hover:text-green-400'>
{title}
</span>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import { Heart, LinkIcon } from 'lucide-react';
import { Heart, Link as LinkIcon } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
@@ -58,7 +58,7 @@ function PlayCircleSolid({
viewBox='0 0 44 44'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={className}
className={`${className} block relative`}
>
<circle
cx='22'
@@ -89,6 +89,7 @@ export default function VideoCard({
}: VideoCardProps) {
const [playHover, setPlayHover] = useState(false);
const [favorited, setFavorited] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const router = useRouter();
// 检查初始收藏状态
@@ -98,11 +99,9 @@ export default function VideoCard({
const fav = await isFavorited(source, id);
setFavorited(fav);
} catch (err) {
/* eslint-disable no-console */
console.error('检查收藏状态失败:', err);
throw new Error('检查收藏状态失败');
}
})();
// 仅在组件挂载或 source/id 变化时运行
}, [source, id]);
// 切换收藏状态
@@ -112,6 +111,12 @@ export default function VideoCard({
e.preventDefault();
e.stopPropagation();
// 如果是从收藏夹移除立即更新UI
if (favorited && from === 'favorites') {
setIsDeleting(true);
onDelete?.();
}
try {
const newState = await toggleFavorite(source, id, {
title,
@@ -123,8 +128,11 @@ export default function VideoCard({
});
setFavorited(newState);
} catch (err) {
/* eslint-disable no-console */
console.error('切换收藏失败:', err);
// 如果删除失败且是收藏夹,恢复显示
if (isDeleting) {
setIsDeleting(false);
}
throw new Error('切换收藏状态失败');
}
};
@@ -137,16 +145,14 @@ export default function VideoCard({
try {
await deletePlayRecord(source, id);
// 通知父组件更新
onDelete?.();
} catch (err) {
/* eslint-disable no-console */
console.error('删除播放记录失败:', err);
throw new Error('删除播放记录失败');
}
};
const hideCheckCircle = from === 'favorites' || from === 'search';
const alwaysShowHeart = from === 'favorites';
return (
<Link
@@ -154,23 +160,35 @@ export default function VideoCard({
title
)}${year ? `&year=${year}` : ''}${from ? `&from=${from}` : ''}`}
>
<div className='group relative w-full rounded-lg bg-transparent shadow-none flex flex-col'>
{/* 海报图片 - 2:3 比例 */}
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-md'>
<div
className={`group relative w-full rounded-lg bg-transparent flex flex-col cursor-pointer transition-all duration-500 ease-in-out ${
isDeleting ? 'opacity-0 scale-90' : ''
}`}
>
{/* 海报图片容器 */}
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-md group-hover:scale-[1.02] transition-all duration-700 cubic-bezier(0.4,0,0.2,1)'>
<Image
src={poster}
alt={title}
fill
className='object-cover'
unoptimized
className='object-cover transition-transform duration-1000 cubic-bezier(0.4,0,0.2,1) group-hover:scale-110'
referrerPolicy='no-referrer'
priority={false}
/>
{/* Hover 效果 */}
<div className='absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center group pointer-events-none'>
{/* Hover 效果 */}
<div
className={`absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent ${
alwaysShowHeart
? 'opacity-50 group-hover:opacity-100'
: 'opacity-0 group-hover:opacity-100'
} transition-all duration-500 cubic-bezier(0.4,0,0.2,1) flex items-center justify-center overflow-hidden`}
>
{/* 播放按钮 */}
<div className='absolute inset-0 flex items-center justify-center pointer-events-auto'>
<div
className={`transition-all duration-200 pointer-events-auto ${
playHover ? 'scale-110' : ''
className={`transition-all duration-500 cubic-bezier(0.4,0,0.2,1) ${
playHover ? 'scale-100 opacity-100' : 'scale-90 opacity-70'
}`}
style={{ cursor: 'pointer' }}
onClick={(e) => {
@@ -188,111 +206,88 @@ export default function VideoCard({
<PlayCircleSolid fillColor={playHover ? '#22c55e' : 'none'} />
</div>
</div>
<div className='absolute bottom-2 right-2 sm:bottom-4 sm:right-4 flex items-center gap-6'>
{/* 右侧操作按钮组 */}
<div className='absolute bottom-2 right-2 sm:bottom-4 sm:right-4 flex items-center gap-3 transform transition-all duration-500 cubic-bezier(0.4,0,0.2,1) group-hover:scale-110'>
{!hideCheckCircle && (
<span
onClick={handleDeleteRecord}
title='标记已看'
className='inline-flex items-center justify-center pointer-events-auto'
className='inline-flex items-center justify-center opacity-70 hover:opacity-100 transition-opacity duration-300'
>
<CheckCircleCustom />
</span>
)}
{favorited && (
<span className='inline-flex w-4 h-4 sm:w-6 sm:h-6 pointer-events-none' />
)}
{!favorited && (
<span
onClick={handleToggleFavorite}
title={favorited ? '移除收藏' : '加入收藏'}
className='inline-flex items-center justify-center pointer-events-auto'
>
<Heart
className={`h-4 w-4 sm:h-6 sm:w-6 stroke-[2] ${
favorited ? 'text-red-500' : 'text-white/90'
}`}
fill={favorited ? 'currentColor' : 'none'}
/>
</span>
)}
<span
onClick={handleToggleFavorite}
title={favorited ? '移除收藏' : '加入收藏'}
className={`inline-flex items-center justify-center ${
alwaysShowHeart ? 'opacity-100' : 'opacity-70'
} hover:opacity-100 transition-opacity duration-300`}
>
<Heart
className={`h-4 w-4 sm:h-5 sm:w-5 ${
favorited ? 'scale-105 text-red-500' : 'text-white/90'
}`}
strokeWidth={2}
fill={favorited ? 'currentColor' : 'none'}
/>
</span>
</div>
</div>
{/* 集数指示器 - 绿色小圆球 */}
{episodes && episodes > 1 && (
<div className='absolute top-2 right-2 w-4 h-4 sm:w-7 sm:h-7 bg-green-500 rounded-full flex items-center justify-center'>
<span className='text-white text-[0.5rem] sm:text-xs font-bold'>
{/* 集数矩形展示框 - 增加条件判断:仅当有多个集数且已播放时显示 */}
{episodes && episodes > 1 && currentEpisode && (
<div className='absolute top-2 right-2 min-w-[1.875rem] h-5 sm:h-7 sm:min-w-[2.5rem] bg-green-500/90 dark:bg-green-600/90 rounded-md flex items-center justify-center px-2 shadow-md text-[0.55rem] sm:text-xs'>
<span className='text-white font-bold leading-none'>
{currentEpisode}
</span>
<span className='mx-1 text-white/80'>/</span>
<span className='text-white font-bold leading-none'>
{episodes}
</span>
</div>
)}
{/* 播放进度条 */}
{progress !== undefined && (
<div className='absolute bottom-0 left-0 right-0 h-1 bg-gray-300 dark:bg-gray-600'>
<div
className='h-full bg-blue-500 transition-all duration-300'
style={{ width: `${progress}%` }}
/>
</div>
)}
{/* 当前播放集数 */}
{currentEpisode && episodes && episodes > 1 && (
<div className='absolute top-2 left-2 w-4 h-4 sm:w-7 sm:h-7 bg-blue-500 rounded-full flex items-center justify-center'>
<span className='text-white text-[0.5rem] sm:text-xs font-bold'>
{currentEpisode}
</span>
</div>
)}
{/* 豆瓣链接按钮 */}
{douban_id && from === 'search' && (
<a
href={`https://movie.douban.com/subject/${douban_id}`}
target='_blank'
rel='noopener noreferrer'
onClick={(e) => e.stopPropagation()}
className='absolute top-2 left-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200'
className='absolute top-2 left-2 scale-90 group-hover:scale-100 opacity-0 group-hover:opacity-100 transition-all duration-500 cubic-bezier(0.4,0,0.2,1)'
>
<div className='w-4 h-4 sm:w-7 sm:h-7 rounded-full bg-green-500 flex items-center justify-center transition-all duration-200 hover:scale-110'>
<div className='w-4 h-4 sm:w-7 sm:h-7 rounded-full bg-[#22c55e] flex items-center justify-center shadow-md opacity-70 hover:opacity-100 transition-all duration-300 ease-in-out hover:scale-110 hover:bg-[#16a34a]'>
<LinkIcon className='w-4 h-4 text-white' strokeWidth={2} />
</div>
</a>
)}
</div>
{/* 信息层 */}
<div className='absolute top-[calc(100%+0.5rem)] left-0 right-0'>
<div className='flex flex-col items-center justify-center'>
<span className='text-gray-900 font-semibold truncate w-full text-center text-xs sm:text-sm dark:text-gray-200'>
{title}
</span>
{source && (
<span className='text-gray-500 text-[0.5rem] sm:text-xs w-full text-center mt-1 dark:text-gray-400'>
<span className='inline-block border border-gray-500/60 rounded px-2 py-[1px] dark:border-gray-400/60'>
{source_name}
</span>
</span>
)}
{/* 播放进度条 - 移至图片容器外部,标题上方 */}
{progress !== undefined && (
<div className='mt-1 h-1 w-full bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden'>
<div
className='h-full bg-[#22c55e] rounded-full transition-all duration-300'
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
{/* 收藏夹始终显示红心 */}
{favorited && (
<div className='absolute bottom-2 right-2 sm:bottom-4 sm:right-4 flex items-center'>
<span
onClick={handleToggleFavorite}
title={favorited ? '移除收藏' : '加入收藏'}
className='inline-flex items-center justify-center'
>
<Heart
className={`h-4 w-4 sm:h-6 sm:w-6 stroke-[2] ${
favorited ? 'text-red-500' : 'text-white/90'
}`}
fill={favorited ? 'currentColor' : 'none'}
/>
{/* 信息层 */}
<span className='mt-2 px-1 block text-gray-900 font-semibold truncate w-full text-center text-xs sm:text-sm dark:text-gray-200 transition-all duration-700 cubic-bezier(0.4,0,0.2,1) group-hover:translate-y-[-2px] translate-y-1 opacity-80 group-hover:opacity-100 group-hover:text-green-600 dark:group-hover:text-green-400'>
{title}
</span>
{/* 来源信息 */}
{source && (
<span className='mt-1 px-1 block text-gray-500 text-[0.5rem] sm:text-xs w-full text-center dark:text-gray-400 transition-all duration-700 cubic-bezier(0.4,0,0.2,1) group-hover:translate-y-[-2px] translate-y-1 opacity-80 group-hover:opacity-100'>
<span className='inline-block border border-gray-500/60 rounded px-2 py-[1px] dark:border-gray-400/60'>
{source_name}
</span>
</div>
</span>
)}
</div>
</Link>