高级推荐

This commit is contained in:
mtvpls
2026-03-29 11:41:03 +08:00
parent 8acc50b198
commit 7547f78472
7 changed files with 397 additions and 4 deletions
+229
View File
@@ -0,0 +1,229 @@
'use client';
import { Blend, Loader2 } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { SearchResult } from '@/lib/types';
import CapsuleSwitch from '@/components/CapsuleSwitch';
import PageLayout from '@/components/PageLayout';
import VideoCard from '@/components/VideoCard';
interface ScriptSourceOption {
key: string;
name: string;
description?: string;
}
export default function AdvancedRecommendationPage() {
const router = useRouter();
const searchParams = useSearchParams();
const loadMoreRef = useRef<HTMLDivElement>(null);
const initialUrlSourceRef = useRef(searchParams.get('source') || '');
const [sources, setSources] = useState<ScriptSourceOption[]>([]);
const [selectedSource, setSelectedSource] = useState('');
const [videos, setVideos] = useState<SearchResult[]>([]);
const [isLoadingSources, setIsLoadingSources] = useState(true);
const [isLoadingVideos, setIsLoadingVideos] = useState(false);
const [error, setError] = useState('');
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const initializedRef = useRef(false);
const hasSyncedUrlRef = useRef(false);
useEffect(() => {
const fetchSources = async () => {
setIsLoadingSources(true);
try {
const response = await fetch('/api/advanced-recommendation/sources');
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '获取脚本源失败');
}
const nextSources: ScriptSourceOption[] = Array.isArray(data.sources)
? data.sources
: [];
setSources(nextSources);
const initialSource =
nextSources.find((item) => item.key === initialUrlSourceRef.current)
?.key ||
nextSources[0]?.key ||
'';
setSelectedSource(initialSource);
} catch (err) {
setError(err instanceof Error ? err.message : '获取脚本源失败');
} finally {
setIsLoadingSources(false);
initializedRef.current = true;
}
};
fetchSources();
}, []);
useEffect(() => {
if (!initializedRef.current || !selectedSource) return;
if (!hasSyncedUrlRef.current) {
hasSyncedUrlRef.current = true;
if (initialUrlSourceRef.current === selectedSource) return;
}
const params = new URLSearchParams();
params.set('source', selectedSource);
router.replace(`/advanced-recommendation?${params.toString()}`, {
scroll: false,
});
}, [selectedSource, router]);
useEffect(() => {
if (!selectedSource) return;
setVideos([]);
setPage(1);
setHasMore(true);
setError('');
}, [selectedSource]);
useEffect(() => {
if (!selectedSource) return;
const fetchVideos = async () => {
setIsLoadingVideos(true);
try {
const response = await fetch(
`/api/advanced-recommendation/videos?source=${encodeURIComponent(selectedSource)}&page=${page}`
);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '获取推荐失败');
}
const nextResults = Array.isArray(data.results) ? data.results : [];
setVideos((prev) => (page === 1 ? nextResults : [...prev, ...nextResults]));
setHasMore(Number(data.page || page) < Number(data.pageCount || 1));
} catch (err) {
setError(err instanceof Error ? err.message : '获取推荐失败');
} finally {
setIsLoadingVideos(false);
}
};
fetchVideos();
}, [selectedSource, page]);
useEffect(() => {
if (!loadMoreRef.current || !hasMore || isLoadingVideos) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
setPage((prev) => prev + 1);
}
},
{ threshold: 0.1 }
);
observer.observe(loadMoreRef.current);
return () => observer.disconnect();
}, [hasMore, isLoadingVideos]);
return (
<PageLayout activePath='/advanced-recommendation'>
<div className='px-4 sm:px-10 py-4 sm:py-8 mb-10'>
<div className='mb-6'>
<h1 className='text-2xl font-bold text-gray-800 dark:text-gray-200 flex items-center gap-2'>
<Blend className='w-6 h-6 text-green-500' />
</h1>
<p className='text-sm text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<div className='max-w-6xl mx-auto space-y-6'>
<div>
<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>
) : sources.length === 0 ? (
<div className='flex items-center justify-center h-24 rounded-xl border border-dashed border-gray-300 bg-gray-50/70 text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400'>
</div>
) : (
<div className='flex justify-center'>
<CapsuleSwitch
options={sources.map((item) => ({
label: item.name,
value: item.key,
}))}
active={selectedSource}
onChange={setSelectedSource}
/>
</div>
)}
</div>
{!!error && (
<div className='rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600 dark:border-red-900/50 dark:bg-red-900/10 dark:text-red-300'>
{error}
</div>
)}
{!isLoadingSources && sources.length > 0 && (
<>
{videos.length > 0 ? (
<div className='grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3 sm:gap-4'>
{videos.map((video, index) => (
<VideoCard
key={`${video.source}-${video.id}-${index}`}
id={video.id}
source={video.source}
source_name={video.source_name}
title={video.title}
poster={video.poster}
year={video.year}
rate={
typeof video.rating === 'number'
? String(video.rating)
: undefined
}
douban_id={video.douban_id}
tmdb_id={video.tmdb_id}
from='source-search'
/>
))}
</div>
) : !isLoadingVideos && !error ? (
<div className='flex items-center justify-center h-32 rounded-xl border border-dashed border-gray-300 bg-gray-50/70 text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400'>
</div>
) : null}
{isLoadingVideos && (
<div className='flex justify-center py-6'>
<Loader2 className='h-6 w-6 animate-spin text-gray-400' />
</div>
)}
<div ref={loadMoreRef} className='h-10' />
</>
)}
</div>
</div>
</PageLayout>
);
}