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 (
+ <>
+
+ {/* 标题 */}
+
+
+
+ 搜索到 {displayVideos.length} 个相关视频
+
+
+
+ {/* 视频网格容器 - 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 ? (
+

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 ? (
+
+ ) : (
+ // 不支持嵌入的视频,显示封面和跳转提示
+
+ {video.coverImage && (
+

+ )}
+
+
+
+
+ {platformName}视频暂不支持嵌入播放
+
+
+ 点击下方按钮前往{platformName}观看
+
+
+
+
+ )}
+
+
+ {/* 悬浮选集面板 */}
+ {showEpisodeButton && (
+
+ {/* 面板标题 */}
+
+
+
+ 视频选集
+
+ {hasEpisodes && (
+
+ {currentEpisode} / {episodes.length}
+
+ )}
+
+
+ {/* 选集列表 */}
+
+ {isLoadingEpisodes ? (
+
+ ) : episodes.length === 0 ? (
+
+ 暂无选集信息
+
+ ) : (
+
+ {episodes.map((episode) => {
+ const isActive = episode.page === currentEpisode;
+
+ return (
+
+ );
+ })}
+
+ )}
+
+
+ {/* 面板底部提示 */}
+
+
+ )}
+
+
+ {/* 底部信息栏 */}
+
+
+ {/* 左侧:元信息 */}
+
+ {video.authors && video.authors.length > 0 && (
+
+
+ {video.authors.join(', ')}
+
+ )}
+
+ {video.date && (
+
+
+ {video.date}
+
+ )}
+
+ {video.duration && (
+
+
+ {formatDuration(video.duration)}
+
+ )}
+
+
+ {/* 右侧:快捷提示 */}
+
+ 按 ESC 关闭
+
+
+
+ {video.snippet && video.snippet.length < 200 && (
+
+ {video.snippet}
+
+ )}
+
+
+
+ {/* 自定义滚动条样式 */}
+
+
+ );
+}