修复测活列表状态
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
+128
-35
@@ -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<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 }> = [
|
||||
{ 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({
|
||||
<>
|
||||
{/* 结果列表 */}
|
||||
<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
|
||||
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'
|
||||
>
|
||||
{/* 标题 */}
|
||||
@@ -460,7 +550,9 @@ export default function AcgSearch({
|
||||
|
||||
{/* 发布时间 */}
|
||||
<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>
|
||||
|
||||
{/* 图片预览 */}
|
||||
@@ -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'>
|
||||
<span
|
||||
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 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>
|
||||
{typeof healthMap[item.guid].durationMs === 'number' && (
|
||||
{typeof health.durationMs === 'number' && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
@@ -508,12 +600,12 @@ export default function AcgSearch({
|
||||
{/* 操作按钮 */}
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<button
|
||||
onClick={() => handleOpenDownloadDialog(item)}
|
||||
disabled={downloadingId === item.guid}
|
||||
onClick={() => handleOpenDownloadDialog(item, index)}
|
||||
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'
|
||||
title='存到私人影库'
|
||||
>
|
||||
{downloadingId === item.guid ? (
|
||||
{downloadingId === itemId ? (
|
||||
<>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
<span>下载中...</span>
|
||||
@@ -526,12 +618,12 @@ export default function AcgSearch({
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCheckHealth(item)}
|
||||
disabled={!item.torrentUrl || Boolean(healthCheckingIds[item.guid])}
|
||||
onClick={() => handleCheckHealth(item, index)}
|
||||
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'
|
||||
title='Tracker 测活(全站并发上限可由 MAGNET_HEALTH_MAX_CONCURRENT 配置)'
|
||||
>
|
||||
{healthCheckingIds[item.guid] ? (
|
||||
{isHealthChecking ? (
|
||||
<>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
<span>测活中...</span>
|
||||
@@ -539,12 +631,12 @@ export default function AcgSearch({
|
||||
) : (
|
||||
<>
|
||||
<Activity className='h-4 w-4' />
|
||||
<span>{healthMap[item.guid] ? '重新测活' : '测活'}</span>
|
||||
<span>{health ? '重新测活' : '测活'}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<a
|
||||
href={item.link}
|
||||
href={item.link || item.torrentUrl || '#'}
|
||||
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'
|
||||
@@ -555,7 +647,8 @@ export default function AcgSearch({
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 加载更多指示器 */}
|
||||
|
||||
Reference in New Issue
Block a user