From ecf11e6b2be0da5874e396169bcd4286e59e5276 Mon Sep 17 00:00:00 2001 From: gaoziman <2942894660@qq.com> Date: Mon, 22 Dec 2025 21:59:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BB=84=E4=BB=B6):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E6=90=9C=E7=B4=A2=E7=BB=93=E6=9E=9C=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E5=92=8C=E6=92=AD=E6=94=BE=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SearchVideosGrid: 视频搜索结果网格展示组件 - 支持3列响应式布局 - 显示视频封面、标题、时长、作者、平台标签 - 封面图片懒加载和加载状态动画 - 点击卡片打开播放弹窗 - VideoPlayerModal: 视频播放弹窗组件 - 支持B站、YouTube视频嵌入播放 - 悬浮选集面板(仿B站原生设计) - 自动获取B站视频选集信息 - 支持选集切换和播放状态指示 - 不支持嵌入的视频显示跳转提示 - ESC关闭和键盘快捷键支持 - EpisodeList: 视频选集列表组件 - 显示选集编号、标题、时长 - 当前播放集高亮显示 - 支持滚动和加载状态 --- src/components/features/EpisodeList.tsx | 144 ++++++ src/components/features/SearchVideosGrid.tsx | 246 +++++++++ src/components/features/VideoPlayerModal.tsx | 497 +++++++++++++++++++ 3 files changed, 887 insertions(+) create mode 100644 src/components/features/EpisodeList.tsx create mode 100644 src/components/features/SearchVideosGrid.tsx create mode 100644 src/components/features/VideoPlayerModal.tsx diff --git a/src/components/features/EpisodeList.tsx b/src/components/features/EpisodeList.tsx new file mode 100644 index 0000000..f0a526b --- /dev/null +++ b/src/components/features/EpisodeList.tsx @@ -0,0 +1,144 @@ +'use client'; + +import { useCallback } from 'react'; +import { Play, Clock, List, Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +/** + * 选集信息 + */ +export interface VideoEpisode { + page: number; // 分P编号,从1开始 + part: string; // 分P标题 + duration: number; // 时长(秒) + cid?: number; // B站视频分片ID +} + +interface EpisodeListProps { + episodes: VideoEpisode[]; + currentEpisode: number; + onEpisodeChange: (page: number) => void; + isLoading?: boolean; + className?: string; +} + +/** + * 格式化时长(秒 -> MM:SS 或 HH:MM:SS) + */ +function formatDuration(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + return `${minutes}:${secs.toString().padStart(2, '0')}`; +} + +/** + * 视频选集列表组件 + */ +export function EpisodeList({ + episodes, + currentEpisode, + onEpisodeChange, + isLoading = false, + className, +}: EpisodeListProps) { + const handleClick = useCallback((page: number) => { + if (page !== currentEpisode) { + onEpisodeChange(page); + } + }, [currentEpisode, onEpisodeChange]); + + // 如果没有选集或只有一个,不显示 + if (!isLoading && episodes.length <= 1) { + return null; + } + + return ( +
+ {/* 标题栏 */} +
+
+ + 视频选集 + {!isLoading && episodes.length > 0 && ( + + ({currentEpisode}/{episodes.length}) + + )} +
+
+ + {/* 选集列表 */} +
+ {isLoading ? ( + // 加载状态 +
+ + 加载选集中... +
+ ) : episodes.length === 0 ? ( + // 无选集 +
+ 暂无选集信息 +
+ ) : ( + // 选集列表 +
+ {episodes.map((episode) => { + const isActive = episode.page === currentEpisode; + + return ( + + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/components/features/SearchVideosGrid.tsx b/src/components/features/SearchVideosGrid.tsx new file mode 100644 index 0000000..b9d43ad --- /dev/null +++ b/src/components/features/SearchVideosGrid.tsx @@ -0,0 +1,246 @@ +'use client'; + +import { useState, useMemo, useCallback } from 'react'; +import { Play, Video, Clock, User, Calendar, ExternalLink } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { formatDuration, detectPlatform, getPlatformName, getPlatformColor } from '@/lib/videoUtils'; +import { VideoPlayerModal } from './VideoPlayerModal'; + +export interface SearchVideoItem { + title: string; + link: string; + snippet: string; + score: string; + position: number; + authors: string[]; + date: string; + duration: string; + coverImage: string; +} + +interface SearchVideosGridProps { + videos: SearchVideoItem[]; + className?: string; + /** 最大显示视频数量,默认为 5 */ + maxDisplay?: number; +} + +/** + * 搜索视频网格展示组件 + * 显示视频封面、标题、时长、作者等信息 + * 点击视频卡片打开播放弹窗 + */ +export function SearchVideosGrid({ + videos, + className, + maxDisplay = 5 +}: SearchVideosGridProps) { + const [playerOpen, setPlayerOpen] = useState(false); + const [selectedVideo, setSelectedVideo] = useState(null); + + // 封面加载状态 + const [loadedCovers, setLoadedCovers] = useState>(new Set()); + const [errorCovers, setErrorCovers] = useState>(new Set()); + + /** + * 计算当前要显示的视频列表 + * 跳过封面加载失败的视频(可选择保留) + */ + const displayVideos = useMemo(() => { + const result: { video: SearchVideoItem; originalIndex: number }[] = []; + + for (let i = 0; i < videos.length && result.length < maxDisplay; i++) { + // 即使封面加载失败也显示,使用占位图 + result.push({ video: videos[i], originalIndex: i }); + } + + return result; + }, [videos, maxDisplay]); + + // 封面加载成功 + const handleCoverLoad = useCallback((index: number) => { + setLoadedCovers((prev) => new Set(prev).add(index)); + }, []); + + // 封面加载失败 + const handleCoverError = useCallback((index: number) => { + setErrorCovers((prev) => new Set(prev).add(index)); + // 标记为已加载(显示占位图) + setLoadedCovers((prev) => new Set(prev).add(index)); + }, []); + + // 打开视频播放器 + const openPlayer = useCallback((video: SearchVideoItem) => { + setSelectedVideo(video); + setPlayerOpen(true); + }, []); + + // 关闭视频播放器 + const closePlayer = useCallback(() => { + setPlayerOpen(false); + setSelectedVideo(null); + }, []); + + if (!videos || videos.length === 0) return null; + + if (displayVideos.length === 0) { + return null; + } + + return ( + <> +
+ {/* 标题 */} +
+
+ + {/* 视频网格容器 - 3列布局 */} +
+ {displayVideos.map(({ video, originalIndex }, displayIndex) => { + const isLoaded = loadedCovers.has(originalIndex); + const hasError = errorCovers.has(originalIndex); + const platform = detectPlatform(video.link); + const platformName = getPlatformName(platform); + const platformColor = getPlatformColor(platform); + + return ( +
openPlayer(video)} + > + {/* 封面容器 */} +
+ {/* 封面图片 */} + {video.coverImage && !hasError ? ( + {video.title handleCoverLoad(originalIndex)} + onError={() => handleCoverError(originalIndex)} + loading="lazy" + /> + ) : ( + // 占位图 +
+
+ )} + + {/* 加载中指示器 */} + {!isLoaded && video.coverImage && ( +
+
+
+ )} + + {/* 播放按钮遮罩 */} +
+
+ +
+
+ + {/* 时长标签 */} + {video.duration && ( +
+ + {formatDuration(video.duration)} +
+ )} + + {/* 平台标签 */} +
+ {platformName} +
+
+ + {/* 视频信息 */} +
+ {/* 标题 */} +

+ {video.title} +

+ + {/* 元信息行 */} +
+ {/* 作者 */} + {video.authors && video.authors.length > 0 && ( +
+ + {video.authors[0]} +
+ )} + + {/* 日期 */} + {video.date && ( +
+ + {video.date} +
+ )} +
+
+
+ ); + })} +
+
+ + {/* 视频播放弹窗 */} + + + ); +} diff --git a/src/components/features/VideoPlayerModal.tsx b/src/components/features/VideoPlayerModal.tsx new file mode 100644 index 0000000..b6ed584 --- /dev/null +++ b/src/components/features/VideoPlayerModal.tsx @@ -0,0 +1,497 @@ +'use client'; + +import { useEffect, useCallback, useRef, useState } from 'react'; +import { X, ExternalLink, User, Calendar, Clock, AlertCircle, List, ChevronRight, Play } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { parseVideoUrl, formatDuration, detectPlatform, getPlatformName, getPlatformColor, supportsEmbed, getEmbedUrlWithPage, supportsEpisodes } from '@/lib/videoUtils'; +import { VideoEpisode } from './EpisodeList'; + +interface VideoPlayerModalProps { + video: { + title: string; + link: string; + snippet?: string; + authors?: string[]; + date?: string; + duration?: string; + coverImage?: string; + } | null; + isOpen: boolean; + onClose: () => void; +} + +/** + * 视频播放弹窗组件 + * 支持 B站、YouTube 视频嵌入播放 + * 悬浮选集面板设计(类似B站原生) + */ +export function VideoPlayerModal({ + video, + isOpen, + onClose, +}: VideoPlayerModalProps) { + const containerRef = useRef(null); + + // 选集状态管理 + const [episodes, setEpisodes] = useState([]); + const [currentEpisode, setCurrentEpisode] = useState(1); + const [isLoadingEpisodes, setIsLoadingEpisodes] = useState(false); + const [episodesError, setEpisodesError] = useState(null); + + // 选集面板展开状态 + const [isEpisodePanelOpen, setIsEpisodePanelOpen] = useState(false); + + // 键盘事件处理 + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (isEpisodePanelOpen) { + setIsEpisodePanelOpen(false); + } else { + onClose(); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + document.body.style.overflow = 'hidden'; + + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.body.style.overflow = ''; + }; + }, [isOpen, onClose, isEpisodePanelOpen]); + + // 获取视频选集 + useEffect(() => { + if (!isOpen || !video?.link) { + setEpisodes([]); + setCurrentEpisode(1); + setEpisodesError(null); + setIsEpisodePanelOpen(false); + return; + } + + const platform = detectPlatform(video.link); + + if (!supportsEpisodes(platform)) { + return; + } + + const fetchEpisodes = async () => { + setIsLoadingEpisodes(true); + setEpisodesError(null); + + try { + const response = await fetch(`/api/video/episodes?url=${encodeURIComponent(video.link)}`); + + if (!response.ok) { + throw new Error('获取选集失败'); + } + + const data = await response.json(); + + if (data.error) { + throw new Error(data.error); + } + + setEpisodes(data.episodes || []); + } catch (error) { + console.error('[VideoPlayerModal] 获取选集失败:', error); + setEpisodesError(error instanceof Error ? error.message : '获取选集失败'); + setEpisodes([]); + } finally { + setIsLoadingEpisodes(false); + } + }; + + fetchEpisodes(); + }, [isOpen, video?.link]); + + // 打开原链接 + const openOriginalLink = useCallback(() => { + if (video?.link) { + window.open(video.link, '_blank', 'noopener,noreferrer'); + } + }, [video?.link]); + + // 切换选集 + const handleEpisodeChange = useCallback((page: number) => { + setCurrentEpisode(page); + }, []); + + // 切换选集面板 + const toggleEpisodePanel = useCallback(() => { + setIsEpisodePanelOpen(prev => !prev); + }, []); + + if (!isOpen || !video) return null; + + const videoInfo = parseVideoUrl(video.link); + const platform = detectPlatform(video.link); + const platformName = getPlatformName(platform); + const platformColor = getPlatformColor(platform); + const canEmbed = supportsEmbed(platform); + + // 计算选集相关状态 + const hasEpisodes = episodes.length > 1; + const showEpisodeButton = supportsEpisodes(platform) && (isLoadingEpisodes || hasEpisodes); + + // 获取当前选集的嵌入 URL + const currentEmbedUrl = hasEpisodes + ? getEmbedUrlWithPage(video.link, currentEpisode) + : videoInfo?.embedUrl; + + // 格式化时长 + const formatEpisodeDuration = (seconds: number): string => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + return `${minutes}:${secs.toString().padStart(2, '0')}`; + }; + + return ( +
+ {/* 弹窗容器 */} +
e.stopPropagation()} + style={{ animation: 'scaleIn 0.25s ease-out' }} + > + {/* 顶部标题栏 */} +
+
+ {/* 平台标签 */} + + {platformName} + + + {/* 标题 */} +

+ {video.title} +

+
+ + {/* 操作按钮 */} +
+ {/* 选集按钮 */} + {showEpisodeButton && ( + + )} + + {/* 打开原链接 */} + + + {/* 关闭按钮 */} + +
+
+ + {/* 视频播放区域 - 相对定位容器 */} +
+ {/* 视频播放器 */} +
+ {canEmbed && currentEmbedUrl ? ( +