网盘搜索前增加来源筛选

This commit is contained in:
mtvpls
2026-06-06 17:00:23 +08:00
parent c8898bcaea
commit f6db363dcb
3 changed files with 217 additions and 15 deletions
+12 -5
View File
@@ -19,12 +19,15 @@ export async function POST(request: NextRequest) {
const body = await request.json(); const body = await request.json();
const { keyword } = body; const { keyword } = body;
const cloudTypes = Array.isArray(body.cloud_types)
? body.cloud_types.filter(
(item: unknown): item is string =>
typeof item === 'string' && item.trim().length > 0
)
: undefined;
if (!keyword) { if (!keyword) {
return NextResponse.json( return NextResponse.json({ error: '关键词不能为空' }, { status: 400 });
{ error: '关键词不能为空' },
{ status: 400 }
);
} }
// 从系统配置中获取 Pansou 配置 // 从系统配置中获取 Pansou 配置
@@ -37,6 +40,7 @@ export async function POST(request: NextRequest) {
keyword, keyword,
apiUrl: apiUrl ? '已配置' : '未配置', apiUrl: apiUrl ? '已配置' : '未配置',
hasAuth: !!(username && password), hasAuth: !!(username && password),
cloudTypes: cloudTypes?.length ? cloudTypes : 'all',
}); });
if (!apiUrl) { if (!apiUrl) {
@@ -50,6 +54,7 @@ export async function POST(request: NextRequest) {
const results = await searchPansou(apiUrl, keyword, { const results = await searchPansou(apiUrl, keyword, {
username, username,
password, password,
cloudTypes,
}); });
const rawBlocklist = config.SiteConfig.PansouKeywordBlocklist || ''; const rawBlocklist = config.SiteConfig.PansouKeywordBlocklist || '';
@@ -66,7 +71,9 @@ export async function POST(request: NextRequest) {
let total = 0; let total = 0;
const shouldBlock = (link: PansouLink) => { const shouldBlock = (link: PansouLink) => {
const content = `${link.note || ''} ${link.url || ''} ${link.source || ''}`.toLowerCase(); const content = `${link.note || ''} ${link.url || ''} ${
link.source || ''
}`.toLowerCase();
return blockedKeywords.some((item) => return blockedKeywords.some((item) =>
content.includes(item.toLowerCase()) content.includes(item.toLowerCase())
); );
+196 -7
View File
@@ -21,6 +21,7 @@ import React, {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { createPortal } from 'react-dom';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import { import {
@@ -37,7 +38,7 @@ import AcgSearch from '@/components/AcgSearch';
import CapsuleSwitch from '@/components/CapsuleSwitch'; import CapsuleSwitch from '@/components/CapsuleSwitch';
import ImageViewer from '@/components/ImageViewer'; import ImageViewer from '@/components/ImageViewer';
import PageLayout from '@/components/PageLayout'; import PageLayout from '@/components/PageLayout';
import PansouSearch from '@/components/PansouSearch'; import PansouSearch, { CLOUD_TYPE_NAMES } from '@/components/PansouSearch';
import ProxyImage from '@/components/ProxyImage'; import ProxyImage from '@/components/ProxyImage';
import SearchResultFilter, { import SearchResultFilter, {
SearchFilterCategory, SearchFilterCategory,
@@ -46,6 +47,10 @@ import SearchSuggestions from '@/components/SearchSuggestions';
import VideoCard, { VideoCardHandle } from '@/components/VideoCard'; import VideoCard, { VideoCardHandle } from '@/components/VideoCard';
import VirtualScrollableGrid from '@/components/VirtualScrollableGrid'; import VirtualScrollableGrid from '@/components/VirtualScrollableGrid';
const PANSOU_CLOUD_TYPE_OPTIONS = Object.entries(CLOUD_TYPE_NAMES).map(
([value, label]) => ({ value, label })
);
type SearchCachePayload = { type SearchCachePayload = {
status: 'complete' | 'partial'; status: 'complete' | 'partial';
results: SearchResult[]; results: SearchResult[];
@@ -66,6 +71,17 @@ function SearchPageClient() {
const [triggerPansouSearch, setTriggerPansouSearch] = useState(false); const [triggerPansouSearch, setTriggerPansouSearch] = useState(false);
// ACG 搜索触发标志 // ACG 搜索触发标志
const [triggerAcgSearch, setTriggerAcgSearch] = useState(false); const [triggerAcgSearch, setTriggerAcgSearch] = useState(false);
const [selectedPansouCloudTypes, setSelectedPansouCloudTypes] = useState<
string[]
>([]);
const [pansouCloudFilterOpen, setPansouCloudFilterOpen] = useState(false);
const [pansouCloudFilterPosition, setPansouCloudFilterPosition] = useState({
x: 0,
y: 0,
width: 0,
});
const pansouCloudFilterButtonRef = useRef<HTMLButtonElement | null>(null);
const pansouCloudFilterDropdownRef = useRef<HTMLDivElement | null>(null);
// 用户权限 // 用户权限
const [userRole, setUserRole] = useState<'owner' | 'admin' | 'user' | null>( const [userRole, setUserRole] = useState<'owner' | 'admin' | 'user' | null>(
null null
@@ -1520,6 +1536,115 @@ function SearchPageClient() {
} }
}; };
const togglePansouCloudType = (cloudType: string) => {
setSelectedPansouCloudTypes((prev) =>
prev.includes(cloudType)
? prev.filter((type) => type !== cloudType)
: [...prev, cloudType]
);
};
const calculatePansouCloudFilterPosition = () => {
const element = pansouCloudFilterButtonRef.current;
if (!element) return;
const rect = element.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const padding = 16;
const width = Math.min(320, viewportWidth - padding * 2);
let x = rect.left;
if (x + width > viewportWidth - padding) {
x = viewportWidth - width - padding;
}
if (x < padding) {
x = padding;
}
setPansouCloudFilterPosition({ x, y: rect.bottom + 8, width });
};
const selectedPansouCloudTypeLabels = selectedPansouCloudTypes
.map((type) => CLOUD_TYPE_NAMES[type] || type)
.filter(Boolean);
const renderPansouCloudTypeFilter = () => {
const hasFilter = selectedPansouCloudTypes.length > 0;
const displayText = hasFilter
? selectedPansouCloudTypes.length === 1
? selectedPansouCloudTypeLabels[0]
: `网盘类型 · ${selectedPansouCloudTypes.length}`
: '网盘类型';
return (
<div className='mx-auto mt-4 flex max-w-2xl justify-end overflow-visible'>
<button
ref={pansouCloudFilterButtonRef}
type='button'
onClick={() => {
if (!pansouCloudFilterOpen) {
calculatePansouCloudFilterPosition();
}
setPansouCloudFilterOpen((prev) => !prev);
}}
className={`relative z-10 rounded-full px-3 py-1 text-xs font-medium transition-all duration-200 whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 ${
hasFilter
? 'cursor-pointer text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300'
: 'cursor-pointer text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'
}`}
aria-expanded={pansouCloudFilterOpen}
aria-haspopup='listbox'
>
<span>{displayText}</span>
<svg
className={`ml-1 inline-block h-3 w-3 transition-transform duration-200 ${
pansouCloudFilterOpen ? 'rotate-180' : ''
}`}
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
aria-hidden='true'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M19 9l-7 7-7-7'
/>
</svg>
</button>
</div>
);
};
useEffect(() => {
if (!pansouCloudFilterOpen) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (
pansouCloudFilterButtonRef.current?.contains(target) ||
pansouCloudFilterDropdownRef.current?.contains(target)
) {
return;
}
setPansouCloudFilterOpen(false);
};
const handleScroll = () => setPansouCloudFilterOpen(false);
const handleResize = () => calculatePansouCloudFilterPosition();
document.addEventListener('mousedown', handleClickOutside);
document.body.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleResize);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.body.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleResize);
};
}, [pansouCloudFilterOpen]);
// 返回顶部功能 // 返回顶部功能
const scrollToTop = () => { const scrollToTop = () => {
try { try {
@@ -1551,7 +1676,7 @@ function SearchPageClient() {
<PageLayout activePath='/search'> <PageLayout activePath='/search'>
<div className='px-4 sm:px-10 py-4 sm:py-8 overflow-visible mb-10'> <div className='px-4 sm:px-10 py-4 sm:py-8 overflow-visible mb-10'>
{/* 搜索框 */} {/* 搜索框 */}
<div className='mb-8'> <div className='mb-0'>
<form onSubmit={handleSearch} className='max-w-2xl mx-auto'> <form onSubmit={handleSearch} className='max-w-2xl mx-auto'>
<div className='relative'> <div className='relative'>
<Search className='absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400 dark:text-gray-500' /> <Search className='absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400 dark:text-gray-500' />
@@ -1600,6 +1725,11 @@ function SearchPageClient() {
router.push( router.push(
`/search?q=${encodeURIComponent(trimmed)}&type=${activeTab}` `/search?q=${encodeURIComponent(trimmed)}&type=${activeTab}`
); );
if (activeTab === 'pansou') {
setTriggerPansouSearch((prev) => !prev);
} else if (activeTab === 'acg') {
setTriggerAcgSearch((prev) => !prev);
}
}} }}
/> />
</div> </div>
@@ -1639,10 +1769,65 @@ function SearchPageClient() {
} }
/> />
</div> </div>
{activeTab === 'pansou' &&
netdiskSearchEnabled &&
renderPansouCloudTypeFilter()}
</div> </div>
{pansouCloudFilterOpen &&
createPortal(
<div
ref={pansouCloudFilterDropdownRef}
className='fixed z-[9999] max-h-[50vh] overflow-y-auto rounded-xl border border-gray-200/50 bg-white/95 p-2 backdrop-blur-sm dark:border-gray-700/50 dark:bg-gray-800/95'
style={{
left: `${pansouCloudFilterPosition.x}px`,
top: `${pansouCloudFilterPosition.y}px`,
width: `${pansouCloudFilterPosition.width}px`,
}}
>
<div className='grid grid-cols-3 gap-1.5 sm:grid-cols-4'>
<button
type='button'
onClick={() => setSelectedPansouCloudTypes([])}
className={`rounded-lg px-2 py-1.5 text-left text-xs transition-all duration-200 ${
selectedPansouCloudTypes.length === 0
? 'border border-green-200 bg-green-100 text-green-700 dark:border-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'text-gray-700 hover:bg-gray-100/80 dark:text-gray-300 dark:hover:bg-gray-700/80'
}`}
aria-pressed={selectedPansouCloudTypes.length === 0}
>
</button>
{PANSOU_CLOUD_TYPE_OPTIONS.map(({ value, label }) => {
const selected = selectedPansouCloudTypes.includes(value);
return (
<button
key={value}
type='button'
onClick={() => togglePansouCloudType(value)}
className={`rounded-lg px-2 py-1.5 text-left text-xs transition-all duration-200 ${
selected
? 'border border-green-200 bg-green-100 text-green-700 dark:border-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'text-gray-700 hover:bg-gray-100/80 dark:text-gray-300 dark:hover:bg-gray-700/80'
}`}
aria-pressed={selected}
>
{label}
</button>
);
})}
</div>
</div>,
document.body
)}
{/* 搜索结果或搜索历史 */} {/* 搜索结果或搜索历史 */}
<div className='max-w-[95%] mx-auto mt-12 overflow-visible'> <div
className={`max-w-[95%] mx-auto overflow-visible ${
activeTab === 'pansou' ? 'mt-4' : 'mt-12'
}`}
>
{showResults ? ( {showResults ? (
<section className='mb-12'> <section className='mb-12'>
{activeTab === 'video' ? ( {activeTab === 'video' ? (
@@ -1680,8 +1865,7 @@ function SearchPageClient() {
</span> </span>
{resultCountMeta.isFiltered && ( {resultCountMeta.isFiltered && (
<span className='inline-flex items-center rounded-full bg-white/80 px-2.5 py-1 font-medium text-gray-500 ring-1 ring-gray-200 dark:bg-gray-900/70 dark:text-gray-400 dark:ring-gray-700'> <span className='inline-flex items-center rounded-full bg-white/80 px-2.5 py-1 font-medium text-gray-500 ring-1 ring-gray-200 dark:bg-gray-900/70 dark:text-gray-400 dark:ring-gray-700'>
{' '} {resultCountMeta.totalCount.toLocaleString()}{' '}
{resultCountMeta.totalCount.toLocaleString()}{' '}
{resultCountMeta.unit} {resultCountMeta.unit}
</span> </span>
)} )}
@@ -1851,7 +2035,9 @@ function SearchPageClient() {
<VideoCard <VideoCard
ref={getGroupRef(mapKey)} ref={getGroupRef(mapKey)}
from='search' from='search'
onBeforeNavigate={savePartialCacheForPlayback} onBeforeNavigate={
savePartialCacheForPlayback
}
isAggregate={true} isAggregate={true}
title={title} title={title}
poster={poster} poster={poster}
@@ -1901,7 +2087,9 @@ function SearchPageClient() {
> >
<VideoCard <VideoCard
id={item.id} id={item.id}
onBeforeNavigate={savePartialCacheForPlayback} onBeforeNavigate={
savePartialCacheForPlayback
}
title={item.title} title={item.title}
poster={item.poster} poster={item.poster}
episodes={item.episodes.length} episodes={item.episodes.length}
@@ -1958,6 +2146,7 @@ function SearchPageClient() {
<PansouSearch <PansouSearch
keyword={searchQuery} keyword={searchQuery}
triggerSearch={triggerPansouSearch} triggerSearch={triggerPansouSearch}
cloudTypes={selectedPansouCloudTypes}
/> />
</> </>
) : ( ) : (
+9 -3
View File
@@ -20,6 +20,7 @@ interface PansouSearchProps {
keyword: string; keyword: string;
triggerSearch?: boolean; // 触发搜索的标志 triggerSearch?: boolean; // 触发搜索的标志
onError?: (error: string) => void; onError?: (error: string) => void;
cloudTypes?: string[];
} }
type DownloadTool = 'aria2' | 'Transmission' | 'qBittorrent'; type DownloadTool = 'aria2' | 'Transmission' | 'qBittorrent';
@@ -31,7 +32,7 @@ const downloadToolOptions: Array<{ value: DownloadTool; label: string }> = [
]; ];
// 网盘类型映射 // 网盘类型映射
const CLOUD_TYPE_NAMES: Record<string, string> = { export const CLOUD_TYPE_NAMES: Record<string, string> = {
baidu: '百度网盘', baidu: '百度网盘',
aliyun: '阿里云盘', aliyun: '阿里云盘',
quark: '夸克网盘', quark: '夸克网盘',
@@ -150,6 +151,7 @@ export default function PansouSearch({
keyword, keyword,
triggerSearch, triggerSearch,
onError, onError,
cloudTypes = [],
}: PansouSearchProps) { }: PansouSearchProps) {
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -264,6 +266,7 @@ export default function PansouSearch({
setLoading(true); setLoading(true);
setError(null); setError(null);
setResults(null); setResults(null);
setSelectedType('all');
try { try {
const response = await fetch('/api/pansou/search', { const response = await fetch('/api/pansou/search', {
@@ -273,6 +276,7 @@ export default function PansouSearch({
}, },
body: JSON.stringify({ body: JSON.stringify({
keyword: currentKeyword, keyword: currentKeyword,
cloud_types: cloudTypes,
}), }),
}); });
@@ -290,7 +294,7 @@ export default function PansouSearch({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [keyword, onError]); }, [keyword, onError, cloudTypes]);
useEffect(() => { useEffect(() => {
// triggerSearch 变化时触发搜索(无论是 true 还是 false // triggerSearch 变化时触发搜索(无论是 true 还是 false
@@ -881,7 +885,9 @@ export default function PansouSearch({
{downloadingUrl === link.url ? ( {downloadingUrl === link.url ? (
<> <>
<Loader2 className='h-3.5 w-3.5 animate-spin' /> <Loader2 className='h-3.5 w-3.5 animate-spin' />
<span className='hidden sm:inline'>...</span> <span className='hidden sm:inline'>
...
</span>
</> </>
) : ( ) : (
<> <>