From 06efd8121fd169f4c6af39a32d33258227ff9c72 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Thu, 22 Jan 2026 11:24:11 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A8=E6=BC=AB=E7=A3=81=E5=8A=9B=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E5=A2=9E=E5=8A=A0=E8=9C=9C=E6=9F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/acg/{search => acgrip}/route.ts | 38 ++- src/app/api/acg/mikan/route.ts | 147 +++++++++ src/components/AcgSearch.tsx | 347 +++++++++++--------- 3 files changed, 365 insertions(+), 167 deletions(-) rename src/app/api/acg/{search => acgrip}/route.ts (75%) create mode 100644 src/app/api/acg/mikan/route.ts diff --git a/src/app/api/acg/search/route.ts b/src/app/api/acg/acgrip/route.ts similarity index 75% rename from src/app/api/acg/search/route.ts rename to src/app/api/acg/acgrip/route.ts index a3ea0c5..21be31e 100644 --- a/src/app/api/acg/search/route.ts +++ b/src/app/api/acg/acgrip/route.ts @@ -7,8 +7,8 @@ import { getAuthInfoFromCookie } from '@/lib/auth'; export const runtime = 'nodejs'; /** - * POST /api/acg/search - * 搜索 ACG 磁力资源(仅管理员和站长可用) + * POST /api/acg/acgrip + * 搜索 ACG.RIP 磁力资源(仅管理员和站长可用,支持分页) */ export async function POST(req: NextRequest) { try { @@ -47,10 +47,10 @@ export async function POST(req: NextRequest) { ); } - // 请求 acg.rip API - const acgUrl = `https://acg.rip/page/${pageNum}.xml?term=${encodeURIComponent(trimmedKeyword)}`; + // 请求 acg.rip RSS + const searchUrl = `https://acg.rip/page/${pageNum}.xml?term=${encodeURIComponent(trimmedKeyword)}`; - const response = await fetch(acgUrl, { + const response = await fetch(searchUrl, { 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', }, @@ -78,10 +78,12 @@ export async function POST(req: NextRequest) { // 转换为标准格式 const results = items.map((item: any) => { + const description = item.description?.[0] || ''; + // 提取描述中的图片(如果有) let images: string[] = []; - if (item.description?.[0]) { - const imgMatches = item.description[0].match(/src="([^"]+)"/g); + if (description) { + const imgMatches = description.match(/src="([^"]+)"/g); if (imgMatches) { images = imgMatches.map((match: string) => { const urlMatch = match.match(/src="([^"]+)"/); @@ -90,13 +92,19 @@ export async function POST(req: NextRequest) { } } + const title = item.title?.[0] || ''; + const link = item.link?.[0] || ''; + const guid = item.guid?.[0] || link || `${title}-${item.pubDate?.[0] || ''}`; + const pubDate = item.pubDate?.[0] || ''; + const torrentUrl = item.enclosure?.[0]?.$?.url || ''; + 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] || '', + title, + link, + guid, + pubDate, + torrentUrl, + description, images, }; }); @@ -107,12 +115,12 @@ export async function POST(req: NextRequest) { total: results.length, items: results, }); - } catch (error: any) { - console.error('ACG 搜索失败:', error); + console.error('ACG.RIP 搜索失败:', error); return NextResponse.json( { error: error.message || '搜索失败' }, { status: 500 } ); } } + diff --git a/src/app/api/acg/mikan/route.ts b/src/app/api/acg/mikan/route.ts new file mode 100644 index 0000000..75c41e6 --- /dev/null +++ b/src/app/api/acg/mikan/route.ts @@ -0,0 +1,147 @@ +/* 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'; + +const pickText = (value: any): string => { + if (value === undefined || value === null) return ''; + if (Array.isArray(value)) return String(value[0] ?? ''); + return String(value); +}; + +/** + * POST /api/acg/mikan + * 搜索 Mikan RSS(仅管理员和站长可用,不支持分页) + */ +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 } + ); + } + + // 验证页码(Mikan RSS 不支持分页,这里仍接收 page 以保持接口一致) + const pageNum = parseInt(String(page), 10); + if (isNaN(pageNum) || pageNum < 1) { + return NextResponse.json( + { error: '页码必须是大于0的整数' }, + { status: 400 } + ); + } + + if (pageNum > 1) { + return NextResponse.json({ + keyword: trimmedKeyword, + page: pageNum, + total: 0, + items: [], + }); + } + + const searchUrl = `https://mikanani.me/RSS/Search?searchstr=${encodeURIComponent(trimmedKeyword)}`; + + const response = await fetch(searchUrl, { + 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(`Mikan API 请求失败: ${response.status}`); + } + + const xmlData = await response.text(); + 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) => { + const title = pickText(item.title); + const link = pickText(item.link); + const guid = pickText(item.guid) || link || `${title}-${pickText(item.torrent?.[0]?.pubDate)}`; + const pubDate = + pickText(item.pubDate) || + pickText(item.torrent?.[0]?.pubDate) || + pickText(item['dc:date']); + + const description = + pickText(item.description) || + pickText(item['content:encoded']) || + ''; + + const torrentUrl = + pickText(item.enclosure?.[0]?.$?.url) || + pickText(item.enclosure?.[0]?.$?.href) || + ''; + + // 提取描述中的图片(如果有) + let images: string[] = []; + if (description) { + const imgMatches = description.match(/src="([^"]+)"/g); + if (imgMatches) { + images = imgMatches.map((match: string) => { + const urlMatch = match.match(/src="([^"]+)"/); + return urlMatch ? urlMatch[1] : ''; + }).filter(Boolean); + } + } + + return { + title, + link, + guid, + pubDate, + torrentUrl, + description, + images, + }; + }); + + return NextResponse.json({ + keyword: trimmedKeyword, + page: pageNum, + total: results.length, + items: results, + }); + } catch (error: any) { + console.error('Mikan 搜索失败:', error); + return NextResponse.json( + { error: error.message || '搜索失败' }, + { status: 500 } + ); + } +} + diff --git a/src/components/AcgSearch.tsx b/src/components/AcgSearch.tsx index a3248e3..eef3b94 100644 --- a/src/components/AcgSearch.tsx +++ b/src/components/AcgSearch.tsx @@ -5,6 +5,7 @@ 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; @@ -29,11 +30,14 @@ interface AcgSearchProps { onError?: (error: string) => void; } +type AcgSearchSource = 'acgrip' | 'mikan'; + export default function AcgSearch({ keyword, triggerSearch, onError, }: AcgSearchProps) { + const [source, setSource] = useState('acgrip'); const [loading, setLoading] = useState(false); const [allItems, setAllItems] = useState([]); // 所有加载的项目 const [error, setError] = useState(null); @@ -46,17 +50,20 @@ export default function AcgSearch({ const [toast, setToast] = useState(null); const loadMoreRef = useRef(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; isLoadingMoreRef.current = true; setLoading(true); setError(null); try { - const response = await fetch('/api/acg/search', { + const apiUrl = source === 'mikan' ? '/api/acg/mikan' : '/api/acg/acgrip'; + const response = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -78,12 +85,12 @@ export default function AcgSearch({ // 追加新数据 setAllItems(prev => [...prev, ...data.items]); // 如果当前页没有结果,说明没有更多了 - setHasMore(data.items.length > 0); + setHasMore(source === 'acgrip' && data.items.length > 0); } else { // 新搜索,重置数据 setAllItems(data.items); // 如果第一页有结果,假设可能还有更多 - setHasMore(data.items.length > 0); + setHasMore(source === 'acgrip' && data.items.length > 0); } setCurrentPage(page); @@ -115,12 +122,29 @@ export default function AcgSearch({ 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 (!loading && hasMore && !isLoadingMoreRef.current) { performSearch(currentPage + 1, true); } - }, [loading, hasMore, currentPage]); + }, [loading, hasMore, currentPage, source]); // 使用 Intersection Observer 监听滚动到底部 useEffect(() => { @@ -200,161 +224,180 @@ export default function AcgSearch({ } }; - if (loading && allItems.length === 0) { - return ( -
-
- -

- 正在搜索动漫资源... -

-
-
- ); - } - - if (error) { - return ( -
-
- -

{error}

-
-
- ); - } - - if (allItems.length === 0) { - return ( -
-
- -

- 未找到相关资源 -

-
-
- ); - } - - return ( -
- {/* 结果列表 */} -
- {allItems.map((item) => ( -
- {/* 标题 */} -
- {item.title} -
- - {/* 发布时间 */} -
- {new Date(item.pubDate).toLocaleString('zh-CN')} -
- - {/* 图片预览 */} - {item.images && item.images.length > 0 && ( -
- {item.images.slice(0, 3).map((img, imgIndex) => ( - - ))} -
- )} - - {/* 操作按钮 */} -
- - - - 详情 - -
-
- ))} -
- - {/* 加载更多指示器 */} - {hasMore && ( -
+ const renderBody = () => { + if (loading && allItems.length === 0) { + return ( +
- -

- 加载更多... + +

+ 正在搜索动漫资源...

- )} + ); + } - {/* 命名弹窗 */} - {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 - /> -
- - -
+ if (error) { + return ( +
+
+ +

{error}

- )} + ); + } + + if (allItems.length === 0) { + return ( +
+
+ +

+ 未找到相关资源 +

+
+
+ ); + } + + return ( + <> + {/* 结果列表 */} +
+ {allItems.map((item) => ( +
+ {/* 标题 */} +
+ {item.title} +
+ + {/* 发布时间 */} +
+ {new Date(item.pubDate).toLocaleString('zh-CN')} +
+ + {/* 图片预览 */} + {item.images && item.images.length > 0 && ( +
+ {item.images.slice(0, 3).map((img, imgIndex) => ( + + ))} +
+ )} + + {/* 操作按钮 */} +
+ + + + 详情 + +
+
+ ))} +
+ + {/* 加载更多指示器 */} + {source === 'acgrip' && hasMore && ( +
+
+ +

+ 加载更多... +

+
+
+ )} + + {/* 命名弹窗 */} + {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 + /> +
+ + +
+
+
+ )} + + ); + }; + + return ( +
+ {/* 搜索源切换 */} +
+ setSource(value as AcgSearchSource)} + /> +
+ {renderBody()} {/* Toast 提示 */} {toast && }