新增源站寻片
This commit is contained in:
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -155,6 +155,7 @@ export default async function RootLayout({
|
|||||||
AI_ENABLE_HOMEPAGE_ENTRY: aiEnableHomepageEntry,
|
AI_ENABLE_HOMEPAGE_ENTRY: aiEnableHomepageEntry,
|
||||||
AI_ENABLE_VIDEOCARD_ENTRY: aiEnableVideoCardEntry,
|
AI_ENABLE_VIDEOCARD_ENTRY: aiEnableVideoCardEntry,
|
||||||
AI_ENABLE_PLAYPAGE_ENTRY: aiEnablePlayPageEntry,
|
AI_ENABLE_PLAYPAGE_ENTRY: aiEnablePlayPageEntry,
|
||||||
|
ENABLE_SOURCE_SEARCH: process.env.NEXT_PUBLIC_ENABLE_SOURCE_SEARCH !== 'false',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+28
-6
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ChevronRight, Bot } from 'lucide-react';
|
import { ChevronRight, Bot, ListVideo } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Suspense, useCallback, useEffect, useRef, useState } from 'react';
|
import { Suspense, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
@@ -40,6 +40,7 @@ function HomeClient() {
|
|||||||
const [showHttpWarning, setShowHttpWarning] = useState(true);
|
const [showHttpWarning, setShowHttpWarning] = useState(true);
|
||||||
const [showAIChat, setShowAIChat] = useState(false);
|
const [showAIChat, setShowAIChat] = useState(false);
|
||||||
const [aiEnabled, setAiEnabled] = useState(false);
|
const [aiEnabled, setAiEnabled] = useState(false);
|
||||||
|
const [sourceSearchEnabled, setSourceSearchEnabled] = useState(true);
|
||||||
|
|
||||||
// 检查AI功能是否启用
|
// 检查AI功能是否启用
|
||||||
useEffect(() => {
|
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(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined' && announcement) {
|
if (typeof window !== 'undefined' && announcement) {
|
||||||
@@ -154,9 +163,22 @@ function HomeClient() {
|
|||||||
<div className='max-w-[95%] mx-auto'>
|
<div className='max-w-[95%] mx-auto'>
|
||||||
{/* 首页内容 */}
|
{/* 首页内容 */}
|
||||||
<>
|
<>
|
||||||
{/* AI问片入口 */}
|
{/* 源站寻片和AI问片入口 */}
|
||||||
{aiEnabled && (
|
<div className='flex items-center justify-end gap-2 mb-4'>
|
||||||
<div className='flex items-center justify-end mb-4'>
|
{/* 源站寻片入口 */}
|
||||||
|
{sourceSearchEnabled && (
|
||||||
|
<Link href='/source-search'>
|
||||||
|
<button
|
||||||
|
className='p-2 rounded-lg text-blue-500 hover:text-blue-600 transition-colors'
|
||||||
|
title='源站寻片'
|
||||||
|
>
|
||||||
|
<ListVideo size={20} />
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI问片入口 */}
|
||||||
|
{aiEnabled && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAIChat(true)}
|
onClick={() => setShowAIChat(true)}
|
||||||
className='p-2 rounded-lg text-purple-500 hover:text-purple-600 transition-colors'
|
className='p-2 rounded-lg text-purple-500 hover:text-purple-600 transition-colors'
|
||||||
@@ -164,8 +186,8 @@ function HomeClient() {
|
|||||||
>
|
>
|
||||||
<Bot size={20} />
|
<Bot size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* 继续观看 */}
|
{/* 继续观看 */}
|
||||||
<ContinueWatching />
|
<ContinueWatching />
|
||||||
|
|||||||
@@ -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<ApiSite[]>([]);
|
||||||
|
const [selectedSource, setSelectedSource] = useState<string>('');
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string>('');
|
||||||
|
const [videos, setVideos] = useState<SearchResult[]>([]);
|
||||||
|
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<ViewMode>('browse');
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState<string>('');
|
||||||
|
const [searchInputValue, setSearchInputValue] = useState<string>('');
|
||||||
|
const loadMoreRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<PageLayout activePath='/source-search'>
|
||||||
|
<div className='px-4 sm:px-10 py-4 sm:py-8 overflow-visible mb-10'>
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<div className='mb-6'>
|
||||||
|
<h1 className='text-2xl font-bold text-gray-800 dark:text-gray-200'>
|
||||||
|
源站寻片
|
||||||
|
</h1>
|
||||||
|
<p className='text-sm text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
根据可用视频源浏览分类内容
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 源选择和分类选择 */}
|
||||||
|
<div className='max-w-4xl mx-auto mb-8 space-y-6'>
|
||||||
|
{/* 源选择 CapsuleSwitch */}
|
||||||
|
<div className='relative'>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3'>
|
||||||
|
选择视频源
|
||||||
|
</label>
|
||||||
|
{isLoadingSources ? (
|
||||||
|
<div className='flex items-center justify-center h-12 bg-gray-50/80 rounded-lg border border-gray-200/50 dark:bg-gray-800 dark:border-gray-700'>
|
||||||
|
<Loader2 className='h-5 w-5 animate-spin text-gray-400' />
|
||||||
|
<span className='ml-2 text-sm text-gray-500 dark:text-gray-400'>
|
||||||
|
加载视频源中...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : apiSites.length === 0 ? (
|
||||||
|
<div className='flex items-center justify-center h-12 bg-gray-50/80 rounded-lg border border-gray-200/50 dark:bg-gray-800 dark:border-gray-700'>
|
||||||
|
<span className='text-sm text-gray-500 dark:text-gray-400'>
|
||||||
|
暂无可用源
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='flex justify-center'>
|
||||||
|
<CapsuleSwitch
|
||||||
|
options={apiSites.map((site) => ({
|
||||||
|
label: site.name,
|
||||||
|
value: site.key,
|
||||||
|
}))}
|
||||||
|
active={selectedSource}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSelectedSource(value);
|
||||||
|
handleBackToBrowse();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索框 */}
|
||||||
|
{selectedSource && (
|
||||||
|
<div className='relative'>
|
||||||
|
<form onSubmit={handleSearch}>
|
||||||
|
<div className='relative'>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={searchInputValue}
|
||||||
|
onChange={(e) => setSearchInputValue(e.target.value)}
|
||||||
|
placeholder='搜索视频...'
|
||||||
|
className='w-full h-12 rounded-lg bg-gray-50/80 py-3 pl-4 pr-12 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:bg-white border border-gray-200/50 shadow-sm dark:bg-gray-800 dark:text-gray-300 dark:focus:bg-gray-700 dark:border-gray-700'
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type='submit'
|
||||||
|
className='absolute right-2 top-1/2 -translate-y-1/2 p-2 rounded-lg text-blue-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-gray-600 transition-colors'
|
||||||
|
>
|
||||||
|
<Search size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 搜索结果提示和返回按钮 */}
|
||||||
|
{viewMode === 'search' && searchKeyword && (
|
||||||
|
<div className='flex items-center justify-between bg-blue-50/80 dark:bg-blue-900/20 border border-blue-200/50 dark:border-blue-800/50 rounded-lg px-4 py-3'>
|
||||||
|
<span className='text-sm text-gray-700 dark:text-gray-300'>
|
||||||
|
搜索结果: <span className='font-medium'>{searchKeyword}</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleBackToBrowse}
|
||||||
|
className='text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 font-medium'
|
||||||
|
>
|
||||||
|
返回分类浏览
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 分类选择 CapsuleSwitch */}
|
||||||
|
{selectedSource && viewMode === 'browse' && (
|
||||||
|
<div className='relative'>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3'>
|
||||||
|
选择分类
|
||||||
|
</label>
|
||||||
|
{isLoadingCategories ? (
|
||||||
|
<div className='flex items-center justify-center h-12 bg-gray-50/80 rounded-lg border border-gray-200/50 dark:bg-gray-800 dark:border-gray-700'>
|
||||||
|
<Loader2 className='h-5 w-5 animate-spin text-gray-400' />
|
||||||
|
<span className='ml-2 text-sm text-gray-500 dark:text-gray-400'>
|
||||||
|
加载分类中...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : categories.length === 0 ? (
|
||||||
|
<div className='flex items-center justify-center h-12 bg-gray-50/80 rounded-lg border border-gray-200/50 dark:bg-gray-800 dark:border-gray-700'>
|
||||||
|
<span className='text-sm text-gray-500 dark:text-gray-400'>
|
||||||
|
暂无分类
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='flex justify-center'>
|
||||||
|
<CapsuleSwitch
|
||||||
|
options={categories.map((category) => ({
|
||||||
|
label: category.name,
|
||||||
|
value: category.id,
|
||||||
|
}))}
|
||||||
|
active={selectedCategory}
|
||||||
|
onChange={setSelectedCategory}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 视频列表 */}
|
||||||
|
{selectedSource && (viewMode === 'search' ? searchKeyword : selectedCategory) && (
|
||||||
|
<div className='max-w-[95%] mx-auto mt-8'>
|
||||||
|
<div className='mb-4'>
|
||||||
|
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||||
|
视频列表
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoadingVideos && currentPage === 1 ? (
|
||||||
|
<div className='flex justify-center items-center h-40'>
|
||||||
|
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500'></div>
|
||||||
|
</div>
|
||||||
|
) : videos.length === 0 ? (
|
||||||
|
<div className='text-center text-gray-500 py-8 dark:text-gray-400'>
|
||||||
|
暂无视频
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className='grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8'>
|
||||||
|
{videos.map((item) => (
|
||||||
|
<div
|
||||||
|
key={`${item.source}-${item.id}`}
|
||||||
|
className='w-full'
|
||||||
|
>
|
||||||
|
<VideoCard
|
||||||
|
id={item.id}
|
||||||
|
title={item.title}
|
||||||
|
poster={item.poster}
|
||||||
|
episodes={item.episodes.length}
|
||||||
|
source={item.source}
|
||||||
|
source_name={item.source_name}
|
||||||
|
douban_id={item.douban_id}
|
||||||
|
year={item.year}
|
||||||
|
from='source-search'
|
||||||
|
type={item.episodes.length > 1 ? 'tv' : 'movie'}
|
||||||
|
cmsData={{
|
||||||
|
desc: item.desc,
|
||||||
|
episodes: item.episodes,
|
||||||
|
episodes_titles: item.episodes_titles,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Infinite scroll trigger */}
|
||||||
|
<div ref={loadMoreRef} className='flex justify-center items-center py-8'>
|
||||||
|
{isLoadingVideos && (
|
||||||
|
<div className='animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500'></div>
|
||||||
|
)}
|
||||||
|
{!hasMore && videos.length > 0 && (
|
||||||
|
<span className='text-sm text-gray-500 dark:text-gray-400'>
|
||||||
|
没有更多了
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SourceSearchPage() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<SourceSearchPageClient />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,14 @@ const CapsuleSwitch: React.FC<CapsuleSwitchProps> = ({
|
|||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||||
|
const isScrollingRef = useRef(false);
|
||||||
|
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const isDraggingRef = useRef(false);
|
||||||
|
const startXRef = useRef(0);
|
||||||
|
const scrollLeftRef = useRef(0);
|
||||||
|
const hasDraggedRef = useRef(false);
|
||||||
const [indicatorStyle, setIndicatorStyle] = useState<{
|
const [indicatorStyle, setIndicatorStyle] = useState<{
|
||||||
left: number;
|
left: number;
|
||||||
width: number;
|
width: number;
|
||||||
@@ -24,79 +31,205 @@ const CapsuleSwitch: React.FC<CapsuleSwitchProps> = ({
|
|||||||
|
|
||||||
const activeIndex = options.findIndex((opt) => opt.value === active);
|
const activeIndex = options.findIndex((opt) => opt.value === active);
|
||||||
|
|
||||||
// 更新指示器位置
|
// 更新指示器位置(仅更新位置,不触发滚动)
|
||||||
const updateIndicatorPosition = () => {
|
const updateIndicatorPosition = (autoScroll = false) => {
|
||||||
if (
|
if (
|
||||||
activeIndex >= 0 &&
|
activeIndex >= 0 &&
|
||||||
buttonRefs.current[activeIndex] &&
|
buttonRefs.current[activeIndex] &&
|
||||||
containerRef.current
|
containerRef.current &&
|
||||||
|
scrollContainerRef.current
|
||||||
) {
|
) {
|
||||||
const button = buttonRefs.current[activeIndex];
|
const button = buttonRefs.current[activeIndex];
|
||||||
const container = containerRef.current;
|
const scrollContainer = scrollContainerRef.current;
|
||||||
if (button && container) {
|
|
||||||
const buttonRect = button.getBoundingClientRect();
|
|
||||||
const containerRect = container.getBoundingClientRect();
|
|
||||||
|
|
||||||
if (buttonRect.width > 0) {
|
if (button) {
|
||||||
setIndicatorStyle({
|
const buttonOffsetLeft = button.offsetLeft;
|
||||||
left: buttonRect.left - containerRect.left,
|
const buttonWidth = button.offsetWidth;
|
||||||
width: buttonRect.width,
|
|
||||||
});
|
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(() => {
|
useEffect(() => {
|
||||||
const timeoutId = setTimeout(updateIndicatorPosition, 0);
|
const timeoutId = setTimeout(() => updateIndicatorPosition(true), 0);
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 监听选中项变化
|
// 监听选中项变化,自动滚动到新选中项
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeoutId = setTimeout(updateIndicatorPosition, 0);
|
const timeoutId = setTimeout(() => updateIndicatorPosition(true), 0);
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}, [activeIndex]);
|
}, [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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={`relative inline-flex bg-gray-300/80 rounded-full p-1 dark:bg-gray-700 ${
|
className={`relative inline-flex bg-gray-300/80 rounded-full p-1 dark:bg-gray-700 max-w-full ${
|
||||||
className || ''
|
className || ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* 滑动的白色背景指示器 */}
|
{/* 可滚动容器 */}
|
||||||
{indicatorStyle.width > 0 && (
|
<div
|
||||||
<div
|
ref={scrollContainerRef}
|
||||||
className='absolute top-1 bottom-1 bg-white dark:bg-gray-500 rounded-full shadow-sm transition-all duration-300 ease-out'
|
className='relative flex overflow-x-auto scrollbar-hide'
|
||||||
style={{
|
style={{
|
||||||
left: `${indicatorStyle.left}px`,
|
scrollbarWidth: 'none',
|
||||||
width: `${indicatorStyle.width}px`,
|
msOverflowStyle: 'none',
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
)}
|
{/* 滑动的白色背景指示器 */}
|
||||||
|
{indicatorStyle.width > 0 && (
|
||||||
{options.map((opt, index) => {
|
<div
|
||||||
const isActive = active === opt.value;
|
className='absolute top-0 bottom-0 bg-white dark:bg-gray-500 rounded-full shadow-sm transition-all duration-300 ease-out pointer-events-none'
|
||||||
return (
|
style={{
|
||||||
<button
|
left: `${indicatorStyle.left}px`,
|
||||||
key={opt.value}
|
width: `${indicatorStyle.width}px`,
|
||||||
ref={(el) => {
|
|
||||||
buttonRefs.current[index] = el;
|
|
||||||
}}
|
}}
|
||||||
onClick={() => onChange(opt.value)}
|
/>
|
||||||
className={`relative z-10 flex items-center justify-center gap-1.5 px-3 py-1 text-xs sm:px-4 sm:py-2 sm:text-sm rounded-full font-medium transition-all duration-200 cursor-pointer ${
|
)}
|
||||||
isActive
|
|
||||||
? 'text-gray-900 dark:text-gray-100'
|
{options.map((opt, index) => {
|
||||||
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'
|
const isActive = active === opt.value;
|
||||||
}`}
|
return (
|
||||||
>
|
<button
|
||||||
{opt.icon && <span className='inline-flex items-center'>{opt.icon}</span>}
|
key={opt.value}
|
||||||
{opt.label}
|
ref={(el) => {
|
||||||
</button>
|
buttonRefs.current[index] = el;
|
||||||
);
|
}}
|
||||||
})}
|
onClick={(e) => {
|
||||||
|
// 如果正在拖动,阻止点击
|
||||||
|
if (hasDraggedRef.current) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(opt.value);
|
||||||
|
}}
|
||||||
|
className={`relative z-10 flex items-center justify-center gap-1.5 px-3 py-1 text-xs sm:px-4 sm:py-2 sm:text-sm rounded-full font-medium transition-all duration-200 cursor-pointer whitespace-nowrap flex-shrink-0 ${
|
||||||
|
isActive
|
||||||
|
? 'text-gray-900 dark:text-gray-100'
|
||||||
|
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.icon && <span className='inline-flex items-center'>{opt.icon}</span>}
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export interface VideoCardProps {
|
|||||||
source_names?: string[];
|
source_names?: string[];
|
||||||
progress?: number;
|
progress?: number;
|
||||||
year?: string;
|
year?: string;
|
||||||
from: 'playrecord' | 'favorite' | 'search' | 'douban' | 'tmdb';
|
from: 'playrecord' | 'favorite' | 'search' | 'douban' | 'tmdb' | 'source-search';
|
||||||
currentEpisode?: number;
|
currentEpisode?: number;
|
||||||
douban_id?: number;
|
douban_id?: number;
|
||||||
tmdb_id?: number;
|
tmdb_id?: number;
|
||||||
@@ -441,6 +441,16 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
showRating: !!rate,
|
showRating: !!rate,
|
||||||
showYear: false,
|
showYear: false,
|
||||||
},
|
},
|
||||||
|
'source-search': {
|
||||||
|
showSourceName: false,
|
||||||
|
showProgress: false,
|
||||||
|
showPlayButton: true,
|
||||||
|
showHeart: true,
|
||||||
|
showCheckCircle: false,
|
||||||
|
showDoubanLink: true,
|
||||||
|
showRating: !!rate,
|
||||||
|
showYear: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return configs[from] || configs.search;
|
return configs[from] || configs.search;
|
||||||
}, [from, isAggregate, douban_id, rate, isUpcoming]);
|
}, [from, isAggregate, douban_id, rate, isUpcoming]);
|
||||||
|
|||||||
Reference in New Issue
Block a user