增加磁链测活功能

This commit is contained in:
mtvpls
2026-07-30 15:13:09 +08:00
parent a68bc35ab6
commit af2a7f9fc7
5 changed files with 1332 additions and 25 deletions
+150 -2
View File
@@ -1,7 +1,13 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import { AlertCircle, Download, ExternalLink, Loader2 } from 'lucide-react';
import {
Activity,
AlertCircle,
Download,
ExternalLink,
Loader2,
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import CapsuleSwitch from '@/components/CapsuleSwitch';
@@ -17,6 +23,19 @@ interface AcgSearchItem {
images: string[];
}
type MagnetHealthLevel = 'good' | 'ok' | 'risk' | 'unknown';
interface MagnetHealthView {
health: MagnetHealthLevel;
seeders: number;
leechers: number;
peers: number;
message: string;
infoHash?: string;
source?: 'scrape' | 'cache';
durationMs?: number;
}
interface AcgSearchResult {
keyword: string;
page: number;
@@ -78,6 +97,12 @@ export default function AcgSearch({
const [customName, setCustomName] = useState('');
const [downloadTool, setDownloadTool] = useState<DownloadTool>('aria2');
const [toast, setToast] = useState<ToastProps | null>(null);
const [healthMap, setHealthMap] = useState<Record<string, MagnetHealthView>>(
{}
);
const [healthCheckingIds, setHealthCheckingIds] = useState<
Record<string, true>
>({});
const loadMoreRef = useRef<HTMLDivElement>(null);
const isLoadingMoreRef = useRef(false);
const didInitSourceRef = useRef(false);
@@ -155,6 +180,7 @@ export default function AcgSearch({
} else {
// 新搜索,重置数据
setAllItems(data.items);
setHealthMap({});
// 如果第一页有结果,假设可能还有更多
setHasMore(
source !== 'mikan' &&
@@ -247,6 +273,83 @@ export default function AcgSearch({
};
}, [loadMore]);
const healthBadgeClass = (level: MagnetHealthLevel) => {
switch (level) {
case 'good':
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300';
case 'ok':
return 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200';
case 'risk':
return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300';
default:
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300';
}
};
const healthLabel = (level: MagnetHealthLevel) => {
switch (level) {
case 'good':
return '健康';
case 'ok':
return '一般';
case 'risk':
return '风险';
default:
return '未知';
}
};
// 单条测活(全站并发由服务端限制为 10;前端可同时点多条)
const handleCheckHealth = async (item: AcgSearchItem) => {
if (!item.torrentUrl || healthCheckingIds[item.guid]) return;
setHealthCheckingIds((prev) => ({ ...prev, [item.guid]: true }));
try {
const response = await fetch('/api/acg/health', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: item.torrentUrl,
// 已有结果时点「重新测活」跳过缓存
skipCache: Boolean(healthMap[item.guid]),
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '测活失败');
}
setHealthMap((prev) => ({
...prev,
[item.guid]: {
health: data.health as MagnetHealthLevel,
seeders: data.seeders ?? 0,
leechers: data.leechers ?? 0,
peers: data.peers ?? 0,
message: data.message || '',
infoHash: data.infoHash,
source: data.source,
durationMs: data.durationMs,
},
}));
} catch (err: any) {
setToast({
message: err.message || '测活失败',
type: 'error',
onClose: () => setToast(null),
});
} finally {
setHealthCheckingIds((prev) => {
const next = { ...prev };
delete next[item.guid];
return next;
});
}
};
// 打开命名弹窗
const handleOpenDownloadDialog = (item: AcgSearchItem) => {
setSelectedItem(item);
@@ -375,8 +478,35 @@ export default function AcgSearch({
</div>
)}
{/* 测活结果 */}
{healthMap[item.guid] && (
<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
)}`}
>
{healthLabel(healthMap[item.guid].health)}
</span>
<span className='text-gray-600 dark:text-gray-300'>
Seeder {healthMap[item.guid].seeders}
{' · '}
Leecher {healthMap[item.guid].leechers}
{' · '}
Peer {healthMap[item.guid].peers}
</span>
{typeof healthMap[item.guid].durationMs === 'number' && (
<span className='text-gray-400 dark:text-gray-500'>
{healthMap[item.guid].source === 'cache'
? '缓存'
: `${healthMap[item.guid].durationMs}ms`}
</span>
)}
</div>
)}
{/* 操作按钮 */}
<div className='flex items-center gap-2'>
<div className='flex flex-wrap items-center gap-2'>
<button
onClick={() => handleOpenDownloadDialog(item)}
disabled={downloadingId === item.guid}
@@ -395,6 +525,24 @@ export default function AcgSearch({
</>
)}
</button>
<button
onClick={() => handleCheckHealth(item)}
disabled={!item.torrentUrl || Boolean(healthCheckingIds[item.guid])}
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] ? (
<>
<Loader2 className='h-4 w-4 animate-spin' />
<span>...</span>
</>
) : (
<>
<Activity className='h-4 w-4' />
<span>{healthMap[item.guid] ? '重新测活' : '测活'}</span>
</>
)}
</button>
<a
href={item.link}
target='_blank'
+176 -23
View File
@@ -2,6 +2,7 @@
'use client';
import {
Activity,
AlertCircle,
Copy,
Download,
@@ -25,6 +26,19 @@ interface PansouSearchProps {
type DownloadTool = 'aria2' | 'Transmission' | 'qBittorrent';
type MagnetHealthLevel = 'good' | 'ok' | 'risk' | 'unknown';
interface MagnetHealthView {
health: MagnetHealthLevel;
seeders: number;
leechers: number;
peers: number;
message: string;
infoHash?: string;
source?: 'scrape' | 'cache';
durationMs?: number;
}
const downloadToolOptions: Array<{ value: DownloadTool; label: string }> = [
{ value: 'aria2', label: 'aria2' },
{ value: 'qBittorrent', label: 'qBittorrent' },
@@ -179,10 +193,18 @@ export default function PansouSearch({
const [checkStatesByType, setCheckStatesByType] = useState<
Record<string, StoredCloudCheckState>
>({});
const [magnetHealthMap, setMagnetHealthMap] = useState<
Record<string, MagnetHealthView>
>({});
const [magnetHealthCheckingIds, setMagnetHealthCheckingIds] = useState<
Record<string, true>
>({});
useEffect(() => {
setCooldownRemainingMs(0);
setCheckStatesByType({});
setMagnetHealthMap({});
setMagnetHealthCheckingIds({});
}, [keyword, triggerSearch]);
useEffect(() => {
@@ -357,6 +379,82 @@ export default function PansouSearch({
}
};
const magnetHealthBadgeClass = (level: MagnetHealthLevel) => {
switch (level) {
case 'good':
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300';
case 'ok':
return 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200';
case 'risk':
return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300';
default:
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300';
}
};
const magnetHealthLabel = (level: MagnetHealthLevel) => {
switch (level) {
case 'good':
return '健康';
case 'ok':
return '一般';
case 'risk':
return '风险';
default:
return '未知';
}
};
// 磁力单条测活(与动漫磁链搜索共用 /api/acg/health,全站并发可配)
const handleMagnetHealthCheck = async (link: PansouLink) => {
const url = link.url?.trim();
if (!url || magnetHealthCheckingIds[url]) return;
setMagnetHealthCheckingIds((prev) => ({ ...prev, [url]: true }));
try {
const response = await fetch('/api/acg/health', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
url,
skipCache: Boolean(magnetHealthMap[url]),
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '测活失败');
}
setMagnetHealthMap((prev) => ({
...prev,
[url]: {
health: data.health as MagnetHealthLevel,
seeders: data.seeders ?? 0,
leechers: data.leechers ?? 0,
peers: data.peers ?? 0,
message: data.message || '',
infoHash: data.infoHash,
source: data.source,
durationMs: data.durationMs,
},
}));
} catch (err: any) {
setToast({
message: err?.message || '测活失败',
type: 'error',
onClose: () => setToast(null),
});
} finally {
setMagnetHealthCheckingIds((prev) => {
const next = { ...prev };
delete next[url];
return next;
});
}
};
const handleOpenDownloadDialog = (link: PansouLink) => {
setSelectedDownloadLink(link);
setCustomName(keyword.trim() || link.note || '');
@@ -879,28 +977,57 @@ export default function PansouSearch({
</>
)}
{cloudType === 'magnet' && (
<button
onClick={() => handleOpenDownloadDialog(link)}
disabled={downloadingUrl === link.url}
className='flex items-center gap-1.5 px-2 py-1 rounded-md bg-green-600 hover:bg-green-700 text-white text-xs transition-colors disabled:opacity-60'
title='存到私人影库'
>
{downloadingUrl === link.url ? (
<>
<Loader2 className='h-3.5 w-3.5 animate-spin' />
<span className='hidden sm:inline'>
...
</span>
</>
) : (
<>
<Download className='h-3.5 w-3.5' />
<span className='hidden sm:inline'>
</span>
</>
)}
</button>
<>
<button
onClick={() => handleOpenDownloadDialog(link)}
disabled={downloadingUrl === link.url}
className='flex items-center gap-1.5 px-2 py-1 rounded-md bg-green-600 hover:bg-green-700 text-white text-xs transition-colors disabled:opacity-60'
title='存到私人影库'
>
{downloadingUrl === link.url ? (
<>
<Loader2 className='h-3.5 w-3.5 animate-spin' />
<span className='hidden sm:inline'>
...
</span>
</>
) : (
<>
<Download className='h-3.5 w-3.5' />
<span className='hidden sm:inline'>
</span>
</>
)}
</button>
<button
onClick={() => handleMagnetHealthCheck(link)}
disabled={
!link.url ||
Boolean(magnetHealthCheckingIds[link.url])
}
className='flex items-center gap-1.5 px-2 py-1 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-xs transition-colors disabled:opacity-60'
title='Tracker 测活(全站并发上限可由 MAGNET_HEALTH_MAX_CONCURRENT 配置)'
>
{magnetHealthCheckingIds[link.url] ? (
<>
<Loader2 className='h-3.5 w-3.5 animate-spin' />
<span className='hidden sm:inline'>
...
</span>
</>
) : (
<>
<Activity className='h-3.5 w-3.5' />
<span className='hidden sm:inline'>
{magnetHealthMap[link.url]
? '重新测活'
: '测活'}
</span>
</>
)}
</button>
</>
)}
<button
onClick={() =>
@@ -933,13 +1060,39 @@ export default function PansouSearch({
</div>
{/* 来源和时间 */}
<div className='flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400'>
<div className='flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400 flex-wrap'>
{link.source && <span>: {link.source}</span>}
{link.datetime && (
<span>
{new Date(link.datetime).toLocaleDateString()}
</span>
)}
{cloudType === 'magnet' && magnetHealthMap[link.url] && (
<>
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 font-medium ${magnetHealthBadgeClass(
magnetHealthMap[link.url].health
)}`}
>
{magnetHealthLabel(magnetHealthMap[link.url].health)}
</span>
<span className='text-gray-600 dark:text-gray-300'>
Seeder {magnetHealthMap[link.url].seeders}
{' · '}
Leecher {magnetHealthMap[link.url].leechers}
{' · '}
Peer {magnetHealthMap[link.url].peers}
</span>
{typeof magnetHealthMap[link.url].durationMs ===
'number' && (
<span className='text-gray-400 dark:text-gray-500'>
{magnetHealthMap[link.url].source === 'cache'
? '缓存'
: `${magnetHealthMap[link.url].durationMs}ms`}
</span>
)}
</>
)}
{(() => {
const checkResult = getCheckResultForUrl(
cloudType,