/* eslint-disable @typescript-eslint/no-explicit-any,no-console */ 'use client'; import { Activity, AlertCircle, Copy, Download, ExternalLink, Loader2, RefreshCw, } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useRef, useState } from 'react'; import { PansouLink, PansouSearchResult } from '@/lib/pansou.client'; import Toast, { ToastProps } from '@/components/Toast'; interface PansouSearchProps { keyword: string; triggerSearch?: boolean; // 触发搜索的标志 onError?: (error: string) => void; cloudTypes?: string[]; } type DownloadTool = 'aria2' | 'Transmission' | 'qBittorrent'; type MagnetHealthLevel = 'good' | 'ok' | 'risk' | 'unknown'; interface MagnetHealthView { health: MagnetHealthLevel; seeders: number; leechers: number; peers: number; message: string; infoHash?: string; source?: 'scrape' | 'cache'; durationMs?: number; } const downloadToolOptions: Array<{ value: DownloadTool; label: string }> = [ { value: 'aria2', label: 'aria2' }, { value: 'qBittorrent', label: 'qBittorrent' }, { value: 'Transmission', label: 'Transmission' }, ]; // 网盘类型映射 export const CLOUD_TYPE_NAMES: Record = { baidu: '百度网盘', aliyun: '阿里云盘', quark: '夸克网盘', tianyi: '天翼云盘', uc: 'UC网盘', mobile: '移动云盘', '115': '115网盘', pikpak: 'PikPak', xunlei: '迅雷网盘', '123': '123网盘', guangya: '光鸭云盘', magnet: '磁力链接', ed2k: '电驴链接', others: '其他', }; // 网盘类型颜色 const CLOUD_TYPE_COLORS: Record = { baidu: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200', aliyun: 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-200', quark: 'bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-200', tianyi: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-200', uc: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-200', mobile: 'bg-pink-100 text-pink-800 dark:bg-pink-900/40 dark:text-pink-200', '115': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-200', pikpak: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-200', xunlei: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-200', '123': 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-200', guangya: 'bg-lime-100 text-lime-800 dark:bg-lime-900/40 dark:text-lime-200', magnet: 'bg-gray-100 text-gray-800 dark:bg-gray-700/40 dark:text-gray-200', ed2k: 'bg-gray-100 text-gray-800 dark:bg-gray-700/40 dark:text-gray-200', others: 'bg-gray-100 text-gray-800 dark:bg-gray-700/40 dark:text-gray-200', }; const CHECKABLE_CLOUD_TYPES = new Set([ '115', 'aliyun', 'baidu', 'mobile', 'quark', 'tianyi', 'uc', 'xunlei', '123', ]); const CLOUD_TYPE_TO_CHECK_PLATFORM: Record = { '115': '115', aliyun: 'aliyun', baidu: 'baidu', mobile: 'cmcc', quark: 'quark', tianyi: 'tianyi', uc: 'uc', xunlei: 'xunlei', '123': 'pan123', }; type CheckItemStatus = | 'pending' | 'checking' | 'valid' | 'invalid' | 'unknown' | 'rate_limited'; interface NetdiskCheckTaskPayload { id: string; platform: string; status: 'running' | 'completed' | 'failed' | 'cancelled'; progress: { total: number; done: number; valid: number; invalid: number; unknown: number; rateLimited: number; currentBatch: number; totalBatches: number; }; results: Record< string, { status: CheckItemStatus; reason?: string; fromCache?: boolean } >; error?: string; } interface StoredCloudCheckState { taskId: string; task: NetdiskCheckTaskPayload; } const CHECK_STATUS_STYLE: Record = { pending: 'bg-gray-100 text-gray-700 dark:bg-gray-700/50 dark:text-gray-200', checking: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200', valid: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-200', invalid: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200', unknown: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-200', rate_limited: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-200', }; const CHECK_STATUS_TEXT: Record = { pending: '未检测', checking: '检测中', valid: '有效', invalid: '失效', unknown: '未知', rate_limited: '受限', }; export default function PansouSearch({ keyword, triggerSearch, onError, cloudTypes = [], }: PansouSearchProps) { const router = useRouter(); const [loading, setLoading] = useState(false); const [results, setResults] = useState(null); const [error, setError] = useState(null); const [copiedUrl, setCopiedUrl] = useState(null); const [selectedType, setSelectedType] = useState('all'); // 'all' 表示显示全部 const typeScrollContainerRef = useRef(null); const isTypeDraggingRef = useRef(false); const typeDragStartXRef = useRef(0); const typeDragScrollLeftRef = useRef(0); const [transferingUrl, setTransferingUrl] = useState(null); const [playingUrl, setPlayingUrl] = useState(null); const [downloadingUrl, setDownloadingUrl] = useState(null); const [showNameDialog, setShowNameDialog] = useState(false); const [selectedDownloadLink, setSelectedDownloadLink] = useState(null); const [customName, setCustomName] = useState(''); const [downloadTool, setDownloadTool] = useState('aria2'); const [toast, setToast] = useState(null); const [cooldownRemainingMs, setCooldownRemainingMs] = useState(0); const [checkStatesByType, setCheckStatesByType] = useState< Record >({}); const [magnetHealthMap, setMagnetHealthMap] = useState< Record >({}); const [magnetHealthCheckingIds, setMagnetHealthCheckingIds] = useState< Record >({}); useEffect(() => { setCooldownRemainingMs(0); setCheckStatesByType({}); setMagnetHealthMap({}); setMagnetHealthCheckingIds({}); }, [keyword, triggerSearch]); useEffect(() => { const runningEntries = Object.entries(checkStatesByType).filter( ([, state]) => state.task.status === 'running' ); if (runningEntries.length === 0) return; let cancelled = false; const timer = setInterval(async () => { const updates = await Promise.all( runningEntries.map(async ([cloudType, state]) => { try { const response = await fetch( `/api/netdisk/check/task?id=${encodeURIComponent(state.taskId)}` ); const data = await response.json(); if (!response.ok) { throw new Error(data.error || '获取检测进度失败'); } return { cloudType, taskId: state.taskId, task: data.task as NetdiskCheckTaskPayload, cooldownRemainingMs: Number(data.cooldownRemainingMs || 0), }; } catch (error) { return { cloudType, taskId: state.taskId, task: { ...state.task, status: 'failed', error: error instanceof Error ? error.message : '获取检测进度失败', } as NetdiskCheckTaskPayload, cooldownRemainingMs: 0, }; } }) ); if (cancelled) return; setCheckStatesByType((prev) => { const next = { ...prev }; updates.forEach((update) => { next[update.cloudType] = { taskId: update.taskId, task: update.task, }; }); return next; }); setCooldownRemainingMs( Math.max(0, ...updates.map((item) => item.cooldownRemainingMs)) ); updates.forEach((update) => { if (update.task.status === 'failed' && update.task.error) { setToast({ message: `${ CLOUD_TYPE_NAMES[update.cloudType] || update.cloudType }: ${update.task.error}`, type: 'error', onClose: () => setToast(null), }); } }); }, 2000); return () => { cancelled = true; clearInterval(timer); }; }, [checkStatesByType]); // 提取搜索函数,以便在重试时调用 const searchPansou = useCallback(async () => { const currentKeyword = keyword.trim(); if (!currentKeyword) { return; } setLoading(true); setError(null); setResults(null); setSelectedType('all'); try { const response = await fetch('/api/pansou/search', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ keyword: currentKeyword, cloud_types: cloudTypes, }), }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || '搜索失败'); } const data: PansouSearchResult = await response.json(); setResults(data); } catch (err: any) { const errorMsg = err.message || '搜索失败,请检查配置'; setError(errorMsg); onError?.(errorMsg); } finally { setLoading(false); } }, [keyword, onError, cloudTypes]); useEffect(() => { // triggerSearch 变化时触发搜索(无论是 true 还是 false) if (triggerSearch === undefined) { return; } searchPansou(); }, [triggerSearch]); // 只在触发标志变化时搜索,避免 keyword 变化自动搜索 const handleCopy = async (text: string, url: string) => { try { await navigator.clipboard.writeText(text); setCopiedUrl(url); setTimeout(() => setCopiedUrl(null), 2000); } catch (err) { console.error('复制失败:', err); } }; const handleOpenLink = (url: string) => { window.open(url, '_blank', 'noopener,noreferrer'); }; const handleQuarkTransfer = async (link: PansouLink) => { try { setTransferingUrl(link.url); const response = await fetch('/api/netdisk/quark/transfer', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ shareUrl: link.url, passcode: link.password || '', }), }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || '转存失败'); } setToast({ message: `转存成功,已保存到:${data.targetPath}`, type: 'success', onClose: () => setToast(null), }); } catch (err: any) { setToast({ message: err?.message || '转存失败', type: 'error', onClose: () => setToast(null), }); } finally { setTransferingUrl(null); } }; const magnetHealthBadgeClass = (level: MagnetHealthLevel) => { switch (level) { case 'good': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'; case 'ok': return 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'; case 'risk': return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'; default: return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'; } }; const magnetHealthLabel = (level: MagnetHealthLevel) => { switch (level) { case 'good': return '健康'; case 'ok': return '一般'; case 'risk': return '风险'; default: return '未知'; } }; // 磁力单条测活(与动漫磁链搜索共用 /api/acg/health,全站并发可配) const handleMagnetHealthCheck = async (link: PansouLink) => { const url = link.url?.trim(); if (!url || magnetHealthCheckingIds[url]) return; setMagnetHealthCheckingIds((prev) => ({ ...prev, [url]: true })); try { const response = await fetch('/api/acg/health', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ url, skipCache: Boolean(magnetHealthMap[url]), }), }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || '测活失败'); } setMagnetHealthMap((prev) => ({ ...prev, [url]: { health: data.health as MagnetHealthLevel, seeders: data.seeders ?? 0, leechers: data.leechers ?? 0, peers: data.peers ?? 0, message: data.message || '', infoHash: data.infoHash, source: data.source, durationMs: data.durationMs, }, })); } catch (err: any) { setToast({ message: err?.message || '测活失败', type: 'error', onClose: () => setToast(null), }); } finally { setMagnetHealthCheckingIds((prev) => { const next = { ...prev }; delete next[url]; return next; }); } }; const handleOpenDownloadDialog = (link: PansouLink) => { setSelectedDownloadLink(link); setCustomName(keyword.trim() || link.note || ''); setShowNameDialog(true); }; const handleCloseDownloadDialog = () => { setShowNameDialog(false); setSelectedDownloadLink(null); setCustomName(''); setDownloadTool('aria2'); }; const handleConfirmDownload = async () => { if (!selectedDownloadLink || !customName.trim()) { return; } setDownloadingUrl(selectedDownloadLink.url); setShowNameDialog(false); try { const response = await fetch('/api/acg/download', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ url: selectedDownloadLink.url, name: customName.trim(), tool: downloadTool, }), }); 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 { setDownloadingUrl(null); setSelectedDownloadLink(null); setCustomName(''); setDownloadTool('aria2'); } }; const handleNetdiskInstantPlay = async ( cloudType: string, link: PansouLink ) => { try { setPlayingUrl(link.url); const instantPlayApi = cloudType === 'mobile' ? '/api/netdisk/mobile/instant-play' : cloudType === 'baidu' ? '/api/netdisk/baidu/instant-play' : cloudType === 'tianyi' ? '/api/netdisk/tianyi/instant-play' : cloudType === '115' ? '/api/netdisk/115/instant-play' : cloudType === 'uc' ? '/api/netdisk/uc/instant-play' : cloudType === '123' ? '/api/netdisk/123/instant-play' : '/api/netdisk/quark/instant-play'; const response = await fetch(instantPlayApi, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ shareUrl: link.url, passcode: link.password || '', title: keyword, }), }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || '播放失败'); } router.push( `/play?source=${encodeURIComponent( data.source || (cloudType === 'mobile' ? 'netdisk-mobile' : cloudType === 'baidu' ? 'netdisk-baidu' : cloudType === 'tianyi' ? 'netdisk-tianyi' : cloudType === '115' ? 'netdisk-115' : cloudType === 'uc' ? 'netdisk-uc' : cloudType === '123' ? 'netdisk-123' : 'netdisk-quark') )}&id=${encodeURIComponent(data.id)}&title=${encodeURIComponent( keyword )}` ); } catch (err: any) { setToast({ message: err?.message || '播放失败', type: 'error', onClose: () => setToast(null), }); } finally { setPlayingUrl(null); } }; const handleStartCheck = async (cloudType: string, links: PansouLink[]) => { try { const platform = CLOUD_TYPE_TO_CHECK_PLATFORM[cloudType]; if (!platform) { throw new Error('当前网盘类型暂不支持有效性检测'); } const response = await fetch('/api/netdisk/check/start', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ platform, links: links.map((item) => item.url), }), }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || '启动检测失败'); } setCheckStatesByType((prev) => ({ ...prev, [cloudType]: { taskId: data.taskId, task: data.task, }, })); setCooldownRemainingMs(Number(data.cooldownRemainingMs || 0)); } catch (err: any) { setToast({ message: err?.message || '启动检测失败', type: 'error', onClose: () => setToast(null), }); } }; const handleCancelCheck = async (cloudType: string) => { const state = checkStatesByType[cloudType]; if (!state?.taskId) return; try { const response = await fetch('/api/netdisk/check/cancel', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ taskId: state.taskId }), }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || '停止检测失败'); } setCheckStatesByType((prev) => ({ ...prev, [cloudType]: { taskId: state.taskId, task: data.task, }, })); } catch (err: any) { setToast({ message: err?.message || '停止检测失败', type: 'error', onClose: () => setToast(null), }); } }; const getCloudCheckState = (cloudType: string) => { return checkStatesByType[cloudType]?.task || null; }; // 网盘类型选项卡横向拖动滚动 const handleTypeMouseDown = (e: React.MouseEvent) => { if (!typeScrollContainerRef.current) return; isTypeDraggingRef.current = true; typeDragStartXRef.current = e.pageX - typeScrollContainerRef.current.offsetLeft; typeDragScrollLeftRef.current = typeScrollContainerRef.current.scrollLeft; typeScrollContainerRef.current.style.cursor = 'grabbing'; typeScrollContainerRef.current.style.userSelect = 'none'; }; const handleTypeMouseLeave = () => { if (!typeScrollContainerRef.current) return; isTypeDraggingRef.current = false; typeScrollContainerRef.current.style.cursor = 'grab'; typeScrollContainerRef.current.style.userSelect = 'auto'; }; const handleTypeMouseUp = () => { if (!typeScrollContainerRef.current) return; isTypeDraggingRef.current = false; typeScrollContainerRef.current.style.cursor = 'grab'; typeScrollContainerRef.current.style.userSelect = 'auto'; }; const handleTypeMouseMove = (e: React.MouseEvent) => { if (!isTypeDraggingRef.current || !typeScrollContainerRef.current) return; e.preventDefault(); const x = e.pageX - typeScrollContainerRef.current.offsetLeft; const walk = (x - typeDragStartXRef.current) * 2; typeScrollContainerRef.current.scrollLeft = typeDragScrollLeftRef.current - walk; }; const getCheckResultForUrl = (cloudType: string, url: string) => { const task = getCloudCheckState(cloudType); return task?.results?.[url] || null; }; const getSortedLinks = (cloudType: string, links: PansouLink[]) => { return links .map((link, index) => ({ link, index, checkResult: getCheckResultForUrl(cloudType, link.url), })) .sort((a, b) => { const aInvalid = a.checkResult?.status === 'invalid' ? 1 : 0; const bInvalid = b.checkResult?.status === 'invalid' ? 1 : 0; if (aInvalid !== bInvalid) { return aInvalid - bInvalid; } return a.index - b.index; }) .map(({ link }) => link); }; const renderBody = () => { if (loading) { return (

正在搜索网盘资源...

); } if (error) { return (

{error}

); } if (!results || results.total === 0 || !results.merged_by_type) { return (

未找到相关资源

); } const cloudTypes = Object.keys(results.merged_by_type || {}); // 过滤显示的网盘类型 const filteredCloudTypes = selectedType === 'all' ? cloudTypes : cloudTypes.filter((type) => type === selectedType); // 计算每种网盘类型的数量 const typeStats = cloudTypes.map((type) => ({ type, count: results.merged_by_type?.[type]?.length || 0, })); return ( <> {/* 搜索结果统计 */}
找到{' '} {results.total} {' '} 个资源
{/* 网盘类型过滤器 */}

网盘类型

{typeStats.map(({ type, count }) => { const typeName = CLOUD_TYPE_NAMES[type] || type; return ( ); })}
{/* 按网盘类型分类显示 */} {filteredCloudTypes.map((cloudType) => { const links = results.merged_by_type?.[cloudType]; if (!links || links.length === 0) return null; const sortedLinks = getSortedLinks(cloudType, links); const typeName = CLOUD_TYPE_NAMES[cloudType] || cloudType; const typeColor = CLOUD_TYPE_COLORS[cloudType] || CLOUD_TYPE_COLORS.others; const checkable = CHECKABLE_CLOUD_TYPES.has(cloudType); const cloudCheckTask = getCloudCheckState(cloudType); const isCheckingThisType = cloudCheckTask?.status === 'running'; const groupProgress = cloudCheckTask?.progress || null; return (
{/* 网盘类型标题 */}
{typeName} {links.length} 个链接 {groupProgress && ( 进度 {groupProgress.done}/{groupProgress.total} · 有效{' '} {groupProgress.valid} · 失效 {groupProgress.invalid} · 未知{' '} {groupProgress.unknown + groupProgress.rateLimited} )} {checkable && ( <> {isCheckingThisType && ( )} )} {cooldownRemainingMs > 0 && ( 冷却中 {Math.ceil(cooldownRemainingMs / 1000)}s )}
{/* 链接列表 */}
{sortedLinks.map((link: PansouLink, index: number) => (
{/* 资源标题 */} {link.note && (
{link.note}
)} {/* 链接和密码 */}
{link.url}
{link.password && (
提取码:{' '} {link.password}
)}
{/* 操作按钮 */}
{(() => { const checkResult = getCheckResultForUrl( cloudType, link.url ); if (!checkResult) return null; return ( {CHECK_STATUS_TEXT[checkResult.status]} ); })()} {(cloudType === 'quark' || cloudType === 'mobile' || cloudType === 'baidu' || cloudType === 'tianyi' || cloudType === '123' || cloudType === 'uc' || cloudType === '115') && ( <> {cloudType === 'quark' && ( )} )} {cloudType === 'magnet' && ( <> )}
{/* 来源和时间 */}
{link.source && 来源: {link.source}} {link.datetime && ( {new Date(link.datetime).toLocaleDateString()} )} {cloudType === 'magnet' && magnetHealthMap[link.url] && ( <> {magnetHealthLabel(magnetHealthMap[link.url].health)} Seeder {magnetHealthMap[link.url].seeders} {' · '} Leecher {magnetHealthMap[link.url].leechers} {' · '} Peer {magnetHealthMap[link.url].peers} {typeof magnetHealthMap[link.url].durationMs === 'number' && ( {magnetHealthMap[link.url].source === 'cache' ? '缓存' : `${magnetHealthMap[link.url].durationMs}ms`} )} )} {(() => { const checkResult = getCheckResultForUrl( cloudType, link.url ); if (!checkResult?.reason) return null; return ( 检测结果: {checkResult.reason} ); })()}
{/* 图片预览 */} {link.images && link.images.length > 0 && (
{link.images.map((img, imgIndex) => ( ))}
)}
))}
); })} ); }; return ( <>
{renderBody()}
{showNameDialog && (

设置资源名称

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 />
)} {toast && } ); }