From 3a64c6785e874626e0a0c97e810b72fd000f95f9 Mon Sep 17 00:00:00 2001 From: SongPro Date: Thu, 10 Jul 2025 11:15:55 +0800 Subject: [PATCH] =?UTF-8?q?refactor(VideoCard):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E7=BB=93=E6=9E=84=E4=B8=8E=E5=8A=A8=E7=94=BB?= =?UTF-8?q?=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构 VideoCard 组件,主要改进包括: 1. 移除自定义图标组件,改用 lucide-react 图标 2. 优化聚合数据处理逻辑,提取通用统计方法 3. 简化配置逻辑,使用 useMemo 缓存配置 4. 改进动画效果,添加悬停过渡和微交互 5. 使用 useCallback 优化事件处理函数 6. 统一组件样式和过渡效果 style(ImagePlaceholder): 添加透明度过渡效果 --- src/components/ImagePlaceholder.tsx | 2 +- src/components/VideoCard.tsx | 557 ++++++++++------------------ 2 files changed, 196 insertions(+), 363 deletions(-) diff --git a/src/components/ImagePlaceholder.tsx b/src/components/ImagePlaceholder.tsx index fc4c402..6732064 100644 --- a/src/components/ImagePlaceholder.tsx +++ b/src/components/ImagePlaceholder.tsx @@ -1,7 +1,7 @@ // 图片占位符组件 - 实现骨架屏效果(支持暗色模式) const ImagePlaceholder = ({ aspectRatio }: { aspectRatio: string }) => (
void; - - // 可选属性 - rate?: string; // douban 卡片可能有评分 - items?: SearchResult[]; // search 卡片可能有聚合数据 -} - -function CheckCircleCustom() { - return ( - - - - - - - ); -} - -function PlayCircleSolid({ - className = '', - fillColor = 'none', -}: { - className?: string; - fillColor?: string; -}) { - return ( - - - - - ); + rate?: string; + items?: SearchResult[]; } export default function VideoCard({ id, - title, - poster, + title = '', + poster = '', episodes, source, source_name, - progress, + progress = 0, year, from, currentEpisode, @@ -95,434 +41,321 @@ export default function VideoCard({ rate, items, }: VideoCardProps) { - const [playHover, setPlayHover] = useState(false); + const router = useRouter(); const [favorited, setFavorited] = useState(false); const [isLoaded, setIsLoaded] = useState(false); const [isDeleting, setIsDeleting] = useState(false); - const router = useRouter(); - // 判断是否为聚合卡片(只有 search 类型可能有聚合) - const isAggregate = from === 'search' && !!items && items.length > 0; + const isAggregate = from === 'search' && !!items?.length; - // 处理聚合卡片的逻辑 + // 聚合数据(仅在 search 模式下) const aggregateData = useMemo(() => { - if (!isAggregate) { - return null; - } + if (!isAggregate || !items) return null; - const first = items[0]; + const countMap = new Map(); + const episodeCountMap = new Map(); + const yearCountMap = new Map(); - // 统计出现次数最多的(非 0) douban_id - const countMap = new Map(); items.forEach((item) => { if (item.douban_id && item.douban_id !== 0) { countMap.set(item.douban_id, (countMap.get(item.douban_id) || 0) + 1); } - }); - - let mostFrequentDoubanId: number | undefined; - let maxCount = 0; - countMap.forEach((cnt, id) => { - if (cnt > maxCount) { - maxCount = cnt; - mostFrequentDoubanId = id; - } - }); - - // 统计最频繁的集数 - const episodeCountMap = new Map(); - items.forEach((item) => { const len = item.episodes?.length || 0; if (len > 0) { episodeCountMap.set(len, (episodeCountMap.get(len) || 0) + 1); } - }); - - let mostFrequentEpisodes = 0; - let maxEpisodeCount = 0; - episodeCountMap.forEach((cnt, len) => { - if (cnt > maxEpisodeCount) { - maxEpisodeCount = cnt; - mostFrequentEpisodes = len; - } - }); - - // 统计出现次数最多的年份 - const yearCountMap = new Map(); - items.forEach((item) => { - if (item.year && item.year.trim()) { + if (item.year?.trim()) { const yearStr = item.year.trim(); yearCountMap.set(yearStr, (yearCountMap.get(yearStr) || 0) + 1); } }); - let mostFrequentYear: string | undefined; - let maxYearCount = 0; - yearCountMap.forEach((cnt, yr) => { - if (cnt > maxYearCount) { - maxYearCount = cnt; - mostFrequentYear = yr; - } - }); + const getMostFrequent = ( + map: Map + ) => { + let maxCount = 0; + let result: T | undefined; + map.forEach((cnt, key) => { + if (cnt > maxCount) { + maxCount = cnt; + result = key; + } + }); + return result; + }; return { - first, - mostFrequentDoubanId, - mostFrequentEpisodes, - mostFrequentYear, + first: items[0], + mostFrequentDoubanId: getMostFrequent(countMap), + mostFrequentEpisodes: getMostFrequent(episodeCountMap) || 0, + mostFrequentYear: getMostFrequent(yearCountMap), }; }, [isAggregate, items]); - // 根据卡片类型决定实际使用的数据 - const actualTitle = - isAggregate && aggregateData ? aggregateData.first.title : title || ''; - const actualPoster = - isAggregate && aggregateData ? aggregateData.first.poster : poster || ''; - const actualSource = - isAggregate && aggregateData ? aggregateData.first.source : source; - const actualId = isAggregate && aggregateData ? aggregateData.first.id : id; - const actualDoubanId = - isAggregate && aggregateData - ? aggregateData.mostFrequentDoubanId?.toString() - : douban_id; - const actualEpisodes = - isAggregate && aggregateData - ? aggregateData.mostFrequentEpisodes - : episodes; - const actualYear = - isAggregate && aggregateData ? aggregateData.mostFrequentYear : year; + const actualTitle = aggregateData?.first.title ?? title; + const actualPoster = aggregateData?.first.poster ?? poster; + const actualSource = aggregateData?.first.source ?? source; + const actualId = aggregateData?.first.id ?? id; + const actualDoubanId = String( + aggregateData?.mostFrequentDoubanId ?? douban_id + ); + const actualEpisodes = aggregateData?.mostFrequentEpisodes ?? episodes; + const actualYear = aggregateData?.mostFrequentYear ?? year; - // 检查初始收藏状态(需要 source 和 id 的卡片类型) + // 获取收藏状态 useEffect(() => { if (from === 'douban' || !actualSource || !actualId) return; - - (async () => { + const fetchFavoriteStatus = async () => { try { const fav = await isFavorited(actualSource, actualId); setFavorited(fav); } catch (err) { throw new Error('检查收藏状态失败'); } - })(); + }; + fetchFavoriteStatus(); }, [from, actualSource, actualId]); - // 切换收藏状态 - const handleToggleFavorite = async ( - e: React.MouseEvent - ) => { - e.preventDefault(); - e.stopPropagation(); + const handleToggleFavorite = useCallback( + async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); - if (from === 'douban' || !actualSource || !actualId) return; + if (from === 'douban' || !actualSource || !actualId) return; - try { - const newState = await toggleFavorite(actualSource, actualId, { - title: actualTitle, - source_name: source_name || '', - year: actualYear || '', - cover: actualPoster, - total_episodes: actualEpisodes ?? 1, - save_time: Date.now(), - }); - setFavorited(newState); - } catch (err) { - // 如果删除失败且是收藏夹,恢复显示 - if (isDeleting) { - setIsDeleting(false); + try { + const newState = await toggleFavorite(actualSource, actualId, { + title: actualTitle, + source_name: source_name || '', + year: actualYear || '', + cover: actualPoster, + total_episodes: actualEpisodes ?? 1, + save_time: Date.now(), + }); + setFavorited(newState); + } catch (err) { + throw new Error('切换收藏状态失败'); } - throw new Error('切换收藏状态失败'); - } - }; + }, + [ + from, + actualSource, + actualId, + actualTitle, + source_name, + actualYear, + actualPoster, + actualEpisodes, + ] + ); - // 删除对应播放记录 - const handleDeleteRecord = async ( - e: React.MouseEvent - ) => { - e.preventDefault(); - e.stopPropagation(); + const handleDeleteRecord = useCallback( + async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); - if (from !== 'playrecord' || !actualSource || !actualId) return; + if (from !== 'playrecord' || !actualSource || !actualId) return; - try { - await deletePlayRecord(actualSource, actualId); - onDelete?.(); - } catch (err) { - throw new Error('删除播放记录失败'); - } - }; + try { + await deletePlayRecord(actualSource, actualId); + setIsDeleting(true); + onDelete?.(); + } catch (err) { + throw new Error('删除播放记录失败'); + } + }, + [from, actualSource, actualId, onDelete] + ); - // 点击处理逻辑 - const handleClick = () => { + const handleClick = useCallback(() => { if (from === 'douban') { - // douban 卡片使用 title 搜索 router.push(`/play?title=${encodeURIComponent(actualTitle.trim())}`); } else if (actualSource && actualId) { - // 其他类型使用 source 和 id router.push( `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent( - actualTitle.trim() + actualTitle )}${actualYear ? `&year=${actualYear}` : ''}` ); } - }; + }, [from, actualSource, actualId, router, actualTitle, actualYear]); - // 播放按钮点击处理 - const handlePlayClick = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - handleClick(); - }; - - // 根据 from 类型决定显示逻辑 - const getDisplayConfig = () => { - switch (from) { - case 'playrecord': - return { - showSourceName: true, - showProgress: true, - showPlayButton: true, - playButtonAlwaysVisible: true, - playButtonOpacity: 'opacity-50 group-hover:opacity-100', - showHeart: true, - heartAlwaysVisible: true, - heartOpacity: 'opacity-100', - showCheckCircle: true, - checkCircleAlwaysVisible: true, - showDoubanLink: false, - showRating: false, - hoverLayerOpacity: 'opacity-50 group-hover:opacity-100', - }; - case 'favorite': - return { - showSourceName: true, - showProgress: false, - showPlayButton: true, - playButtonAlwaysVisible: false, - playButtonOpacity: 'opacity-70 group-hover:opacity-100', - showHeart: true, - heartAlwaysVisible: false, - heartOpacity: 'opacity-70 group-hover:opacity-100', - showCheckCircle: false, - checkCircleAlwaysVisible: false, - showDoubanLink: false, - showRating: false, - hoverLayerOpacity: 'opacity-0 group-hover:opacity-100', - }; - case 'search': - return { - showSourceName: true, - showProgress: false, - showPlayButton: true, - playButtonAlwaysVisible: true, - playButtonOpacity: 'opacity-50 group-hover:opacity-100', - showHeart: !isAggregate, // 聚合卡片不显示收藏 - heartAlwaysVisible: !isAggregate, - heartOpacity: 'opacity-50 group-hover:opacity-100', - showCheckCircle: false, - checkCircleAlwaysVisible: false, - showDoubanLink: !!actualDoubanId, - showRating: false, - hoverLayerOpacity: isAggregate - ? 'opacity-50 group-hover:opacity-100' - : 'opacity-50 group-hover:opacity-100', - }; - case 'douban': - return { - showSourceName: false, - showProgress: false, - showPlayButton: true, - playButtonAlwaysVisible: false, - playButtonOpacity: 'opacity-70 group-hover:opacity-100', - showHeart: false, - heartAlwaysVisible: false, - heartOpacity: '', - showCheckCircle: false, - checkCircleAlwaysVisible: false, - showDoubanLink: true, - showRating: !!rate, - hoverLayerOpacity: 'opacity-0 group-hover:opacity-100', - }; - default: - return { - showSourceName: true, - showProgress: false, - showPlayButton: true, - playButtonAlwaysVisible: false, - playButtonOpacity: 'opacity-70 group-hover:opacity-100', - showHeart: true, - heartAlwaysVisible: false, - heartOpacity: 'opacity-70 group-hover:opacity-100', - showCheckCircle: false, - checkCircleAlwaysVisible: false, - showDoubanLink: false, - showRating: false, - hoverLayerOpacity: 'opacity-0 group-hover:opacity-100', - }; - } - }; - - const config = getDisplayConfig(); + const config = useMemo(() => { + const configs = { + playrecord: { + showSourceName: true, + showProgress: true, + showPlayButton: true, + showHeart: true, + showCheckCircle: true, + showDoubanLink: false, + showRating: false, + }, + favorite: { + showSourceName: true, + showProgress: false, + showPlayButton: true, + showHeart: true, + showCheckCircle: false, + showDoubanLink: false, + showRating: false, + }, + search: { + showSourceName: true, + showProgress: false, + showPlayButton: true, + showHeart: !isAggregate, + showCheckCircle: false, + showDoubanLink: !!actualDoubanId, + showRating: false, + }, + douban: { + showSourceName: false, + showProgress: false, + showPlayButton: true, + showHeart: false, + showCheckCircle: false, + showDoubanLink: true, + showRating: !!rate, + }, + }; + return configs[from] || configs.search; + }, [from, isAggregate, actualDoubanId, rate]); return (
- {/* 海报图片容器 */} -
- {/* 图片占位符 - 骨架屏效果 */} - + {/* 海报容器 */} +
+ {/* 骨架屏 */} + {!isLoaded && } + {/* 图片加载动画 */} {actualTitle} setIsLoaded(true)} referrerPolicy='no-referrer' priority={false} /> - {/* Hover 效果层 */} -
- {/* 播放按钮 */} + {/* 悬浮层 - 添加渐变动画效果 */} +
{config.showPlayButton && ( -
-
setPlayHover(true)} - onMouseLeave={() => setPlayHover(false)} - > - -
-
+ )} - {/* 右侧操作按钮组 */} + {/* 已看 / 收藏按钮 - 添加弹出动画 */} {(config.showHeart || config.showCheckCircle) && ( -
+
{config.showCheckCircle && ( - ) => + handleDeleteRecord( + e as unknown as React.MouseEvent + ) + } + title='标记为已看' + className='p-1.5 rounded-full transition-all duration-300 transform hover:scale-110 hover:bg-white/30' > - - + + )} {config.showHeart && ( - ) => + handleToggleFavorite( + e as unknown as React.MouseEvent + ) + } + title={favorited ? '取消收藏' : '加入收藏'} + className='p-1.5 rounded-full transition-all duration-300 transform hover:scale-110 hover:bg-white/30' > - + )}
)}
- {/* 评分徽章(豆瓣卡片) */} + {/* 集数徽章 / 标签元素 - 添加微动画 */} {config.showRating && rate && ( -
- - {rate} - +
+ {rate}
)} - {/* 继续观看 - 集数矩形展示框 */} - {(from === 'playrecord' || from === 'favorite') && + {['playrecord', 'favorite'].includes(from) && actualEpisodes && actualEpisodes > 1 && currentEpisode && ( -
- - {currentEpisode} - / - - - {actualEpisodes} - +
+ {currentEpisode}/{actualEpisodes}
)} - {/* 搜索页 - 集数圆形展示框 */} {from === 'search' && actualEpisodes && actualEpisodes > 1 && !currentEpisode && ( -
- - {actualEpisodes} - +
+ {actualEpisodes}
)} - {/* 豆瓣链接按钮 */} + {/* 豆瓣链接按钮 - 添加滑入动画 */} {config.showDoubanLink && actualDoubanId && ( e.stopPropagation()} - className='absolute top-2 left-2 scale-90 group-hover:scale-100 opacity-0 group-hover:opacity-100 transition-all duration-300 cubic-bezier(0.4,0,0.2,1)' + className='absolute top-2 left-2 opacity-0 translate-x-[-10px] group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-300' > - - {/* 播放进度条(仅播放记录卡片) */} + {/* 进度条 - 移除进度变化动画 */} {config.showProgress && progress !== undefined && (
)} - {/* 信息层 */} - + {/* 标题与来源信息 - 添加颜色过渡 */} + {actualTitle} - {/* 来源信息 */} {config.showSourceName && source_name && ( - - + + {source_name}