Files
MoonTVPlus/src/components/AcgSearch.tsx
2026-01-22 11:47:21 +08:00

415 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import { AlertCircle, Download, ExternalLink, Loader2 } from 'lucide-react';
import { useEffect, useState, useRef, useCallback } from 'react';
import Toast, { ToastProps } from '@/components/Toast';
import CapsuleSwitch from '@/components/CapsuleSwitch';
interface AcgSearchItem {
title: string;
link: string;
guid: string;
pubDate: string;
torrentUrl: string;
description: string;
images: string[];
}
interface AcgSearchResult {
keyword: string;
page: number;
total: number;
items: AcgSearchItem[];
}
interface AcgSearchProps {
keyword: string;
triggerSearch?: boolean;
onError?: (error: string) => void;
}
type AcgSearchSource = 'acgrip' | 'mikan' | 'dmhy';
export default function AcgSearch({
keyword,
triggerSearch,
onError,
}: AcgSearchProps) {
const [source, setSource] = useState<AcgSearchSource>('acgrip');
const [loading, setLoading] = useState(false);
const [allItems, setAllItems] = useState<AcgSearchItem[]>([]); // 所有加载的项目
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const [showNameDialog, setShowNameDialog] = useState(false);
const [selectedItem, setSelectedItem] = useState<AcgSearchItem | null>(null);
const [customName, setCustomName] = useState('');
const [toast, setToast] = useState<ToastProps | null>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
const isLoadingMoreRef = useRef(false);
const didInitSourceRef = useRef(false);
// 执行搜索
const performSearch = async (page: number, isLoadMore = false) => {
if (isLoadingMoreRef.current) return;
if (source === 'mikan' && page > 1) return;
if (source === 'dmhy' && page > 1) return;
isLoadingMoreRef.current = true;
setLoading(true);
setError(null);
try {
const apiUrl =
source === 'mikan'
? '/api/acg/mikan'
: source === 'dmhy'
? '/api/acg/dmhy'
: '/api/acg/acgrip';
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
keyword: keyword.trim(),
page,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || '搜索失败');
}
const data: AcgSearchResult = await response.json();
if (isLoadMore) {
// 追加新数据
setAllItems(prev => [...prev, ...data.items]);
// 如果当前页没有结果,说明没有更多了
setHasMore(source !== 'mikan' && source !== 'dmhy' && data.items.length > 0);
} else {
// 新搜索,重置数据
setAllItems(data.items);
// 如果第一页有结果,假设可能还有更多
setHasMore(source !== 'mikan' && source !== 'dmhy' && data.items.length > 0);
}
setCurrentPage(page);
} catch (err: any) {
const errorMsg = err.message || '搜索失败,请稍后重试';
setError(errorMsg);
onError?.(errorMsg);
} finally {
setLoading(false);
isLoadingMoreRef.current = false;
}
};
useEffect(() => {
// triggerSearch 变化时触发搜索(无论是 true 还是 false
if (triggerSearch === undefined) {
return;
}
const currentKeyword = keyword.trim();
if (!currentKeyword) {
return;
}
// 重置状态并开始新搜索
setAllItems([]);
setCurrentPage(1);
setHasMore(true);
performSearch(1, false);
}, [triggerSearch]);
// 切换搜索源时,自动重新搜索(避免组件初次挂载时重复触发)
useEffect(() => {
if (!didInitSourceRef.current) {
didInitSourceRef.current = true;
return;
}
const currentKeyword = keyword.trim();
if (!currentKeyword) return;
setAllItems([]);
setCurrentPage(1);
setHasMore(true);
performSearch(1, false);
}, [source]);
// 加载更多数据
const loadMore = useCallback(() => {
if (source === 'mikan') return;
if (source === 'dmhy') return;
if (!loading && hasMore && !isLoadingMoreRef.current) {
performSearch(currentPage + 1, true);
}
}, [loading, hasMore, currentPage, source]);
// 使用 Intersection Observer 监听滚动到底部
useEffect(() => {
const element = loadMoreRef.current;
if (!element) return;
const observer = new IntersectionObserver(
(entries) => {
const target = entries[0];
if (target.isIntersecting) {
loadMore();
}
},
{
root: null,
rootMargin: '100px',
threshold: 0.1,
}
);
observer.observe(element);
return () => {
observer.unobserve(element);
};
}, [loadMore]);
// 打开命名弹窗
const handleOpenDownloadDialog = (item: AcgSearchItem) => {
setSelectedItem(item);
setCustomName(keyword.trim());
setShowNameDialog(true);
};
// 确认下载
const handleConfirmDownload = async () => {
if (!selectedItem || !customName.trim()) {
return;
}
setDownloadingId(selectedItem.guid);
setShowNameDialog(false);
try {
const response = await fetch('/api/acg/download', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: selectedItem.torrentUrl,
name: customName.trim(),
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '添加下载任务失败');
}
setToast({
message: data.message || '已添加到离线下载队列',
type: 'success',
onClose: () => setToast(null),
});
} catch (err: any) {
setToast({
message: err.message || '添加下载任务失败',
type: 'error',
onClose: () => setToast(null),
});
} finally {
setDownloadingId(null);
setSelectedItem(null);
setCustomName('');
}
};
const renderBody = () => {
if (loading && allItems.length === 0) {
return (
<div className='flex items-center justify-center py-12'>
<div className='text-center'>
<Loader2 className='mx-auto h-8 w-8 animate-spin text-green-600 dark:text-green-400' />
<p className='mt-4 text-sm text-gray-600 dark:text-gray-400'>
...
</p>
</div>
</div>
);
}
if (error) {
return (
<div className='flex items-center justify-center py-12'>
<div className='text-center'>
<AlertCircle className='mx-auto h-12 w-12 text-red-500 dark:text-red-400' />
<p className='mt-4 text-sm text-red-600 dark:text-red-400'>{error}</p>
</div>
</div>
);
}
if (allItems.length === 0) {
return (
<div className='flex items-center justify-center py-12'>
<div className='text-center'>
<AlertCircle className='mx-auto h-12 w-12 text-gray-400 dark:text-gray-600' />
<p className='mt-4 text-sm text-gray-600 dark:text-gray-400'>
</p>
</div>
</div>
);
}
return (
<>
{/* 结果列表 */}
<div className='space-y-3'>
{allItems.map((item) => (
<div
key={item.guid}
className='p-4 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:border-green-400 dark:hover:border-green-600 transition-colors'
>
{/* 标题 */}
<div className='mb-2 font-medium text-gray-900 dark:text-gray-100'>
{item.title}
</div>
{/* 发布时间 */}
<div className='mb-2 text-xs text-gray-500 dark:text-gray-400'>
{new Date(item.pubDate).toLocaleString('zh-CN')}
</div>
{/* 图片预览 */}
{item.images && item.images.length > 0 && (
<div className='mb-3 flex gap-2 overflow-x-auto'>
{item.images.slice(0, 3).map((img, imgIndex) => (
<img
key={imgIndex}
src={img}
alt=''
className='h-20 w-auto rounded object-cover'
loading='lazy'
/>
))}
</div>
)}
{/* 操作按钮 */}
<div className='flex items-center gap-2'>
<button
onClick={() => handleOpenDownloadDialog(item)}
disabled={downloadingId === item.guid}
className='flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-green-600 text-white text-sm hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors'
title='存到私人影库'
>
{downloadingId === item.guid ? (
<>
<Loader2 className='h-4 w-4 animate-spin' />
<span>...</span>
</>
) : (
<>
<Download className='h-4 w-4' />
<span></span>
</>
)}
</button>
<a
href={item.link}
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-gray-200 text-gray-700 text-sm hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors'
title='查看详情'
>
<ExternalLink className='h-4 w-4' />
<span></span>
</a>
</div>
</div>
))}
</div>
{/* 加载更多指示器 */}
{source !== 'mikan' && source !== 'dmhy' && hasMore && (
<div ref={loadMoreRef} className='flex items-center justify-center py-8'>
<div className='text-center'>
<Loader2 className='mx-auto h-6 w-6 animate-spin text-green-600 dark:text-green-400' />
<p className='mt-2 text-sm text-gray-600 dark:text-gray-400'>
...
</p>
</div>
</div>
)}
{/* 命名弹窗 */}
{showNameDialog && (
<div className='fixed inset-0 z-[1000] flex items-center justify-center bg-black/50'>
<div className='bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-xl'>
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4'>
</h3>
<input
type='text'
value={customName}
onChange={(e) => setCustomName(e.target.value)}
placeholder='请输入资源名称'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500'
autoFocus
/>
<div className='mt-4 flex gap-2 justify-end'>
<button
onClick={() => {
setShowNameDialog(false);
setSelectedItem(null);
setCustomName('');
}}
className='px-4 py-2 rounded-lg bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors'
>
</button>
<button
onClick={handleConfirmDownload}
disabled={!customName.trim()}
className='px-4 py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors'
>
</button>
</div>
</div>
</div>
)}
</>
);
};
return (
<div className='space-y-6'>
{/* 搜索源切换 */}
<div className='flex justify-center'>
<CapsuleSwitch
options={[
{ label: 'ACG.RIP', value: 'acgrip' },
{ label: '蜜柑', value: 'mikan' },
{ label: '动漫花园', value: 'dmhy' },
]}
active={source}
onChange={(value) => setSource(value as AcgSearchSource)}
/>
</div>
{renderBody()}
{/* Toast 提示 */}
{toast && <Toast {...toast} />}
</div>
);
}