diff --git a/src/app/api/source-search/categories/route.ts b/src/app/api/source-search/categories/route.ts
new file mode 100644
index 0000000..daf157c
--- /dev/null
+++ b/src/app/api/source-search/categories/route.ts
@@ -0,0 +1,77 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+import { getAuthInfoFromCookie } from '@/lib/auth';
+import { API_CONFIG, getAvailableApiSites } from '@/lib/config';
+
+export const runtime = 'nodejs';
+
+interface CmsClassResponse {
+ class?: Array<{
+ type_id: string | number;
+ type_name: string;
+ }>;
+}
+
+/**
+ * 获取指定视频源的分类列表
+ */
+export async function GET(request: NextRequest) {
+ const authInfo = getAuthInfoFromCookie(request);
+ if (!authInfo || !authInfo.username) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const sourceKey = searchParams.get('source');
+
+ if (!sourceKey) {
+ return NextResponse.json(
+ { error: '缺少参数: source' },
+ { status: 400 }
+ );
+ }
+
+ try {
+ const apiSites = await getAvailableApiSites(authInfo.username);
+ const targetSite = apiSites.find((site) => site.key === sourceKey);
+
+ if (!targetSite) {
+ return NextResponse.json(
+ { error: `未找到指定的视频源: ${sourceKey}` },
+ { status: 404 }
+ );
+ }
+
+ // 请求分类列表
+ const classUrl = `${targetSite.api}?ac=list`;
+ const classResponse = await fetch(classUrl, {
+ headers: API_CONFIG.search.headers,
+ signal: AbortSignal.timeout(10000),
+ });
+
+ if (!classResponse.ok) {
+ throw new Error('获取分类列表失败');
+ }
+
+ const classData: CmsClassResponse = await classResponse.json();
+
+ if (!classData.class || !Array.isArray(classData.class)) {
+ return NextResponse.json({
+ categories: [],
+ });
+ }
+
+ return NextResponse.json({
+ categories: classData.class.map((item) => ({
+ id: item.type_id.toString(),
+ name: item.type_name,
+ })),
+ });
+ } catch (error) {
+ console.error('Failed to get categories:', error);
+ return NextResponse.json(
+ { error: '获取分类列表失败' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/source-search/search/route.ts b/src/app/api/source-search/search/route.ts
new file mode 100644
index 0000000..eff5d41
--- /dev/null
+++ b/src/app/api/source-search/search/route.ts
@@ -0,0 +1,128 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+import { getAuthInfoFromCookie } from '@/lib/auth';
+import { API_CONFIG, getAvailableApiSites } from '@/lib/config';
+import { SearchResult } from '@/lib/types';
+
+export const runtime = 'nodejs';
+
+interface CmsVideoItem {
+ vod_id: string | number;
+ vod_name: string;
+ vod_pic: string;
+ vod_remarks?: string;
+ vod_year?: string;
+ vod_play_from?: string;
+ vod_play_url?: string;
+}
+
+interface CmsVideoResponse {
+ list?: CmsVideoItem[];
+ total?: number;
+ page?: number;
+ pagecount?: number;
+}
+
+/**
+ * 在指定视频源中搜索视频
+ */
+export async function GET(request: NextRequest) {
+ const authInfo = getAuthInfoFromCookie(request);
+ if (!authInfo || !authInfo.username) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const sourceKey = searchParams.get('source');
+ const keyword = searchParams.get('keyword');
+ const page = searchParams.get('page') || '1';
+
+ if (!sourceKey) {
+ return NextResponse.json(
+ { error: '缺少参数: source' },
+ { status: 400 }
+ );
+ }
+
+ if (!keyword || keyword.trim() === '') {
+ return NextResponse.json(
+ { error: '缺少参数: keyword' },
+ { status: 400 }
+ );
+ }
+
+ try {
+ const apiSites = await getAvailableApiSites(authInfo.username);
+ const targetSite = apiSites.find((site) => site.key === sourceKey);
+
+ if (!targetSite) {
+ return NextResponse.json(
+ { error: `未找到指定的视频源: ${sourceKey}` },
+ { status: 404 }
+ );
+ }
+
+ // 请求搜索结果
+ const searchUrl = `${targetSite.api}?ac=videolist&wd=${encodeURIComponent(keyword)}&pg=${page}`;
+ const searchResponse = await fetch(searchUrl, {
+ headers: API_CONFIG.search.headers,
+ signal: AbortSignal.timeout(10000),
+ });
+
+ if (!searchResponse.ok) {
+ throw new Error('搜索失败');
+ }
+
+ const searchData: CmsVideoResponse = await searchResponse.json();
+
+ if (!searchData.list || !Array.isArray(searchData.list)) {
+ return NextResponse.json({
+ results: [],
+ total: 0,
+ page: parseInt(page),
+ pageCount: 0,
+ });
+ }
+
+ // 转换为 SearchResult 格式
+ const results: SearchResult[] = searchData.list.map((item) => {
+ const episodes: string[] = [];
+ const episodes_titles: string[] = [];
+
+ // 解析播放信息
+ if (item.vod_play_url && item.vod_play_from) {
+ const playUrls = item.vod_play_url.split('#');
+ playUrls.forEach((episodeStr) => {
+ if (episodeStr.trim()) {
+ const [name, url] = episodeStr.split('$');
+ if (name && url) {
+ episodes.push(url.trim());
+ episodes_titles.push(name.trim());
+ }
+ }
+ });
+ }
+
+ return {
+ id: item.vod_id.toString(),
+ title: item.vod_name,
+ poster: item.vod_pic || '',
+ year: item.vod_year || 'unknown',
+ episodes,
+ episodes_titles,
+ source: targetSite.key,
+ source_name: targetSite.name,
+ };
+ });
+
+ return NextResponse.json({
+ results,
+ total: searchData.total || 0,
+ page: parseInt(page),
+ pageCount: searchData.pagecount || 0,
+ });
+ } catch (error) {
+ console.error('Failed to search videos:', error);
+ return NextResponse.json({ error: '搜索失败' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/source-search/sources/route.ts b/src/app/api/source-search/sources/route.ts
new file mode 100644
index 0000000..1dc88c0
--- /dev/null
+++ b/src/app/api/source-search/sources/route.ts
@@ -0,0 +1,31 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+import { getAuthInfoFromCookie } from '@/lib/auth';
+import { getAvailableApiSites } from '@/lib/config';
+
+export const runtime = 'nodejs';
+
+export async function GET(request: NextRequest) {
+ const authInfo = getAuthInfoFromCookie(request);
+ if (!authInfo || !authInfo.username) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const apiSites = await getAvailableApiSites(authInfo.username);
+
+ return NextResponse.json({
+ sources: apiSites.map((site) => ({
+ key: site.key,
+ name: site.name,
+ api: site.api,
+ })),
+ });
+ } catch (error) {
+ console.error('Failed to get available API sites:', error);
+ return NextResponse.json(
+ { error: 'Failed to load sources' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/source-search/videos/route.ts b/src/app/api/source-search/videos/route.ts
new file mode 100644
index 0000000..e6e4f0c
--- /dev/null
+++ b/src/app/api/source-search/videos/route.ts
@@ -0,0 +1,131 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+import { getAuthInfoFromCookie } from '@/lib/auth';
+import { API_CONFIG, getAvailableApiSites } from '@/lib/config';
+import { SearchResult } from '@/lib/types';
+
+export const runtime = 'nodejs';
+
+interface CmsVideoItem {
+ vod_id: string | number;
+ vod_name: string;
+ vod_pic: string;
+ vod_remarks?: string;
+ vod_year?: string;
+ vod_play_from?: string;
+ vod_play_url?: string;
+}
+
+interface CmsVideoResponse {
+ list?: CmsVideoItem[];
+ total?: number;
+ page?: number;
+ pagecount?: number;
+}
+
+/**
+ * 获取指定视频源的分类视频列表
+ */
+export async function GET(request: NextRequest) {
+ const authInfo = getAuthInfoFromCookie(request);
+ if (!authInfo || !authInfo.username) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const sourceKey = searchParams.get('source');
+ const categoryId = searchParams.get('categoryId');
+ const page = searchParams.get('page') || '1';
+
+ if (!sourceKey) {
+ return NextResponse.json(
+ { error: '缺少参数: source' },
+ { status: 400 }
+ );
+ }
+
+ if (!categoryId) {
+ return NextResponse.json(
+ { error: '缺少参数: categoryId' },
+ { status: 400 }
+ );
+ }
+
+ try {
+ const apiSites = await getAvailableApiSites(authInfo.username);
+ const targetSite = apiSites.find((site) => site.key === sourceKey);
+
+ if (!targetSite) {
+ return NextResponse.json(
+ { error: `未找到指定的视频源: ${sourceKey}` },
+ { status: 404 }
+ );
+ }
+
+ // 请求分类视频列表
+ const videoUrl = `${targetSite.api}?ac=videolist&t=${categoryId}&pg=${page}`;
+ const videoResponse = await fetch(videoUrl, {
+ headers: API_CONFIG.search.headers,
+ signal: AbortSignal.timeout(10000),
+ });
+
+ if (!videoResponse.ok) {
+ throw new Error('获取视频列表失败');
+ }
+
+ const videoData: CmsVideoResponse = await videoResponse.json();
+
+ if (!videoData.list || !Array.isArray(videoData.list)) {
+ return NextResponse.json({
+ results: [],
+ total: 0,
+ page: parseInt(page),
+ pageCount: 0,
+ });
+ }
+
+ // 转换为 SearchResult 格式
+ const results: SearchResult[] = videoData.list.map((item) => {
+ const episodes: string[] = [];
+ const episodes_titles: string[] = [];
+
+ // 解析播放信息
+ if (item.vod_play_url && item.vod_play_from) {
+ const playUrls = item.vod_play_url.split('#');
+ playUrls.forEach((episodeStr) => {
+ if (episodeStr.trim()) {
+ const [name, url] = episodeStr.split('$');
+ if (name && url) {
+ episodes.push(url.trim());
+ episodes_titles.push(name.trim());
+ }
+ }
+ });
+ }
+
+ return {
+ id: item.vod_id.toString(),
+ title: item.vod_name,
+ poster: item.vod_pic || '',
+ year: item.vod_year || 'unknown',
+ episodes,
+ episodes_titles,
+ source: targetSite.key,
+ source_name: targetSite.name,
+ };
+ });
+
+ return NextResponse.json({
+ results,
+ total: videoData.total || 0,
+ page: parseInt(page),
+ pageCount: videoData.pagecount || 0,
+ });
+ } catch (error) {
+ console.error('Failed to get videos:', error);
+ return NextResponse.json(
+ { error: '获取视频列表失败' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 0b57b60..746330f 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -155,6 +155,7 @@ export default async function RootLayout({
AI_ENABLE_HOMEPAGE_ENTRY: aiEnableHomepageEntry,
AI_ENABLE_VIDEOCARD_ENTRY: aiEnableVideoCardEntry,
AI_ENABLE_PLAYPAGE_ENTRY: aiEnablePlayPageEntry,
+ ENABLE_SOURCE_SEARCH: process.env.NEXT_PUBLIC_ENABLE_SOURCE_SEARCH !== 'false',
};
return (
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 297e477..6f38ff6 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -2,7 +2,7 @@
'use client';
-import { ChevronRight, Bot } from 'lucide-react';
+import { ChevronRight, Bot, ListVideo } from 'lucide-react';
import Link from 'next/link';
import { Suspense, useCallback, useEffect, useRef, useState } from 'react';
@@ -40,6 +40,7 @@ function HomeClient() {
const [showHttpWarning, setShowHttpWarning] = useState(true);
const [showAIChat, setShowAIChat] = useState(false);
const [aiEnabled, setAiEnabled] = useState(false);
+ const [sourceSearchEnabled, setSourceSearchEnabled] = useState(true);
// 检查AI功能是否启用
useEffect(() => {
@@ -51,6 +52,14 @@ function HomeClient() {
}
}, []);
+ // 检查源站寻片功能是否启用
+ useEffect(() => {
+ if (typeof window !== 'undefined') {
+ const enabled = (window as any).RUNTIME_CONFIG?.ENABLE_SOURCE_SEARCH !== false;
+ setSourceSearchEnabled(enabled);
+ }
+ }, []);
+
// 检查公告弹窗状态
useEffect(() => {
if (typeof window !== 'undefined' && announcement) {
@@ -154,9 +163,22 @@ function HomeClient() {
{/* 首页内容 */}
<>
- {/* AI问片入口 */}
- {aiEnabled && (
-
+ {/* 源站寻片和AI问片入口 */}
+
+ {/* 源站寻片入口 */}
+ {sourceSearchEnabled && (
+
+
+
+ )}
+
+ {/* AI问片入口 */}
+ {aiEnabled && (
-
- )}
+ )}
+
{/* 继续观看 */}
diff --git a/src/app/source-search/page.tsx b/src/app/source-search/page.tsx
new file mode 100644
index 0000000..1c51257
--- /dev/null
+++ b/src/app/source-search/page.tsx
@@ -0,0 +1,397 @@
+/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
+'use client';
+
+import { Loader2, Search } from 'lucide-react';
+import { Suspense, useEffect, useRef, useState } from 'react';
+
+import { ApiSite } from '@/lib/config';
+import { SearchResult } from '@/lib/types';
+
+import CapsuleSwitch from '@/components/CapsuleSwitch';
+import PageLayout from '@/components/PageLayout';
+import VideoCard from '@/components/VideoCard';
+
+interface Category {
+ id: string;
+ name: string;
+}
+
+type ViewMode = 'browse' | 'search';
+
+function SourceSearchPageClient() {
+ const [apiSites, setApiSites] = useState
([]);
+ const [selectedSource, setSelectedSource] = useState('');
+ const [categories, setCategories] = useState([]);
+ const [selectedCategory, setSelectedCategory] = useState('');
+ const [videos, setVideos] = useState([]);
+ const [isLoadingSources, setIsLoadingSources] = useState(true);
+ const [isLoadingCategories, setIsLoadingCategories] = useState(false);
+ const [isLoadingVideos, setIsLoadingVideos] = useState(false);
+ const [currentPage, setCurrentPage] = useState(1);
+ const [hasMore, setHasMore] = useState(true);
+ const [viewMode, setViewMode] = useState('browse');
+ const [searchKeyword, setSearchKeyword] = useState('');
+ const [searchInputValue, setSearchInputValue] = useState('');
+ const loadMoreRef = useRef(null);
+
+ // 加载用户可用的视频源
+ useEffect(() => {
+ const fetchApiSites = async () => {
+ setIsLoadingSources(true);
+ try {
+ const response = await fetch('/api/source-search/sources');
+ const data = await response.json();
+ if (data.sources && Array.isArray(data.sources)) {
+ setApiSites(data.sources);
+ // 默认选择第一个源
+ if (data.sources.length > 0) {
+ setSelectedSource(data.sources[0].key);
+ }
+ }
+ } catch (error) {
+ console.error('Failed to load API sources:', error);
+ } finally {
+ setIsLoadingSources(false);
+ }
+ };
+
+ fetchApiSites();
+ }, []);
+
+ // 当选择的源变化时,加载分类列表
+ useEffect(() => {
+ if (!selectedSource) return;
+
+ const fetchCategories = async () => {
+ setIsLoadingCategories(true);
+ setCategories([]);
+ setSelectedCategory('');
+ setVideos([]);
+ setCurrentPage(1);
+ setHasMore(true);
+ try {
+ const response = await fetch(
+ `/api/source-search/categories?source=${encodeURIComponent(selectedSource)}`
+ );
+ const data = await response.json();
+ if (data.categories && Array.isArray(data.categories)) {
+ setCategories(data.categories);
+ // 默认选择第一个分类
+ if (data.categories.length > 0) {
+ setSelectedCategory(data.categories[0].id);
+ }
+ }
+ } catch (error) {
+ console.error('Failed to load categories:', error);
+ } finally {
+ setIsLoadingCategories(false);
+ }
+ };
+
+ fetchCategories();
+ }, [selectedSource]);
+
+ // 当选择的分类或页码变化时,加载视频列表(浏览模式)
+ useEffect(() => {
+ if (viewMode !== 'browse' || !selectedSource || !selectedCategory) return;
+
+ const fetchVideos = async () => {
+ setIsLoadingVideos(true);
+ try {
+ const response = await fetch(
+ `/api/source-search/videos?source=${encodeURIComponent(selectedSource)}&categoryId=${encodeURIComponent(selectedCategory)}&page=${currentPage}`
+ );
+ const data = await response.json();
+ if (data.results && Array.isArray(data.results)) {
+ if (currentPage === 1) {
+ setVideos(data.results);
+ } else {
+ setVideos((prev) => [...prev, ...data.results]);
+ }
+ setHasMore(data.page < data.pageCount);
+ }
+ } catch (error) {
+ console.error('Failed to load videos:', error);
+ } finally {
+ setIsLoadingVideos(false);
+ }
+ };
+
+ fetchVideos();
+ }, [selectedSource, selectedCategory, currentPage, viewMode]);
+
+ // 当搜索关键词或页码变化时,执行搜索(搜索模式)
+ useEffect(() => {
+ if (viewMode !== 'search' || !selectedSource || !searchKeyword) return;
+
+ const searchVideos = async () => {
+ setIsLoadingVideos(true);
+ try {
+ const response = await fetch(
+ `/api/source-search/search?source=${encodeURIComponent(selectedSource)}&keyword=${encodeURIComponent(searchKeyword)}&page=${currentPage}`
+ );
+ const data = await response.json();
+ if (data.results && Array.isArray(data.results)) {
+ if (currentPage === 1) {
+ setVideos(data.results);
+ } else {
+ setVideos((prev) => [...prev, ...data.results]);
+ }
+ setHasMore(data.page < data.pageCount);
+ }
+ } catch (error) {
+ console.error('Failed to search videos:', error);
+ } finally {
+ setIsLoadingVideos(false);
+ }
+ };
+
+ searchVideos();
+ }, [selectedSource, searchKeyword, currentPage, viewMode]);
+
+ // 当分类变化时,重置到第一页
+ useEffect(() => {
+ setCurrentPage(1);
+ setVideos([]);
+ setHasMore(true);
+ }, [selectedCategory]);
+
+ // 处理搜索提交
+ const handleSearch = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (searchInputValue.trim()) {
+ setSearchKeyword(searchInputValue.trim());
+ setViewMode('search');
+ setCurrentPage(1);
+ setVideos([]);
+ setHasMore(true);
+ }
+ };
+
+ // 切换回浏览模式
+ const handleBackToBrowse = () => {
+ setViewMode('browse');
+ setSearchKeyword('');
+ setSearchInputValue('');
+ setCurrentPage(1);
+ setVideos([]);
+ setHasMore(true);
+ };
+
+ // Intersection Observer for infinite scroll
+ useEffect(() => {
+ if (!loadMoreRef.current) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const target = entries[0];
+ if (target.isIntersecting && hasMore && !isLoadingVideos) {
+ setCurrentPage((prev) => prev + 1);
+ }
+ },
+ { threshold: 0.1 }
+ );
+
+ observer.observe(loadMoreRef.current);
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [hasMore, isLoadingVideos]);
+
+ return (
+
+
+ {/* 页面标题 */}
+
+
+ 源站寻片
+
+
+ 根据可用视频源浏览分类内容
+
+
+
+ {/* 源选择和分类选择 */}
+
+ {/* 源选择 CapsuleSwitch */}
+
+
+ {isLoadingSources ? (
+
+
+
+ 加载视频源中...
+
+
+ ) : apiSites.length === 0 ? (
+
+
+ 暂无可用源
+
+
+ ) : (
+
+ ({
+ label: site.name,
+ value: site.key,
+ }))}
+ active={selectedSource}
+ onChange={(value) => {
+ setSelectedSource(value);
+ handleBackToBrowse();
+ }}
+ />
+
+ )}
+
+
+ {/* 搜索框 */}
+ {selectedSource && (
+
+ )}
+
+ {/* 搜索结果提示和返回按钮 */}
+ {viewMode === 'search' && searchKeyword && (
+
+
+ 搜索结果: {searchKeyword}
+
+
+
+ )}
+
+ {/* 分类选择 CapsuleSwitch */}
+ {selectedSource && viewMode === 'browse' && (
+
+
+ {isLoadingCategories ? (
+
+
+
+ 加载分类中...
+
+
+ ) : categories.length === 0 ? (
+
+
+ 暂无分类
+
+
+ ) : (
+
+ ({
+ label: category.name,
+ value: category.id,
+ }))}
+ active={selectedCategory}
+ onChange={setSelectedCategory}
+ />
+
+ )}
+
+ )}
+
+
+ {/* 视频列表 */}
+ {selectedSource && (viewMode === 'search' ? searchKeyword : selectedCategory) && (
+
+
+
+ 视频列表
+
+
+
+ {isLoadingVideos && currentPage === 1 ? (
+
+ ) : videos.length === 0 ? (
+
+ 暂无视频
+
+ ) : (
+ <>
+
+ {videos.map((item) => (
+
+ 1 ? 'tv' : 'movie'}
+ cmsData={{
+ desc: item.desc,
+ episodes: item.episodes,
+ episodes_titles: item.episodes_titles,
+ }}
+ />
+
+ ))}
+
+
+ {/* Infinite scroll trigger */}
+
+ {isLoadingVideos && (
+
+ )}
+ {!hasMore && videos.length > 0 && (
+
+ 没有更多了
+
+ )}
+
+ >
+ )}
+
+ )}
+
+
+ );
+}
+
+export default function SourceSearchPage() {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/CapsuleSwitch.tsx b/src/components/CapsuleSwitch.tsx
index 1e3396a..15e118f 100644
--- a/src/components/CapsuleSwitch.tsx
+++ b/src/components/CapsuleSwitch.tsx
@@ -16,7 +16,14 @@ const CapsuleSwitch: React.FC = ({
className,
}) => {
const containerRef = useRef(null);
+ const scrollContainerRef = useRef(null);
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
+ const isScrollingRef = useRef(false);
+ const scrollTimeoutRef = useRef(null);
+ const isDraggingRef = useRef(false);
+ const startXRef = useRef(0);
+ const scrollLeftRef = useRef(0);
+ const hasDraggedRef = useRef(false);
const [indicatorStyle, setIndicatorStyle] = useState<{
left: number;
width: number;
@@ -24,79 +31,205 @@ const CapsuleSwitch: React.FC = ({
const activeIndex = options.findIndex((opt) => opt.value === active);
- // 更新指示器位置
- const updateIndicatorPosition = () => {
+ // 更新指示器位置(仅更新位置,不触发滚动)
+ const updateIndicatorPosition = (autoScroll = false) => {
if (
activeIndex >= 0 &&
buttonRefs.current[activeIndex] &&
- containerRef.current
+ containerRef.current &&
+ scrollContainerRef.current
) {
const button = buttonRefs.current[activeIndex];
- const container = containerRef.current;
- if (button && container) {
- const buttonRect = button.getBoundingClientRect();
- const containerRect = container.getBoundingClientRect();
+ const scrollContainer = scrollContainerRef.current;
- if (buttonRect.width > 0) {
- setIndicatorStyle({
- left: buttonRect.left - containerRect.left,
- width: buttonRect.width,
- });
+ if (button) {
+ const buttonOffsetLeft = button.offsetLeft;
+ const buttonWidth = button.offsetWidth;
+
+ setIndicatorStyle({
+ left: buttonOffsetLeft,
+ width: buttonWidth,
+ });
+
+ // 只在需要自动滚动时才执行
+ if (autoScroll && !isScrollingRef.current) {
+ const buttonRect = button.getBoundingClientRect();
+ const scrollContainerRect = scrollContainer.getBoundingClientRect();
+ const isVisible =
+ buttonRect.left >= scrollContainerRect.left &&
+ buttonRect.right <= scrollContainerRect.right;
+
+ if (!isVisible) {
+ // 将选中项滚动到视图中心
+ const scrollToPosition =
+ buttonOffsetLeft -
+ scrollContainer.offsetWidth / 2 +
+ buttonWidth / 2;
+ scrollContainer.scrollTo({
+ left: scrollToPosition,
+ behavior: 'smooth',
+ });
+ }
}
}
}
};
- // 组件挂载时立即计算初始位置
+ // 组件挂载时立即计算初始位置并滚动到选中项
useEffect(() => {
- const timeoutId = setTimeout(updateIndicatorPosition, 0);
+ const timeoutId = setTimeout(() => updateIndicatorPosition(true), 0);
return () => clearTimeout(timeoutId);
}, []);
- // 监听选中项变化
+ // 监听选中项变化,自动滚动到新选中项
useEffect(() => {
- const timeoutId = setTimeout(updateIndicatorPosition, 0);
+ const timeoutId = setTimeout(() => updateIndicatorPosition(true), 0);
return () => clearTimeout(timeoutId);
}, [activeIndex]);
+ // 监听滚动事件,仅更新指示器位置
+ useEffect(() => {
+ const scrollContainer = scrollContainerRef.current;
+ if (!scrollContainer) return;
+
+ const handleScroll = () => {
+ // 标记正在滚动
+ isScrollingRef.current = true;
+
+ // 清除之前的超时
+ if (scrollTimeoutRef.current) {
+ clearTimeout(scrollTimeoutRef.current);
+ }
+
+ // 仅更新指示器位置,不触发自动滚动
+ updateIndicatorPosition(false);
+
+ // 滚动结束后重置标记
+ scrollTimeoutRef.current = setTimeout(() => {
+ isScrollingRef.current = false;
+ }, 150);
+ };
+
+ scrollContainer.addEventListener('scroll', handleScroll, { passive: true });
+ return () => {
+ scrollContainer.removeEventListener('scroll', handleScroll);
+ if (scrollTimeoutRef.current) {
+ clearTimeout(scrollTimeoutRef.current);
+ }
+ };
+ }, [activeIndex]);
+
+ // 鼠标拖动功能
+ useEffect(() => {
+ const scrollContainer = scrollContainerRef.current;
+ if (!scrollContainer) return;
+
+ const handleMouseDown = (e: MouseEvent) => {
+ isDraggingRef.current = true;
+ hasDraggedRef.current = false;
+ startXRef.current = e.pageX - scrollContainer.offsetLeft;
+ scrollLeftRef.current = scrollContainer.scrollLeft;
+ scrollContainer.style.cursor = 'grabbing';
+ scrollContainer.style.userSelect = 'none';
+ };
+
+ const handleMouseLeave = () => {
+ isDraggingRef.current = false;
+ scrollContainer.style.cursor = 'grab';
+ };
+
+ const handleMouseUp = () => {
+ isDraggingRef.current = false;
+ scrollContainer.style.cursor = 'grab';
+ // 短暂延迟后重置拖动标记,防止点击事件被触发
+ setTimeout(() => {
+ hasDraggedRef.current = false;
+ }, 50);
+ };
+
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!isDraggingRef.current) return;
+ e.preventDefault();
+ const x = e.pageX - scrollContainer.offsetLeft;
+ const walk = (x - startXRef.current) * 1.5; // 调整拖动速度
+
+ // 如果移动距离超过5px,标记为已拖动
+ if (Math.abs(walk) > 5) {
+ hasDraggedRef.current = true;
+ }
+
+ scrollContainer.scrollLeft = scrollLeftRef.current - walk;
+ };
+
+ scrollContainer.style.cursor = 'grab';
+ scrollContainer.addEventListener('mousedown', handleMouseDown);
+ scrollContainer.addEventListener('mouseleave', handleMouseLeave);
+ scrollContainer.addEventListener('mouseup', handleMouseUp);
+ scrollContainer.addEventListener('mousemove', handleMouseMove);
+
+ return () => {
+ scrollContainer.removeEventListener('mousedown', handleMouseDown);
+ scrollContainer.removeEventListener('mouseleave', handleMouseLeave);
+ scrollContainer.removeEventListener('mouseup', handleMouseUp);
+ scrollContainer.removeEventListener('mousemove', handleMouseMove);
+ };
+ }, []);
+
return (
- {/* 滑动的白色背景指示器 */}
- {indicatorStyle.width > 0 && (
-
- )}
-
- {options.map((opt, index) => {
- const isActive = active === opt.value;
- return (
-