增加动漫磁力搜索
This commit is contained in:
@@ -2762,6 +2762,7 @@ const OpenListConfigComponent = ({
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [rootPath, setRootPath] = useState('/');
|
||||
const [offlineDownloadPath, setOfflineDownloadPath] = useState('/');
|
||||
const [scanInterval, setScanInterval] = useState(0);
|
||||
const [videos, setVideos] = useState<any[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
@@ -2780,6 +2781,7 @@ const OpenListConfigComponent = ({
|
||||
setUsername(config.OpenListConfig.Username || '');
|
||||
setPassword(config.OpenListConfig.Password || '');
|
||||
setRootPath(config.OpenListConfig.RootPath || '/');
|
||||
setOfflineDownloadPath(config.OpenListConfig.OfflineDownloadPath || '/');
|
||||
setScanInterval(config.OpenListConfig.ScanInterval || 0);
|
||||
}
|
||||
}, [config]);
|
||||
@@ -2819,6 +2821,7 @@ const OpenListConfigComponent = ({
|
||||
Username: username,
|
||||
Password: password,
|
||||
RootPath: rootPath,
|
||||
OfflineDownloadPath: offlineDownloadPath,
|
||||
ScanInterval: scanInterval,
|
||||
}),
|
||||
});
|
||||
@@ -3111,6 +3114,23 @@ const OpenListConfigComponent = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
离线下载目录
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={offlineDownloadPath}
|
||||
onChange={(e) => setOfflineDownloadPath(e.target.value)}
|
||||
disabled={!enabled}
|
||||
placeholder='/'
|
||||
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-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
/>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
动漫磁力等离线下载任务的保存目录,默认为根目录 /
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
定时扫描间隔(分钟)
|
||||
|
||||
107
src/app/api/acg/download/route.ts
Normal file
107
src/app/api/acg/download/route.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { OpenListClient } from '@/lib/openlist.client';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* POST /api/acg/download
|
||||
* 添加 ACG 资源到 OpenList 离线下载(仅管理员和站长可用)
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// 检查权限
|
||||
const authInfo = getAuthInfoFromCookie(req);
|
||||
if (!authInfo || (authInfo.role !== 'admin' && authInfo.role !== 'owner')) {
|
||||
return NextResponse.json(
|
||||
{ error: '无权限访问' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { url, name } = await req.json();
|
||||
|
||||
if (!url || typeof url !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: '下载链接不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: '资源名称不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取 OpenList 配置
|
||||
const config = await getConfig();
|
||||
const openlistConfig = config.OpenListConfig;
|
||||
|
||||
if (!openlistConfig?.Enabled) {
|
||||
return NextResponse.json(
|
||||
{ error: '私人影库功能未启用' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!openlistConfig.URL || !openlistConfig.Username || !openlistConfig.Password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'OpenList 配置不完整' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 构建下载路径(使用离线下载目录)
|
||||
const offlineDownloadPath = openlistConfig.OfflineDownloadPath || '/';
|
||||
const downloadPath = `${offlineDownloadPath.replace(/\/$/, '')}/${name}`;
|
||||
|
||||
// 使用 OpenListClient 添加离线下载任务
|
||||
const client = new OpenListClient(
|
||||
openlistConfig.URL,
|
||||
openlistConfig.Username,
|
||||
openlistConfig.Password
|
||||
);
|
||||
|
||||
// 获取 Token 并调用 API
|
||||
const token = await (client as any).getToken();
|
||||
const openlistUrl = `${openlistConfig.URL.replace(/\/$/, '')}/api/fs/add_offline_download`;
|
||||
|
||||
const response = await fetch(openlistUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: downloadPath,
|
||||
urls: [url],
|
||||
tool: 'aria2',
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 检查响应状态
|
||||
if (!response.ok || data.code !== 200) {
|
||||
throw new Error(data.message || '添加离线下载任务失败');
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '已添加到离线下载队列',
|
||||
path: downloadPath,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('添加离线下载任务失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || '添加离线下载任务失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
118
src/app/api/acg/search/route.ts
Normal file
118
src/app/api/acg/search/route.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { parseStringPromise } from 'xml2js';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* POST /api/acg/search
|
||||
* 搜索 ACG 磁力资源(仅管理员和站长可用)
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// 检查权限
|
||||
const authInfo = getAuthInfoFromCookie(req);
|
||||
if (!authInfo || (authInfo.role !== 'admin' && authInfo.role !== 'owner')) {
|
||||
return NextResponse.json(
|
||||
{ error: '无权限访问' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { keyword, page = 1 } = await req.json();
|
||||
|
||||
if (!keyword || typeof keyword !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: '搜索关键词不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const trimmedKeyword = keyword.trim();
|
||||
if (!trimmedKeyword) {
|
||||
return NextResponse.json(
|
||||
{ error: '搜索关键词不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证页码
|
||||
const pageNum = parseInt(String(page), 10);
|
||||
if (isNaN(pageNum) || pageNum < 1) {
|
||||
return NextResponse.json(
|
||||
{ error: '页码必须是大于0的整数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 请求 acg.rip API
|
||||
const acgUrl = `https://acg.rip/page/${pageNum}.xml?term=${encodeURIComponent(trimmedKeyword)}`;
|
||||
|
||||
const response = await fetch(acgUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`ACG.RIP API 请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const xmlData = await response.text();
|
||||
|
||||
// 解析 XML
|
||||
const parsed = await parseStringPromise(xmlData);
|
||||
|
||||
if (!parsed?.rss?.channel?.[0]?.item) {
|
||||
return NextResponse.json({
|
||||
keyword: trimmedKeyword,
|
||||
page: pageNum,
|
||||
total: 0,
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
|
||||
const items = parsed.rss.channel[0].item;
|
||||
|
||||
// 转换为标准格式
|
||||
const results = items.map((item: any) => {
|
||||
// 提取描述中的图片(如果有)
|
||||
let images: string[] = [];
|
||||
if (item.description?.[0]) {
|
||||
const imgMatches = item.description[0].match(/src="([^"]+)"/g);
|
||||
if (imgMatches) {
|
||||
images = imgMatches.map((match: string) => {
|
||||
const urlMatch = match.match(/src="([^"]+)"/);
|
||||
return urlMatch ? urlMatch[1] : '';
|
||||
}).filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: item.title?.[0] || '',
|
||||
link: item.link?.[0] || '',
|
||||
guid: item.guid?.[0] || '',
|
||||
pubDate: item.pubDate?.[0] || '',
|
||||
torrentUrl: item.enclosure?.[0]?.$?.url || '',
|
||||
description: item.description?.[0] || '',
|
||||
images,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
keyword: trimmedKeyword,
|
||||
page: pageNum,
|
||||
total: results.length,
|
||||
items: results,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('ACG 搜索失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || '搜索失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { action, Enabled, URL, Username, Password, RootPath, ScanInterval } = body;
|
||||
const { action, Enabled, URL, Username, Password, RootPath, OfflineDownloadPath, ScanInterval } = body;
|
||||
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
@@ -56,6 +56,7 @@ export async function POST(request: NextRequest) {
|
||||
Username: Username || '',
|
||||
Password: Password || '',
|
||||
RootPath: RootPath || '/',
|
||||
OfflineDownloadPath: OfflineDownloadPath || '/',
|
||||
LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime,
|
||||
ResourceCount: adminConfig.OpenListConfig?.ResourceCount,
|
||||
ScanInterval: 0,
|
||||
@@ -105,6 +106,7 @@ export async function POST(request: NextRequest) {
|
||||
Username,
|
||||
Password,
|
||||
RootPath: RootPath || '/',
|
||||
OfflineDownloadPath: OfflineDownloadPath || '/',
|
||||
LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime,
|
||||
ResourceCount: adminConfig.OpenListConfig?.ResourceCount,
|
||||
ScanInterval: scanInterval,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps, @typescript-eslint/no-explicit-any,@typescript-eslint/no-non-null-assertion,no-empty */
|
||||
'use client';
|
||||
|
||||
import { ChevronUp, RefreshCw, Search, X, Film, HardDrive } from 'lucide-react';
|
||||
import { ChevronUp, RefreshCw, Search, X, Film, HardDrive, Magnet } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import React, { startTransition, Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
@@ -13,12 +13,14 @@ import {
|
||||
subscribeToDataUpdates,
|
||||
} from '@/lib/db.client';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||
|
||||
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';
|
||||
import AcgSearch from '@/components/AcgSearch';
|
||||
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
||||
|
||||
function SearchPageClient() {
|
||||
@@ -26,10 +28,14 @@ function SearchPageClient() {
|
||||
const [searchHistory, setSearchHistory] = useState<string[]>([]);
|
||||
// 返回顶部按钮显示状态
|
||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||
// 选项卡状态: 'video' 或 'pansou'
|
||||
const [activeTab, setActiveTab] = useState<'video' | 'pansou'>('video');
|
||||
// 选项卡状态: 'video' 或 'pansou' 或 'acg'
|
||||
const [activeTab, setActiveTab] = useState<'video' | 'pansou' | 'acg'>('video');
|
||||
// Pansou 搜索触发标志
|
||||
const [triggerPansouSearch, setTriggerPansouSearch] = useState(false);
|
||||
// ACG 搜索触发标志
|
||||
const [triggerAcgSearch, setTriggerAcgSearch] = useState(false);
|
||||
// 用户权限
|
||||
const [userRole, setUserRole] = useState<'owner' | 'admin' | 'user' | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -393,12 +399,48 @@ function SearchPageClient() {
|
||||
if (activeTab === 'pansou' && searchQuery.trim() && showResults) {
|
||||
setTriggerPansouSearch(prev => !prev);
|
||||
}
|
||||
// 如果切换到 ACG 磁力搜索选项卡,且有搜索关键词,且已显示结果,则触发搜索
|
||||
if (activeTab === 'acg' && searchQuery.trim() && showResults) {
|
||||
setTriggerAcgSearch(prev => !prev);
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
// 从 URL 读取搜索类型参数
|
||||
const typeParam = searchParams.get('type');
|
||||
const query = searchParams.get('q');
|
||||
|
||||
if (typeParam === 'pansou' || typeParam === 'acg') {
|
||||
setActiveTab(typeParam);
|
||||
|
||||
// 如果有搜索关键词且显示结果,触发对应的搜索
|
||||
if (query && query.trim()) {
|
||||
setSearchQuery(query);
|
||||
setShowResults(true);
|
||||
|
||||
// 延迟触发搜索,确保组件已经切换到正确的标签页
|
||||
setTimeout(() => {
|
||||
if (typeParam === 'pansou') {
|
||||
setTriggerPansouSearch(prev => !prev);
|
||||
} else if (typeParam === 'acg') {
|
||||
setTriggerAcgSearch(prev => !prev);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
} else if (typeParam === 'video') {
|
||||
setActiveTab('video');
|
||||
} else if (!typeParam && query) {
|
||||
// 如果没有 type 参数但有查询,默认为 video
|
||||
setActiveTab('video');
|
||||
}
|
||||
|
||||
// 无搜索参数时聚焦搜索框
|
||||
!searchParams.get('q') && document.getElementById('searchInput')?.focus();
|
||||
|
||||
// 获取用户权限
|
||||
const authInfo = getAuthInfoFromBrowserCookie();
|
||||
setUserRole(authInfo?.role || null);
|
||||
|
||||
// 初始加载搜索历史
|
||||
getSearchHistory().then(setSearchHistory);
|
||||
|
||||
@@ -710,11 +752,16 @@ function SearchPageClient() {
|
||||
// 根据当前选项卡执行不同的搜索
|
||||
if (activeTab === 'video') {
|
||||
// 影视搜索
|
||||
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
|
||||
router.push(`/search?q=${encodeURIComponent(trimmed)}&type=video`);
|
||||
// 其余由 searchParams 变化的 effect 处理
|
||||
} else if (activeTab === 'pansou') {
|
||||
// 网盘搜索 - 触发搜索
|
||||
router.push(`/search?q=${encodeURIComponent(trimmed)}&type=pansou`);
|
||||
setTriggerPansouSearch(prev => !prev); // 切换状态来触发搜索
|
||||
} else if (activeTab === 'acg') {
|
||||
// ACG 磁力搜索 - 触发搜索
|
||||
router.push(`/search?q=${encodeURIComponent(trimmed)}&type=acg`);
|
||||
setTriggerAcgSearch(prev => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -725,8 +772,20 @@ function SearchPageClient() {
|
||||
// 自动执行搜索
|
||||
setShowResults(true);
|
||||
|
||||
router.push(`/search?q=${encodeURIComponent(suggestion)}`);
|
||||
// 其余由 searchParams 变化的 effect 处理
|
||||
// 根据当前选项卡执行不同的搜索
|
||||
if (activeTab === 'video') {
|
||||
// 影视搜索
|
||||
router.push(`/search?q=${encodeURIComponent(suggestion)}&type=video`);
|
||||
// 其余由 searchParams 变化的 effect 处理
|
||||
} else if (activeTab === 'pansou') {
|
||||
// 网盘搜索 - 触发搜索
|
||||
router.push(`/search?q=${encodeURIComponent(suggestion)}&type=pansou`);
|
||||
setTriggerPansouSearch(prev => !prev);
|
||||
} else if (activeTab === 'acg') {
|
||||
// ACG 磁力搜索 - 触发搜索
|
||||
router.push(`/search?q=${encodeURIComponent(suggestion)}&type=acg`);
|
||||
setTriggerAcgSearch(prev => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
// 返回顶部功能
|
||||
@@ -743,6 +802,17 @@ function SearchPageClient() {
|
||||
}
|
||||
};
|
||||
|
||||
// 处理标签切换
|
||||
const handleTabChange = (newTab: 'video' | 'pansou' | 'acg') => {
|
||||
setActiveTab(newTab);
|
||||
|
||||
// 如果有搜索关键词,更新 URL
|
||||
const currentQuery = searchParams.get('q');
|
||||
if (currentQuery) {
|
||||
router.push(`/search?q=${encodeURIComponent(currentQuery)}&type=${newTab}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout activePath='/search'>
|
||||
<div className='px-4 sm:px-10 py-4 sm:py-8 overflow-visible mb-10'>
|
||||
@@ -814,9 +884,19 @@ function SearchPageClient() {
|
||||
value: 'pansou',
|
||||
icon: <HardDrive size={16} />,
|
||||
},
|
||||
// 仅管理员和站长显示 ACG 磁力搜索
|
||||
...(userRole === 'admin' || userRole === 'owner'
|
||||
? [
|
||||
{
|
||||
label: '动漫磁力',
|
||||
value: 'acg' as const,
|
||||
icon: <Magnet size={16} />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
active={activeTab}
|
||||
onChange={(value) => setActiveTab(value as 'video' | 'pansou')}
|
||||
onChange={(value) => handleTabChange(value as 'video' | 'pansou' | 'acg')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -975,7 +1055,7 @@ function SearchPageClient() {
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
) : activeTab === 'pansou' ? (
|
||||
<>
|
||||
{/* 网盘搜索结果 */}
|
||||
<div className='mb-4'>
|
||||
@@ -988,6 +1068,19 @@ function SearchPageClient() {
|
||||
triggerSearch={triggerPansouSearch}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* ACG 磁力搜索结果 */}
|
||||
<div className='mb-4'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
动漫磁力搜索结果
|
||||
</h2>
|
||||
</div>
|
||||
<AcgSearch
|
||||
keyword={searchQuery}
|
||||
triggerSearch={triggerAcgSearch}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
) : searchHistory.length > 0 ? (
|
||||
@@ -1018,11 +1111,20 @@ function SearchPageClient() {
|
||||
if (activeTab === 'video') {
|
||||
// 影视搜索
|
||||
router.push(
|
||||
`/search?q=${encodeURIComponent(item.trim())}`
|
||||
`/search?q=${encodeURIComponent(item.trim())}&type=video`
|
||||
);
|
||||
} else if (activeTab === 'pansou') {
|
||||
// 网盘搜索
|
||||
router.push(
|
||||
`/search?q=${encodeURIComponent(item.trim())}&type=pansou`
|
||||
);
|
||||
setTriggerPansouSearch(prev => !prev);
|
||||
} else if (activeTab === 'acg') {
|
||||
// ACG 磁力搜索
|
||||
router.push(
|
||||
`/search?q=${encodeURIComponent(item.trim())}&type=acg`
|
||||
);
|
||||
setTriggerAcgSearch(prev => !prev);
|
||||
}
|
||||
}}
|
||||
className='px-4 py-2 bg-gray-500/10 hover:bg-gray-300 rounded-full text-sm text-gray-700 transition-colors duration-200 dark:bg-gray-700/50 dark:hover:bg-gray-600 dark:text-gray-300'
|
||||
|
||||
327
src/components/AcgSearch.tsx
Normal file
327
src/components/AcgSearch.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
'use client';
|
||||
|
||||
import { AlertCircle, Download, ExternalLink, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Toast, { ToastProps } from '@/components/Toast';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export default function AcgSearch({
|
||||
keyword,
|
||||
triggerSearch,
|
||||
onError,
|
||||
}: AcgSearchProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [results, setResults] = useState<AcgSearchResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
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 performSearch = async (page: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/acg/search', {
|
||||
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();
|
||||
setResults(data);
|
||||
setCurrentPage(page);
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.message || '搜索失败,请稍后重试';
|
||||
setError(errorMsg);
|
||||
onError?.(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// triggerSearch 变化时触发搜索(无论是 true 还是 false)
|
||||
if (triggerSearch === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentKeyword = keyword.trim();
|
||||
if (!currentKeyword) {
|
||||
return;
|
||||
}
|
||||
|
||||
performSearch(1);
|
||||
}, [triggerSearch]);
|
||||
|
||||
// 处理页码变化
|
||||
const handlePageChange = (newPage: number) => {
|
||||
if (newPage < 1 || loading) return;
|
||||
performSearch(newPage);
|
||||
};
|
||||
|
||||
// 打开命名弹窗
|
||||
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('');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && !results) {
|
||||
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 (!results || results.total === 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-6'>
|
||||
{/* 搜索结果统计 */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='text-sm text-gray-600 dark:text-gray-400'>
|
||||
第 <span className='font-semibold text-green-600 dark:text-green-400'>{currentPage}</span> 页,
|
||||
共 <span className='font-semibold text-green-600 dark:text-green-400'>{results.total}</span> 个结果
|
||||
</div>
|
||||
|
||||
{/* 分页按钮 */}
|
||||
<div className='flex gap-2'>
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1 || loading}
|
||||
className='px-4 py-2 rounded-lg text-sm font-medium bg-gray-100 text-gray-700 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={loading || results.total === 0}
|
||||
className='px-4 py-2 rounded-lg text-sm font-medium bg-gray-100 text-gray-700 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 结果列表 */}
|
||||
<div className='space-y-3'>
|
||||
{results.items.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>
|
||||
|
||||
{/* 命名弹窗 */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Toast 提示 */}
|
||||
{toast && <Toast {...toast} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,8 +58,8 @@ export default function PansouSearch({
|
||||
const [selectedType, setSelectedType] = useState<string>('all'); // 'all' 表示显示全部
|
||||
|
||||
useEffect(() => {
|
||||
// 只在 triggerSearch 变化时执行搜索,不响应 keyword 变化
|
||||
if (!triggerSearch) {
|
||||
// triggerSearch 变化时触发搜索(无论是 true 还是 false)
|
||||
if (triggerSearch === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ export interface AdminConfig {
|
||||
Username: string; // 账号(用于登录获取Token)
|
||||
Password: string; // 密码(用于登录获取Token)
|
||||
RootPath: string; // 根目录路径,默认 "/"
|
||||
OfflineDownloadPath: string; // 离线下载目录,默认 "/"
|
||||
LastRefreshTime?: number; // 上次刷新时间戳
|
||||
ResourceCount?: number; // 资源数量
|
||||
ScanInterval?: number; // 定时扫描间隔(分钟),0表示关闭,最低60分钟
|
||||
|
||||
Reference in New Issue
Block a user