diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 8170d79..ccb8c7b 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -5105,6 +5105,9 @@ const SiteConfigComponent = ({ DanmakuApiToken: '87654321', TMDBApiKey: '', TMDBProxy: '', + PansouApiUrl: '', + PansouUsername: '', + PansouPassword: '', EnableComments: false, EnableRegistration: false, RegistrationRequireTurnstile: false, @@ -5189,6 +5192,9 @@ const SiteConfigComponent = ({ DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321', TMDBApiKey: config.SiteConfig.TMDBApiKey || '', TMDBProxy: config.SiteConfig.TMDBProxy || '', + PansouApiUrl: config.SiteConfig.PansouApiUrl || '', + PansouUsername: config.SiteConfig.PansouUsername || '', + PansouPassword: config.SiteConfig.PansouPassword || '', EnableComments: config.SiteConfig.EnableComments || false, }); } @@ -5779,6 +5785,87 @@ const SiteConfigComponent = ({ + {/* Pansou 配置 */} +
+

+ Pansou 网盘搜索配置 +

+ + {/* Pansou API 地址 */} +
+ + + setSiteSettings((prev) => ({ + ...prev, + PansouApiUrl: e.target.value, + })) + } + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent' + /> +

+ 配置 Pansou 服务器地址,用于网盘资源搜索。项目地址:{' '} + + https://github.com/fish2018/pansou + +

+
+ + {/* Pansou 账号 */} +
+ + + setSiteSettings((prev) => ({ + ...prev, + PansouUsername: e.target.value, + })) + } + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent' + /> +

+ 如果 Pansou 服务启用了认证功能,需要提供账号密码 +

+
+ + {/* Pansou 密码 */} +
+ + + setSiteSettings((prev) => ({ + ...prev, + PansouPassword: e.target.value, + })) + } + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent' + /> +

+ 配置账号密码后,系统会自动登录并缓存 Token +

+
+
+ {/* 评论功能配置 */}

diff --git a/src/app/api/admin/site/route.ts b/src/app/api/admin/site/route.ts index 00d78e9..5ca0eb9 100644 --- a/src/app/api/admin/site/route.ts +++ b/src/app/api/admin/site/route.ts @@ -43,6 +43,9 @@ export async function POST(request: NextRequest) { DanmakuApiToken, TMDBApiKey, TMDBProxy, + PansouApiUrl, + PansouUsername, + PansouPassword, EnableComments, CustomAdFilterCode, CustomAdFilterVersion, @@ -76,6 +79,9 @@ export async function POST(request: NextRequest) { DanmakuApiToken: string; TMDBApiKey?: string; TMDBProxy?: string; + PansouApiUrl?: string; + PansouUsername?: string; + PansouPassword?: string; EnableComments: boolean; CustomAdFilterCode?: string; CustomAdFilterVersion?: number; @@ -163,6 +169,9 @@ export async function POST(request: NextRequest) { DanmakuApiToken, TMDBApiKey, TMDBProxy, + PansouApiUrl, + PansouUsername, + PansouPassword, EnableComments, CustomAdFilterCode, CustomAdFilterVersion, diff --git a/src/app/api/pansou/search/route.ts b/src/app/api/pansou/search/route.ts new file mode 100644 index 0000000..7f10dff --- /dev/null +++ b/src/app/api/pansou/search/route.ts @@ -0,0 +1,61 @@ +/* eslint-disable @typescript-eslint/no-explicit-any,no-console */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { getConfig } from '@/lib/config'; +import { searchPansou } from '@/lib/pansou.client'; + +export const runtime = 'nodejs'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { keyword } = body; + + if (!keyword) { + return NextResponse.json( + { error: '关键词不能为空' }, + { status: 400 } + ); + } + + // 从系统配置中获取 Pansou 配置 + const config = await getConfig(); + const apiUrl = config.SiteConfig.PansouApiUrl; + const username = config.SiteConfig.PansouUsername; + const password = config.SiteConfig.PansouPassword; + + console.log('Pansou 搜索请求:', { + keyword, + apiUrl: apiUrl ? '已配置' : '未配置', + hasAuth: !!(username && password), + }); + + if (!apiUrl) { + return NextResponse.json( + { error: '未配置 Pansou API 地址,请在管理面板配置' }, + { status: 400 } + ); + } + + // 调用 Pansou 搜索 + const results = await searchPansou(apiUrl, keyword, { + username, + password, + }); + + console.log('Pansou 搜索结果:', { + total: results.total, + hasData: !!results.merged_by_type, + types: results.merged_by_type ? Object.keys(results.merged_by_type) : [], + }); + + return NextResponse.json(results); + } catch (error: any) { + console.error('Pansou 搜索失败:', error); + return NextResponse.json( + { error: error.message || '搜索失败' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/server-config/route.ts b/src/app/api/server-config/route.ts index 0be1393..2701b09 100644 --- a/src/app/api/server-config/route.ts +++ b/src/app/api/server-config/route.ts @@ -46,6 +46,10 @@ export async function GET(request: NextRequest) { EnableOIDCLogin: config.SiteConfig.EnableOIDCLogin || false, EnableOIDCRegistration: config.SiteConfig.EnableOIDCRegistration || false, OIDCButtonText: config.SiteConfig.OIDCButtonText || '', + SiteConfig: { + PansouApiUrl: config.SiteConfig.PansouApiUrl || '', + // 不暴露用户名和密码,认证在后端处理 + }, }; return NextResponse.json(result); } diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 049a8a3..ea15678 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -2,7 +2,7 @@ 'use client'; -import { Heart } from 'lucide-react'; +import { Heart, Search, X } from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; import { Suspense, useEffect, useRef, useState } from 'react'; @@ -48,6 +48,7 @@ import DoubanComments from '@/components/DoubanComments'; import DanmakuFilterSettings from '@/components/DanmakuFilterSettings'; import Toast, { ToastProps } from '@/components/Toast'; import { useEnableComments } from '@/hooks/useEnableComments'; +import PansouSearch from '@/components/PansouSearch'; // 扩展 HTMLVideoElement 类型以支持 hls 属性 declare global { @@ -96,6 +97,9 @@ function PlayPageClient() { // 收藏状态 const [favorited, setFavorited] = useState(false); + // 网盘搜索弹窗状态 + const [showPansouDialog, setShowPansouDialog] = useState(false); + // 跳过片头片尾配置 const [skipConfig, setSkipConfig] = useState<{ enable: boolean; @@ -4890,6 +4894,17 @@ function PlayPageClient() { > + {/* 网盘搜索按钮 */} + {/* 豆瓣评分显示 */} {doubanRating && doubanRating.value > 0 && (
@@ -5107,6 +5122,40 @@ function PlayPageClient() { }); }} /> + + {/* 网盘搜索弹窗 */} + {showPansouDialog && ( +
setShowPansouDialog(false)} + > +
e.stopPropagation()} + > + {/* 弹窗头部 */} +
+

+ 搜索网盘资源: {detail?.title || ''} +

+ +
+ + {/* 弹窗内容 */} +
+ +
+
+
+ )} ); } diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index e8166fa..9174fa4 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -18,12 +18,17 @@ import PageLayout from '@/components/PageLayout'; import SearchResultFilter, { SearchFilterCategory } from '@/components/SearchResultFilter'; import SearchSuggestions from '@/components/SearchSuggestions'; import VideoCard, { VideoCardHandle } from '@/components/VideoCard'; +import PansouSearch from '@/components/PansouSearch'; function SearchPageClient() { // 搜索历史 const [searchHistory, setSearchHistory] = useState([]); // 返回顶部按钮显示状态 const [showBackToTop, setShowBackToTop] = useState(false); + // 选项卡状态: 'video' 或 'pansou' + const [activeTab, setActiveTab] = useState<'video' | 'pansou'>('video'); + // Pansou 搜索触发标志 + const [triggerPansouSearch, setTriggerPansouSearch] = useState(false); const router = useRouter(); const searchParams = useSearchParams(); @@ -381,6 +386,14 @@ function SearchPageClient() { }); }, [aggregatedResults, filterAgg, searchQuery]); + // 监听选项卡切换,自动执行搜索 + useEffect(() => { + // 如果切换到网盘搜索选项卡,且有搜索关键词,且已显示结果,则触发搜索 + if (activeTab === 'pansou' && searchQuery.trim() && showResults) { + setTriggerPansouSearch(prev => !prev); + } + }, [activeTab]); + useEffect(() => { // 无搜索参数时聚焦搜索框 !searchParams.get('q') && document.getElementById('searchInput')?.focus(); @@ -693,8 +706,15 @@ function SearchPageClient() { setShowResults(true); setShowSuggestions(false); - router.push(`/search?q=${encodeURIComponent(trimmed)}`); - // 其余由 searchParams 变化的 effect 处理 + // 根据当前选项卡执行不同的搜索 + if (activeTab === 'video') { + // 影视搜索 + router.push(`/search?q=${encodeURIComponent(trimmed)}`); + // 其余由 searchParams 变化的 effect 处理 + } else if (activeTab === 'pansou') { + // 网盘搜索 - 触发搜索 + setTriggerPansouSearch(prev => !prev); // 切换状态来触发搜索 + } }; const handleSuggestionSelect = (suggestion: string) => { @@ -778,14 +798,41 @@ function SearchPageClient() { />
+ + {/* 选项卡 */} +
+ + +

{/* 搜索结果或搜索历史 */}
{showResults ? (
- {/* 标题 */} -
+ {activeTab === 'video' ? ( + <> + {/* 影视搜索结果 */} + {/* 标题 */} +

搜索结果 {isFromCache ? ( @@ -930,6 +977,21 @@ function SearchPageClient() { ))}

)} + + ) : ( + <> + {/* 网盘搜索结果 */} +
+

+ 网盘搜索结果 +

+
+ + + )}
) : searchHistory.length > 0 ? ( // 搜索历史 diff --git a/src/components/PansouSearch.tsx b/src/components/PansouSearch.tsx new file mode 100644 index 0000000..84d3d1d --- /dev/null +++ b/src/components/PansouSearch.tsx @@ -0,0 +1,314 @@ +/* eslint-disable @typescript-eslint/no-explicit-any,no-console */ +'use client'; + +import { AlertCircle, Copy, ExternalLink, Loader2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +import { PansouLink, PansouSearchResult } from '@/lib/pansou.client'; + +interface PansouSearchProps { + keyword: string; + triggerSearch?: boolean; // 触发搜索的标志 + onError?: (error: string) => void; +} + +// 网盘类型映射 +const CLOUD_TYPE_NAMES: Record = { + baidu: '百度网盘', + aliyun: '阿里云盘', + quark: '夸克网盘', + tianyi: '天翼云盘', + uc: 'UC网盘', + mobile: '移动云盘', + '115': '115网盘', + pikpak: 'PikPak', + xunlei: '迅雷网盘', + '123': '123网盘', + 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', + 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', +}; + +export default function PansouSearch({ + keyword, + triggerSearch, + onError, +}: PansouSearchProps) { + 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' 表示显示全部 + + useEffect(() => { + // 只在 triggerSearch 变化时执行搜索,不响应 keyword 变化 + if (!triggerSearch) { + return; + } + + const currentKeyword = keyword.trim(); + if (!currentKeyword) { + return; + } + + const searchPansou = async () => { + setLoading(true); + setError(null); + setResults(null); + + try { + const response = await fetch('/api/pansou/search', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + keyword: currentKeyword, + }), + }); + + 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); + } + }; + + searchPansou(); + }, [triggerSearch, onError]); // 移除 keyword 依赖,只依赖 triggerSearch + + 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'); + }; + + 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; + const typeColor = CLOUD_TYPE_COLORS[type] || CLOUD_TYPE_COLORS.others; + + return ( + + ); + })} +
+ + {/* 按网盘类型分类显示 */} + {filteredCloudTypes.map((cloudType) => { + const links = results.merged_by_type[cloudType]; + if (!links || links.length === 0) return null; + + const typeName = CLOUD_TYPE_NAMES[cloudType] || cloudType; + const typeColor = CLOUD_TYPE_COLORS[cloudType] || CLOUD_TYPE_COLORS.others; + + return ( +
+ {/* 网盘类型标题 */} +
+ + {typeName} + + + {links.length} 个链接 + +
+ + {/* 链接列表 */} +
+ {links.map((link: PansouLink, index: number) => ( +
+ {/* 资源标题 */} + {link.note && ( +
+ {link.note} +
+ )} + + {/* 链接和密码 */} +
+
+
+ {link.url} +
+ {link.password && ( +
+ 提取码: {link.password} +
+ )} +
+ + {/* 操作按钮 */} +
+ + +
+
+ + {/* 来源和时间 */} +
+ {link.source && ( + 来源: {link.source} + )} + {link.datetime && ( + {new Date(link.datetime).toLocaleDateString()} + )} +
+ + {/* 图片预览 */} + {link.images && link.images.length > 0 && ( +
+ {link.images.map((img, imgIndex) => ( + + ))} +
+ )} +
+ ))} +
+
+ ); + })} +
+ ); +} diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index e76fa01..bd51ec2 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -22,6 +22,10 @@ export interface AdminConfig { // TMDB配置 TMDBApiKey?: string; TMDBProxy?: string; + // Pansou配置 + PansouApiUrl?: string; + PansouUsername?: string; + PansouPassword?: string; // 评论功能开关 EnableComments: boolean; // 自定义去广告代码 diff --git a/src/lib/pansou.client.ts b/src/lib/pansou.client.ts new file mode 100644 index 0000000..b928d3d --- /dev/null +++ b/src/lib/pansou.client.ts @@ -0,0 +1,218 @@ +/* eslint-disable @typescript-eslint/no-explicit-any,no-console */ + +/** + * Pansou 网盘搜索 API 客户端 + * 文档: https://github.com/fish2018/pansou + */ + +// Token 缓存 +let cachedToken: string | null = null; +let tokenExpiry: number | null = null; + +export interface PansouLink { + url: string; + password: string; + note: string; + datetime: string; + source: string; + images?: string[]; +} + +export interface PansouSearchResult { + total: number; + merged_by_type?: { + [key: string]: PansouLink[]; + }; +} + +export interface PansouLoginResponse { + token: string; + expires_at: number; + username: string; +} + +/** + * 登录 Pansou 获取 Token + */ +export async function loginPansou( + apiUrl: string, + username: string, + password: string +): Promise { + try { + const response = await fetch(`${apiUrl}/api/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, password }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || '登录失败'); + } + + const data: PansouLoginResponse = await response.json(); + + // 缓存 Token + cachedToken = data.token; + tokenExpiry = data.expires_at; + + return data.token; + } catch (error) { + console.error('Pansou 登录失败:', error); + throw error; + } +} + +/** + * 获取有效的 Token(自动处理登录和缓存) + */ +async function getValidToken( + apiUrl: string, + username?: string, + password?: string +): Promise { + // 如果没有配置账号密码,返回 null(不需要认证) + if (!username || !password) { + return null; + } + + // 检查缓存的 Token 是否有效 + if (cachedToken && tokenExpiry) { + const now = Math.floor(Date.now() / 1000); + // 提前 5 分钟刷新 Token + if (tokenExpiry - now > 300) { + return cachedToken; + } + } + + // Token 过期或不存在,重新登录 + try { + return await loginPansou(apiUrl, username, password); + } catch (error) { + console.error('获取 Pansou Token 失败:', error); + return null; + } +} + +/** + * 搜索网盘资源 + */ +export async function searchPansou( + apiUrl: string, + keyword: string, + options?: { + username?: string; + password?: string; + refresh?: boolean; + cloudTypes?: string[]; + } +): Promise { + try { + // 获取 Token(如果需要认证) + const token = await getValidToken( + apiUrl, + options?.username, + options?.password + ); + + // 构建请求头 + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + // 构建请求体 + const body: any = { + kw: keyword, + res: 'merge', // 只返回按网盘类型分类的结果 + }; + + if (options?.refresh) { + body.refresh = true; + } + + if (options?.cloudTypes && options.cloudTypes.length > 0) { + body.cloud_types = options.cloudTypes; + } + + const response = await fetch(`${apiUrl}/api/search`, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || error.message || '搜索失败'); + } + + const responseData = await response.json(); + + // Pansou API 返回的数据结构是 { code, message, data } + // 实际数据在 data 字段中 + let data: PansouSearchResult; + + if (responseData.data) { + // 如果有 data 字段,使用 data 中的内容 + data = responseData.data; + } else { + // 否则直接使用返回的数据 + data = responseData; + } + + // 验证返回的数据结构 + if (!data || typeof data !== 'object') { + throw new Error('返回数据格式错误'); + } + + // 确保 merged_by_type 存在 + if (!data.merged_by_type) { + data.merged_by_type = {}; + } + + // 确保 total 存在 + if (typeof data.total !== 'number') { + data.total = 0; + } + + return data; + } catch (error) { + console.error('Pansou 搜索失败:', error); + throw error; + } +} + +/** + * 清除缓存的 Token + */ +export function clearPansouToken(): void { + cachedToken = null; + tokenExpiry = null; +} + +/** + * 检查 Pansou 服务是否可用 + */ +export async function checkPansouHealth(apiUrl: string): Promise { + try { + const response = await fetch(`${apiUrl}/api/health`, { + method: 'GET', + }); + + if (!response.ok) { + return false; + } + + const data = await response.json(); + return data.status === 'ok'; + } catch (error) { + console.error('Pansou 健康检查失败:', error); + return false; + } +}