diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index e7be48a..a2264dc 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1686,6 +1686,18 @@ const VideoSourceConfig = ({ from: 'config', }); + // 有效性检测相关状态 + const [showValidationModal, setShowValidationModal] = useState(false); + const [searchKeyword, setSearchKeyword] = useState(''); + const [isValidating, setIsValidating] = useState(false); + const [validationResults, setValidationResults] = useState>([]); + // dnd-kit 传感器 const sensors = useSensors( useSensor(PointerSensor, { @@ -1792,6 +1804,139 @@ const VideoSourceConfig = ({ }); }; + // 有效性检测函数 + const handleValidateSources = async () => { + if (!searchKeyword.trim()) { + showAlert({ type: 'warning', title: '请输入搜索关键词', message: '搜索关键词不能为空' }); + return; + } + + setIsValidating(true); + setValidationResults([]); // 清空之前的结果 + setShowValidationModal(false); // 立即关闭弹窗 + + // 初始化所有视频源为检测中状态 + const initialResults = sources.map(source => ({ + key: source.key, + name: source.name, + status: 'validating' as const, + message: '检测中...', + resultCount: 0 + })); + setValidationResults(initialResults); + + try { + // 使用EventSource接收流式数据 + const eventSource = new EventSource(`/api/admin/source/validate?q=${encodeURIComponent(searchKeyword.trim())}`); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + switch (data.type) { + case 'start': + console.log(`开始检测 ${data.totalSources} 个视频源`); + break; + + case 'source_result': + case 'source_error': + // 更新验证结果 + setValidationResults(prev => { + const existing = prev.find(r => r.key === data.source); + if (existing) { + return prev.map(r => r.key === data.source ? { + key: data.source, + name: sources.find(s => s.key === data.source)?.name || data.source, + status: data.status, + message: data.status === 'valid' ? '搜索正常' : + data.status === 'no_results' ? '无法搜索到结果' : '连接失败', + resultCount: data.status === 'valid' ? 1 : 0 + } : r); + } else { + return [...prev, { + key: data.source, + name: sources.find(s => s.key === data.source)?.name || data.source, + status: data.status, + message: data.status === 'valid' ? '搜索正常' : + data.status === 'no_results' ? '无法搜索到结果' : '连接失败', + resultCount: data.status === 'valid' ? 1 : 0 + }]; + } + }); + break; + + case 'complete': + console.log(`检测完成,共检测 ${data.completedSources} 个视频源`); + eventSource.close(); + setIsValidating(false); + break; + } + } catch (error) { + console.error('解析EventSource数据失败:', error); + } + }; + + eventSource.onerror = (error) => { + console.error('EventSource错误:', error); + eventSource.close(); + setIsValidating(false); + showAlert({ type: 'error', title: '验证失败', message: '连接错误,请重试' }); + }; + + // 设置超时,防止长时间等待 + setTimeout(() => { + if (eventSource.readyState === EventSource.OPEN) { + eventSource.close(); + setIsValidating(false); + showAlert({ type: 'warning', title: '验证超时', message: '检测超时,请重试' }); + } + }, 60000); // 60秒超时 + + } catch (error) { + setIsValidating(false); + showAlert({ type: 'error', title: '验证失败', message: error instanceof Error ? error.message : '未知错误' }); + } + }; + + // 获取有效性状态显示 + const getValidationStatus = (sourceKey: string) => { + const result = validationResults.find(r => r.key === sourceKey); + if (!result) return null; + + switch (result.status) { + case 'validating': + return { + text: '检测中', + className: 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300', + icon: '⟳', + message: result.message + }; + case 'valid': + return { + text: '有效', + className: 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300', + icon: '✓', + message: result.message + }; + case 'no_results': + return { + text: '无法搜索', + className: 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300', + icon: '⚠', + message: result.message + }; + case 'invalid': + return { + text: '无效', + className: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300', + icon: '✗', + message: result.message + }; + default: + return null; + } + }; + // 可拖拽行封装 (dnd-kit) const DraggableRow = ({ source }: { source: DataSource }) => { const { attributes, listeners, setNodeRef, transform, transition } = @@ -1844,6 +1989,23 @@ const VideoSourceConfig = ({ {!source.disabled ? '启用中' : '已禁用'} + + {(() => { + const status = getValidationStatus(source.key); + if (!status) { + return ( + + 未检测 + + ); + } + return ( + + {status.icon} {status.text} + + ); + })()} + +
+ + +
{showAddForm && ( @@ -1963,6 +2144,9 @@ const VideoSourceConfig = ({ 状态 + + 有效性 + 操作 @@ -2001,6 +2185,46 @@ const VideoSourceConfig = ({ )} + {/* 有效性检测弹窗 */} + {showValidationModal && createPortal( +
+
+

+ 视频源有效性检测 +

+

+ 请输入检测用的搜索关键词 +

+
+ setSearchKeyword(e.target.value)} + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' + onKeyPress={(e) => e.key === 'Enter' && handleValidateSources()} + /> +
+ + +
+
+
+
, + document.body + )} + {/* 通用弹窗组件 */} { + try { + if (streamClosed || (!controller.desiredSize && controller.desiredSize !== 0)) { + return false; + } + controller.enqueue(data); + return true; + } catch (error) { + console.warn('Failed to enqueue data:', error); + streamClosed = true; + return false; + } + }; + + // 发送开始事件 + const startEvent = `data: ${JSON.stringify({ + type: 'start', + totalSources: apiSites.length + })}\n\n`; + + if (!safeEnqueue(encoder.encode(startEvent))) { + return; + } + + // 记录已完成的源数量 + let completedSources = 0; + + // 为每个源创建验证 Promise + const validationPromises = apiSites.map(async (site) => { + try { + // 构建搜索URL,只获取第一页 + const searchUrl = `${site.api}?ac=videolist&wd=${encodeURIComponent(searchKeyword)}`; + + // 设置超时控制 + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + try { + const response = await fetch(searchUrl, { + headers: API_CONFIG.search.headers, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json() as any; + + // 检查结果是否有效 + let status: 'valid' | 'no_results' | 'invalid'; + let message: string; + let resultCount: number; + + if ( + data && + data.list && + Array.isArray(data.list) && + data.list.length > 0 + ) { + // 检查是否有标题包含搜索词的结果 + const validResults = data.list.filter((item: any) => { + const title = item.vod_name || ''; + return title.toLowerCase().includes(searchKeyword.toLowerCase()); + }); + + if (validResults.length > 0) { + status = 'valid'; + message = `搜索正常,找到 ${validResults.length} 个相关结果`; + resultCount = validResults.length; + } else { + status = 'no_results'; + message = '搜索结果中无相关标题'; + resultCount = 0; + } + } else { + status = 'no_results'; + message = '无法搜索到结果'; + resultCount = 0; + } + + // 发送该源的验证结果 + completedSources++; + + if (!streamClosed) { + const sourceEvent = `data: ${JSON.stringify({ + type: 'source_result', + source: site.key, + status + })}\n\n`; + + if (!safeEnqueue(encoder.encode(sourceEvent))) { + streamClosed = true; + return; + } + } + + } finally { + clearTimeout(timeoutId); + } + + } catch (error) { + console.warn(`验证失败 ${site.name}:`, error); + + // 发送源错误事件 + completedSources++; + + if (!streamClosed) { + const errorEvent = `data: ${JSON.stringify({ + type: 'source_error', + source: site.key, + status: 'invalid' + })}\n\n`; + + if (!safeEnqueue(encoder.encode(errorEvent))) { + streamClosed = true; + return; + } + } + } + + // 检查是否所有源都已完成 + if (completedSources === apiSites.length) { + if (!streamClosed) { + // 发送最终完成事件 + const completeEvent = `data: ${JSON.stringify({ + type: 'complete', + completedSources + })}\n\n`; + + if (safeEnqueue(encoder.encode(completeEvent))) { + try { + controller.close(); + } catch (error) { + console.warn('Failed to close controller:', error); + } + } + } + } + }); + + // 等待所有验证完成 + await Promise.allSettled(validationPromises); + }, + + cancel() { + streamClosed = true; + console.log('Client disconnected, cancelling validation stream'); + }, + }); + + // 返回流式响应 + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET', + 'Access-Control-Allow-Headers': 'Content-Type', + }, + }); +}