修复测活列表状态

This commit is contained in:
mtvpls
2026-07-30 15:31:40 +08:00
parent af2a7f9fc7
commit 33193d9ae1
4 changed files with 197 additions and 60 deletions
+28 -10
View File
@@ -9,6 +9,16 @@ import { hasFeaturePermission } from '@/lib/permissions';
export const runtime = 'nodejs'; 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 * POST /api/acg/acgrip
* 搜索 ACG.RIP 磁力资源(仅管理员和站长可用,支持分页) * 搜索 ACG.RIP 磁力资源(仅管理员和站长可用,支持分页)
@@ -86,27 +96,35 @@ export async function POST(req: NextRequest) {
const items = parsed.rss.channel[0].item; const items = parsed.rss.channel[0].item;
// 转换为标准格式 // 转换为标准格式
const results = items.map((item: any) => { const results = items.map((item: any, index: number) => {
const description = item.description?.[0] || ''; 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[] = []; let images: string[] = [];
if (description) { if (description) {
const imgMatches = description.match(/src="([^"]+)"/g); const imgMatches = description.match(/src="([^"]+)"/g);
if (imgMatches) { if (imgMatches) {
images = imgMatches.map((match: string) => { images = imgMatches
.map((match: string) => {
const urlMatch = match.match(/src="([^"]+)"/); const urlMatch = match.match(/src="([^"]+)"/);
return urlMatch ? urlMatch[1] : ''; return urlMatch ? urlMatch[1] : '';
}).filter(Boolean); })
.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 { return {
title, title,
link, link,
+25 -7
View File
@@ -9,6 +9,16 @@ import { hasFeaturePermission } from '@/lib/permissions';
export const runtime = 'nodejs'; 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 * POST /api/acg/dmhy
* 搜索 动漫花园 (share.dmhy.org) RSS(仅管理员和站长可用) * 搜索 动漫花园 (share.dmhy.org) RSS(仅管理员和站长可用)
@@ -92,13 +102,21 @@ export async function POST(req: NextRequest) {
const items = parsed.rss.channel[0].item; const items = parsed.rss.channel[0].item;
const results = items.map((item: any) => { const results = items.map((item: any, index: number) => {
const title = item.title?.[0] || ''; const title = pickText(item.title);
const link = item.link?.[0] || ''; const link = pickText(item.link);
const guid = item.guid?.[0] || link || `${title}-${item.pubDate?.[0] || ''}`; const pubDate = pickText(item.pubDate);
const pubDate = item.pubDate?.[0] || ''; const description = pickText(item.description);
const description = item.description?.[0] || ''; const torrentUrl =
const torrentUrl = item.enclosure?.[0]?.$?.url || ''; 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[] = []; let images: string[] = [];
+14 -6
View File
@@ -11,8 +11,12 @@ export const runtime = 'nodejs';
const pickText = (value: any): string => { const pickText = (value: any): string => {
if (value === undefined || value === null) return ''; if (value === undefined || value === null) return '';
if (Array.isArray(value)) return String(value[0] ?? ''); const first = Array.isArray(value) ? value[0] : value;
return String(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 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 title = pickText(item.title);
const link = pickText(item.link); const link = pickText(item.link);
const guid = pickText(item.guid) || link || `${title}-${pickText(item.torrent?.[0]?.pubDate)}`;
const pubDate = const pubDate =
pickText(item.pubDate) || pickText(item.pubDate) ||
pickText(item.torrent?.[0]?.pubDate) || pickText(item.torrent?.[0]?.pubDate) ||
pickText(item['dc:date']); pickText(item['dc:date']);
const description = const description =
pickText(item.description) || pickText(item.description) ||
pickText(item['content:encoded']) || pickText(item['content:encoded']) ||
''; '';
const torrentUrl = const torrentUrl =
pickText(item.enclosure?.[0]?.$?.url) || pickText(item.enclosure?.[0]?.$?.url) ||
pickText(item.enclosure?.[0]?.$?.href) || 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[] = []; let images: string[] = [];
+128 -35
View File
@@ -54,6 +54,35 @@ interface AcgSearchProps {
type AcgSearchSource = 'acgrip' | 'mikan' | 'dmhy' | 'nyaa'; type AcgSearchSource = 'acgrip' | 'mikan' | 'dmhy' | 'nyaa';
type DownloadTool = 'aria2' | 'Transmission' | 'qBittorrent'; 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<string, unknown>;
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 }> = [ const downloadToolOptions: Array<{ value: DownloadTool; label: string }> = [
{ value: 'aria2', label: 'aria2' }, { value: 'aria2', label: 'aria2' },
{ value: 'qBittorrent', label: 'qBittorrent' }, { value: 'qBittorrent', label: 'qBittorrent' },
@@ -166,27 +195,83 @@ export default function AcgSearch({
} }
const data: AcgSearchResult = await response.json(); 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) { if (isLoadMore) {
// 追加新数据 // 追加新数据(续页 index 用当前长度偏移,保证 key 不撞)
setAllItems((prev) => [...prev, ...data.items]); 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( setHasMore(
source !== 'mikan' && source !== 'mikan' &&
source !== 'dmhy' && source !== 'dmhy' &&
source !== 'nyaa' && source !== 'nyaa' &&
data.items.length > 0 normalizedItems.length > 0
); );
} else { } else {
// 新搜索,重置数据 // 新搜索,重置数据
setAllItems(data.items); setAllItems(normalizedItems);
setHealthMap({}); setHealthMap({});
setHealthCheckingIds({});
// 如果第一页有结果,假设可能还有更多 // 如果第一页有结果,假设可能还有更多
setHasMore( setHasMore(
source !== 'mikan' && source !== 'mikan' &&
source !== 'dmhy' && source !== 'dmhy' &&
source !== 'nyaa' && source !== 'nyaa' &&
data.items.length > 0 normalizedItems.length > 0
); );
} }
@@ -299,11 +384,12 @@ export default function AcgSearch({
} }
}; };
// 单条测活(全站并发由服务端限制为 10;前端可同时点多条) // 单条测活(全站并发由服务端限制;前端可同时点多条)
const handleCheckHealth = async (item: AcgSearchItem) => { const handleCheckHealth = async (item: AcgSearchItem, index: number) => {
if (!item.torrentUrl || healthCheckingIds[item.guid]) return; const itemId = getAcgItemId(item, index);
if (!item.torrentUrl || healthCheckingIds[itemId]) return;
setHealthCheckingIds((prev) => ({ ...prev, [item.guid]: true })); setHealthCheckingIds((prev) => ({ ...prev, [itemId]: true }));
try { try {
const response = await fetch('/api/acg/health', { const response = await fetch('/api/acg/health', {
method: 'POST', method: 'POST',
@@ -313,7 +399,7 @@ export default function AcgSearch({
body: JSON.stringify({ body: JSON.stringify({
url: item.torrentUrl, url: item.torrentUrl,
// 已有结果时点「重新测活」跳过缓存 // 已有结果时点「重新测活」跳过缓存
skipCache: Boolean(healthMap[item.guid]), skipCache: Boolean(healthMap[itemId]),
}), }),
}); });
const data = await response.json(); const data = await response.json();
@@ -324,7 +410,7 @@ export default function AcgSearch({
setHealthMap((prev) => ({ setHealthMap((prev) => ({
...prev, ...prev,
[item.guid]: { [itemId]: {
health: data.health as MagnetHealthLevel, health: data.health as MagnetHealthLevel,
seeders: data.seeders ?? 0, seeders: data.seeders ?? 0,
leechers: data.leechers ?? 0, leechers: data.leechers ?? 0,
@@ -344,15 +430,15 @@ export default function AcgSearch({
} finally { } finally {
setHealthCheckingIds((prev) => { setHealthCheckingIds((prev) => {
const next = { ...prev }; const next = { ...prev };
delete next[item.guid]; delete next[itemId];
return next; return next;
}); });
} }
}; };
// 打开命名弹窗 // 打开命名弹窗
const handleOpenDownloadDialog = (item: AcgSearchItem) => { const handleOpenDownloadDialog = (item: AcgSearchItem, index: number) => {
setSelectedItem(item); setSelectedItem({ ...item, guid: getAcgItemId(item, index) });
setCustomName(keyword.trim()); setCustomName(keyword.trim());
setShowNameDialog(true); setShowNameDialog(true);
}; };
@@ -448,9 +534,13 @@ export default function AcgSearch({
<> <>
{/* 结果列表 */} {/* 结果列表 */}
<div className='space-y-3'> <div className='space-y-3'>
{allItems.map((item) => ( {allItems.map((item, index) => {
const itemId = getAcgItemId(item, index);
const health = healthMap[itemId];
const isHealthChecking = Boolean(healthCheckingIds[itemId]);
return (
<div <div
key={item.guid} key={itemId}
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' 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'
> >
{/* 标题 */} {/* 标题 */}
@@ -460,7 +550,9 @@ export default function AcgSearch({
{/* 发布时间 */} {/* 发布时间 */}
<div className='mb-2 text-xs text-gray-500 dark:text-gray-400'> <div className='mb-2 text-xs text-gray-500 dark:text-gray-400'>
{new Date(item.pubDate).toLocaleString('zh-CN')} {item.pubDate
? new Date(item.pubDate).toLocaleString('zh-CN')
: ''}
</div> </div>
{/* 图片预览 */} {/* 图片预览 */}
@@ -479,27 +571,27 @@ export default function AcgSearch({
)} )}
{/* 测活结果 */} {/* 测活结果 */}
{healthMap[item.guid] && ( {health && (
<div className='mb-3 flex flex-wrap items-center gap-2 text-xs'> <div className='mb-3 flex flex-wrap items-center gap-2 text-xs'>
<span <span
className={`inline-flex items-center rounded-full px-2 py-0.5 font-medium ${healthBadgeClass( className={`inline-flex items-center rounded-full px-2 py-0.5 font-medium ${healthBadgeClass(
healthMap[item.guid].health health.health
)}`} )}`}
> >
{healthLabel(healthMap[item.guid].health)} {healthLabel(health.health)}
</span> </span>
<span className='text-gray-600 dark:text-gray-300'> <span className='text-gray-600 dark:text-gray-300'>
Seeder {healthMap[item.guid].seeders} Seeder {health.seeders}
{' · '} {' · '}
Leecher {healthMap[item.guid].leechers} Leecher {health.leechers}
{' · '} {' · '}
Peer {healthMap[item.guid].peers} Peer {health.peers}
</span> </span>
{typeof healthMap[item.guid].durationMs === 'number' && ( {typeof health.durationMs === 'number' && (
<span className='text-gray-400 dark:text-gray-500'> <span className='text-gray-400 dark:text-gray-500'>
{healthMap[item.guid].source === 'cache' {health.source === 'cache'
? '缓存' ? '缓存'
: `${healthMap[item.guid].durationMs}ms`} : `${health.durationMs}ms`}
</span> </span>
)} )}
</div> </div>
@@ -508,12 +600,12 @@ export default function AcgSearch({
{/* 操作按钮 */} {/* 操作按钮 */}
<div className='flex flex-wrap items-center gap-2'> <div className='flex flex-wrap items-center gap-2'>
<button <button
onClick={() => handleOpenDownloadDialog(item)} onClick={() => handleOpenDownloadDialog(item, index)}
disabled={downloadingId === item.guid} disabled={downloadingId === itemId}
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' 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='存到私人影库' title='存到私人影库'
> >
{downloadingId === item.guid ? ( {downloadingId === itemId ? (
<> <>
<Loader2 className='h-4 w-4 animate-spin' /> <Loader2 className='h-4 w-4 animate-spin' />
<span>...</span> <span>...</span>
@@ -526,12 +618,12 @@ export default function AcgSearch({
)} )}
</button> </button>
<button <button
onClick={() => handleCheckHealth(item)} onClick={() => handleCheckHealth(item, index)}
disabled={!item.torrentUrl || Boolean(healthCheckingIds[item.guid])} disabled={!item.torrentUrl || isHealthChecking}
className='flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-sky-600 text-white text-sm hover:bg-sky-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors' className='flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-sky-600 text-white text-sm hover:bg-sky-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors'
title='Tracker 测活(全站并发上限可由 MAGNET_HEALTH_MAX_CONCURRENT 配置)' title='Tracker 测活(全站并发上限可由 MAGNET_HEALTH_MAX_CONCURRENT 配置)'
> >
{healthCheckingIds[item.guid] ? ( {isHealthChecking ? (
<> <>
<Loader2 className='h-4 w-4 animate-spin' /> <Loader2 className='h-4 w-4 animate-spin' />
<span>...</span> <span>...</span>
@@ -539,12 +631,12 @@ export default function AcgSearch({
) : ( ) : (
<> <>
<Activity className='h-4 w-4' /> <Activity className='h-4 w-4' />
<span>{healthMap[item.guid] ? '重新测活' : '测活'}</span> <span>{health ? '重新测活' : '测活'}</span>
</> </>
)} )}
</button> </button>
<a <a
href={item.link} href={item.link || item.torrentUrl || '#'}
target='_blank' target='_blank'
rel='noopener noreferrer' 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' 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'
@@ -555,7 +647,8 @@ export default function AcgSearch({
</a> </a>
</div> </div>
</div> </div>
))} );
})}
</div> </div>
{/* 加载更多指示器 */} {/* 加载更多指示器 */}