From 33193d9ae1e9672704d913713d90f632e710f67f Mon Sep 17 00:00:00 2001 From: mtvpls <247332661+mtvpls@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:31:40 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B5=8B=E6=B4=BB=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/acg/acgrip/route.ts | 42 +++++--- src/app/api/acg/dmhy/route.ts | 32 +++++-- src/app/api/acg/mikan/route.ts | 20 ++-- src/components/AcgSearch.tsx | 163 +++++++++++++++++++++++++------- 4 files changed, 197 insertions(+), 60 deletions(-) diff --git a/src/app/api/acg/acgrip/route.ts b/src/app/api/acg/acgrip/route.ts index 809fb75..6a97d66 100644 --- a/src/app/api/acg/acgrip/route.ts +++ b/src/app/api/acg/acgrip/route.ts @@ -9,6 +9,16 @@ import { hasFeaturePermission } from '@/lib/permissions'; export const runtime = 'nodejs'; +const pickText = (value: any): string => { + if (value === undefined || value === null) return ''; + const first = Array.isArray(value) ? value[0] : value; + if (first === undefined || first === null) return ''; + if (typeof first === 'object') { + return String(first._ ?? first.$?.url ?? first.$?.href ?? '').trim(); + } + return String(first).trim(); +}; + /** * POST /api/acg/acgrip * 搜索 ACG.RIP 磁力资源(仅管理员和站长可用,支持分页) @@ -86,27 +96,35 @@ export async function POST(req: NextRequest) { const items = parsed.rss.channel[0].item; // 转换为标准格式 - const results = items.map((item: any) => { - const description = item.description?.[0] || ''; + const results = items.map((item: any, index: number) => { + const title = pickText(item.title); + const link = pickText(item.link); + const pubDate = pickText(item.pubDate); + const description = pickText(item.description); + const torrentUrl = + pickText(item.enclosure?.[0]?.$?.url) || + pickText(item.enclosure?.[0]?.$?.href) || + pickText(item.enclosure?.[0]); + const guid = + pickText(item.guid) || + torrentUrl || + link || + `${title}-${pubDate}-${index}`; // 提取描述中的图片(如果有) 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); + images = imgMatches + .map((match: string) => { + const urlMatch = match.match(/src="([^"]+)"/); + return urlMatch ? urlMatch[1] : ''; + }) + .filter(Boolean); } } - 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, link, diff --git a/src/app/api/acg/dmhy/route.ts b/src/app/api/acg/dmhy/route.ts index 41ef47a..89ea310 100644 --- a/src/app/api/acg/dmhy/route.ts +++ b/src/app/api/acg/dmhy/route.ts @@ -9,6 +9,16 @@ import { hasFeaturePermission } from '@/lib/permissions'; export const runtime = 'nodejs'; +const pickText = (value: any): string => { + if (value === undefined || value === null) return ''; + const first = Array.isArray(value) ? value[0] : value; + if (first === undefined || first === null) return ''; + if (typeof first === 'object') { + return String(first._ ?? first.$?.url ?? first.$?.href ?? '').trim(); + } + return String(first).trim(); +}; + /** * POST /api/acg/dmhy * 搜索 动漫花园 (share.dmhy.org) RSS(仅管理员和站长可用) @@ -92,13 +102,21 @@ export async function POST(req: NextRequest) { const items = parsed.rss.channel[0].item; - const results = items.map((item: any) => { - 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 description = item.description?.[0] || ''; - const torrentUrl = item.enclosure?.[0]?.$?.url || ''; + const results = items.map((item: any, index: number) => { + const title = pickText(item.title); + const link = pickText(item.link); + const pubDate = pickText(item.pubDate); + const description = pickText(item.description); + const torrentUrl = + pickText(item.enclosure?.[0]?.$?.url) || + pickText(item.enclosure?.[0]?.$?.href) || + pickText(item.enclosure?.[0]); + // guid 在 xml2js 下常为 { _: url, $: { isPermaLink } },必须抽成纯字符串,否则前端 key 全变成 [object Object] + const guid = + pickText(item.guid) || + torrentUrl || + link || + `${title}-${pubDate}-${index}`; // 提取描述中的图片(如果有) let images: string[] = []; diff --git a/src/app/api/acg/mikan/route.ts b/src/app/api/acg/mikan/route.ts index 17de4de..1c64f6c 100644 --- a/src/app/api/acg/mikan/route.ts +++ b/src/app/api/acg/mikan/route.ts @@ -11,8 +11,12 @@ 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); + const first = Array.isArray(value) ? value[0] : value; + if (first === undefined || first === null) return ''; + if (typeof first === 'object') { + return String(first._ ?? first.$?.url ?? first.$?.href ?? '').trim(); + } + return String(first).trim(); }; /** @@ -97,24 +101,28 @@ export async function POST(req: NextRequest) { const items = parsed.rss.channel[0].item; - const results = items.map((item: any) => { + const results = items.map((item: any, index: number) => { 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) || + pickText(item.enclosure?.[0]) || ''; + // guid 必须是唯一纯字符串,避免 xml 对象导致前端 key 冲突 + const guid = + pickText(item.guid) || + torrentUrl || + link || + `${title}-${pubDate}-${index}`; // 提取描述中的图片(如果有) let images: string[] = []; diff --git a/src/components/AcgSearch.tsx b/src/components/AcgSearch.tsx index 1db2ad4..5e5c848 100644 --- a/src/components/AcgSearch.tsx +++ b/src/components/AcgSearch.tsx @@ -54,6 +54,35 @@ interface AcgSearchProps { type AcgSearchSource = 'acgrip' | 'mikan' | 'dmhy' | 'nyaa'; type DownloadTool = 'aria2' | 'Transmission' | 'qBittorrent'; +/** 把可能的 xml 对象/空值收成稳定字符串,避免 key 变成 [object Object] */ +function asItemText(value: unknown): string { + if (typeof value === 'string') return value.trim(); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (value && typeof value === 'object') { + const obj = value as Record; + if (typeof obj._ === 'string') return obj._.trim(); + if (typeof obj.url === 'string') return obj.url.trim(); + if (typeof (obj as any).$?.url === 'string') return String((obj as any).$.url).trim(); + } + return ''; +} + +/** 单条列表行身份:业务字段 + index,避免 guid 冲突导致整表联动 */ +function getAcgItemId(item: AcgSearchItem, index?: number): string { + const torrentUrl = asItemText(item.torrentUrl); + const link = asItemText(item.link); + const guid = asItemText(item.guid); + const title = asItemText(item.title); + const pubDate = asItemText(item.pubDate); + const base = + torrentUrl || + link || + guid || + (title || pubDate ? `${title}|${pubDate}` : 'acg-item'); + if (typeof index === 'number') return `${base}#${index}`; + return base; +} + const downloadToolOptions: Array<{ value: DownloadTool; label: string }> = [ { value: 'aria2', label: 'aria2' }, { value: 'qBittorrent', label: 'qBittorrent' }, @@ -166,27 +195,83 @@ export default function AcgSearch({ } const data: AcgSearchResult = await response.json(); + // 规范化字段,避免 guid 为对象时整表共用同一个 key + const normalizedItems: AcgSearchItem[] = (data.items || []).map( + (item, index) => { + const title = asItemText(item.title); + const link = asItemText(item.link); + const torrentUrl = asItemText(item.torrentUrl); + const pubDate = asItemText(item.pubDate); + const description = asItemText(item.description); + const guid = getAcgItemId( + { + ...item, + title, + link, + torrentUrl, + pubDate, + guid: asItemText(item.guid), + description, + images: Array.isArray(item.images) ? item.images : [], + }, + index + ); + return { + title, + link, + guid, + pubDate, + torrentUrl, + description, + images: Array.isArray(item.images) ? item.images : [], + }; + } + ); if (isLoadMore) { - // 追加新数据 - setAllItems((prev) => [...prev, ...data.items]); + // 追加新数据(续页 index 用当前长度偏移,保证 key 不撞) + setAllItems((prev) => { + const offset = prev.length; + const appended = (data.items || []).map((item, index) => { + const title = asItemText(item.title); + const link = asItemText(item.link); + const torrentUrl = asItemText(item.torrentUrl); + const pubDate = asItemText(item.pubDate); + const description = asItemText(item.description); + const baseItem: AcgSearchItem = { + title, + link, + torrentUrl, + pubDate, + description, + guid: asItemText(item.guid), + images: Array.isArray(item.images) ? item.images : [], + }; + return { + ...baseItem, + guid: getAcgItemId(baseItem, offset + index), + }; + }); + return [...prev, ...appended]; + }); // 如果当前页没有结果,说明没有更多了 setHasMore( source !== 'mikan' && source !== 'dmhy' && source !== 'nyaa' && - data.items.length > 0 + normalizedItems.length > 0 ); } else { // 新搜索,重置数据 - setAllItems(data.items); + setAllItems(normalizedItems); setHealthMap({}); + setHealthCheckingIds({}); // 如果第一页有结果,假设可能还有更多 setHasMore( source !== 'mikan' && source !== 'dmhy' && source !== 'nyaa' && - data.items.length > 0 + normalizedItems.length > 0 ); } @@ -299,11 +384,12 @@ export default function AcgSearch({ } }; - // 单条测活(全站并发由服务端限制为 10;前端可同时点多条) - const handleCheckHealth = async (item: AcgSearchItem) => { - if (!item.torrentUrl || healthCheckingIds[item.guid]) return; + // 单条测活(全站并发由服务端限制;前端可同时点多条) + const handleCheckHealth = async (item: AcgSearchItem, index: number) => { + const itemId = getAcgItemId(item, index); + if (!item.torrentUrl || healthCheckingIds[itemId]) return; - setHealthCheckingIds((prev) => ({ ...prev, [item.guid]: true })); + setHealthCheckingIds((prev) => ({ ...prev, [itemId]: true })); try { const response = await fetch('/api/acg/health', { method: 'POST', @@ -313,7 +399,7 @@ export default function AcgSearch({ body: JSON.stringify({ url: item.torrentUrl, // 已有结果时点「重新测活」跳过缓存 - skipCache: Boolean(healthMap[item.guid]), + skipCache: Boolean(healthMap[itemId]), }), }); const data = await response.json(); @@ -324,7 +410,7 @@ export default function AcgSearch({ setHealthMap((prev) => ({ ...prev, - [item.guid]: { + [itemId]: { health: data.health as MagnetHealthLevel, seeders: data.seeders ?? 0, leechers: data.leechers ?? 0, @@ -344,15 +430,15 @@ export default function AcgSearch({ } finally { setHealthCheckingIds((prev) => { const next = { ...prev }; - delete next[item.guid]; + delete next[itemId]; return next; }); } }; // 打开命名弹窗 - const handleOpenDownloadDialog = (item: AcgSearchItem) => { - setSelectedItem(item); + const handleOpenDownloadDialog = (item: AcgSearchItem, index: number) => { + setSelectedItem({ ...item, guid: getAcgItemId(item, index) }); setCustomName(keyword.trim()); setShowNameDialog(true); }; @@ -448,9 +534,13 @@ export default function AcgSearch({ <> {/* 结果列表 */}
- {allItems.map((item) => ( + {allItems.map((item, index) => { + const itemId = getAcgItemId(item, index); + const health = healthMap[itemId]; + const isHealthChecking = Boolean(healthCheckingIds[itemId]); + return (
{/* 标题 */} @@ -460,7 +550,9 @@ export default function AcgSearch({ {/* 发布时间 */}
- {new Date(item.pubDate).toLocaleString('zh-CN')} + {item.pubDate + ? new Date(item.pubDate).toLocaleString('zh-CN') + : ''}
{/* 图片预览 */} @@ -479,27 +571,27 @@ export default function AcgSearch({ )} {/* 测活结果 */} - {healthMap[item.guid] && ( + {health && (
- {healthLabel(healthMap[item.guid].health)} + {healthLabel(health.health)} - Seeder {healthMap[item.guid].seeders} + Seeder {health.seeders} {' · '} - Leecher {healthMap[item.guid].leechers} + Leecher {health.leechers} {' · '} - Peer {healthMap[item.guid].peers} + Peer {health.peers} - {typeof healthMap[item.guid].durationMs === 'number' && ( + {typeof health.durationMs === 'number' && ( - {healthMap[item.guid].source === 'cache' + {health.source === 'cache' ? '缓存' - : `${healthMap[item.guid].durationMs}ms`} + : `${health.durationMs}ms`} )}
@@ -508,12 +600,12 @@ export default function AcgSearch({ {/* 操作按钮 */}
- ))} + ); + })}
{/* 加载更多指示器 */}