import { useEffect, useRef, useState } from 'react'; import { createModel, deleteModel, fetchModels, fetchScrapeJob, fetchScrapeJobs, fetchSchedule, postScrape, updateSchedule } from '../api'; import type { Model, Schedule } from '../api'; import type { ScrapeJob, ScrapeJobDetail } from '../types'; import './Scraper.css'; function PriceCard({ result }: { result: NonNullable[number] }) { const { model_info, rate_limits, tool_prices, prices } = result; return (
{result.model_name}
{result.url}
{/* 模型信息 */} {model_info && (
模型信息
{model_info.display_tags && model_info.display_tags.length > 0 && (
{model_info.display_tags.map(t => {t})}
)} {model_info.description && (
{model_info.description}
)} {(model_info.input_modalities?.length || model_info.output_modalities?.length) ? (
{model_info.input_modalities?.length ? ( 输入:{model_info.input_modalities.join(' / ')} ) : null} {model_info.output_modalities?.length ? ( 输出:{model_info.output_modalities.join(' / ')} ) : null}
) : null} {model_info.features && (
{Object.entries(model_info.features).map(([k, v]) => ( {v ? '✓' : '✗'} {k} ))}
)}
)} {/* 限流与上下文 */} {rate_limits && Object.keys(rate_limits).length > 0 && (
限流与上下文
{Object.entries(rate_limits).map(([k, v]) => (
{k} {v ?? '-'}
))}
)} {/* 工具调用价格 */} {tool_prices && tool_prices.length > 0 && (
工具调用价格
{tool_prices.map((t, i) => (
{t.label} {t.price === 0 ? '免费' : `${t.price} ${t.unit ?? '元/千次'}`} {t.note ? ({t.note}) : null}
))}
)} {/* Token 价格 */} {Object.keys(prices).length > 0 && (
Token 价格
{Object.entries(prices).map(([tier, val]) => (
{tier} {JSON.stringify(val)}
))}
)}
爬取时间:{new Date(result.scraped_at).toLocaleString()}
); } export function Scraper() { const [models, setModels] = useState([]); const [selected, setSelected] = useState>(new Set()); const [showAdd, setShowAdd] = useState(false); const [newName, setNewName] = useState(''); const [newUrl, setNewUrl] = useState(''); const [addError, setAddError] = useState(null); const [schedule, setSchedule] = useState(null); const [scheduleEdit, setScheduleEdit] = useState({ interval_days: 1, start_hour: 2 }); const [scheduleSaving, setScheduleSaving] = useState(false); const [submitting, setSubmitting] = useState(false); const [expandedJobs, setExpandedJobs] = useState>({}); const [history, setHistory] = useState([]); const [error, setError] = useState(null); const [historyPage, setHistoryPage] = useState(1); const [historyPageSize, setHistoryPageSize] = useState(10); const pollRef = useRef | null>(null); const resultRef = useRef(null); const loadModels = () => fetchModels().then(setModels).catch(() => {}); const loadHistory = () => fetchScrapeJobs().then(setHistory).catch(() => {}); useEffect(() => { loadModels(); loadHistory(); fetchSchedule().then(s => { setSchedule(s); setScheduleEdit({ interval_days: s.interval_days, start_hour: s.start_hour }); }).catch(() => {}); }, []); const stopPolling = () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } }; const startPolling = (jobId: string) => { stopPolling(); const deadline = Date.now() + 10 * 60 * 1000; // 最多轮询 10 分钟 pollRef.current = setInterval(async () => { if (Date.now() > deadline) { stopPolling(); setExpandedJobs(prev => { const job = prev[jobId]; if (!job || job.status === 'done' || job.status === 'failed') return prev; return { ...prev, [jobId]: { ...job, status: 'failed', error: '轮询超时,请刷新页面查看最新状态' } }; }); loadHistory(); return; } try { const detail = await fetchScrapeJob(jobId); setExpandedJobs(prev => ({ ...prev, [jobId]: detail })); if (detail.status === 'done' || detail.status === 'failed') { stopPolling(); loadHistory(); } } catch { stopPolling(); } }, 2000); }; useEffect(() => () => stopPolling(), []); const toggleSelect = (id: number) => { setSelected(prev => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; }); }; const toggleAll = () => { if (selected.size === models.length) setSelected(new Set()); else setSelected(new Set(models.map(m => m.id))); }; const handleScrape = async () => { const urls = models.filter(m => selected.has(m.id)).map(m => m.url); if (urls.length === 0) return; setSubmitting(true); setError(null); try { const job = await postScrape(urls); setExpandedJobs(prev => ({ ...prev, [job.job_id]: { ...job, results: undefined } })); startPolling(job.job_id); loadHistory(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setSubmitting(false); } }; const handleAdd = async () => { if (!newName.trim() || !newUrl.trim()) return; setAddError(null); try { await createModel(newName.trim(), newUrl.trim()); setNewName(''); setNewUrl(''); setShowAdd(false); loadModels(); } catch (e) { setAddError(e instanceof Error ? e.message : String(e)); } }; const handleToggleSchedule = async () => { if (!schedule) return; setScheduleSaving(true); try { const updated = await updateSchedule({ ...scheduleEdit, enabled: !schedule.enabled }); setSchedule(updated); } finally { setScheduleSaving(false); } }; const handleSaveSchedule = async () => { if (!schedule) return; setScheduleSaving(true); try { const updated = await updateSchedule({ ...scheduleEdit, enabled: schedule.enabled }); setSchedule(updated); } finally { setScheduleSaving(false); } }; const handleDelete = async (id: number) => { await deleteModel(id); setSelected(prev => { const n = new Set(prev); n.delete(id); return n; }); loadModels(); }; const handleHistoryClick = async (jobId: string) => { // 已展开则收起 if (expandedJobs[jobId]) { setExpandedJobs(prev => { const n = { ...prev }; delete n[jobId]; return n; }); return; } try { const detail = await fetchScrapeJob(jobId); setExpandedJobs(prev => ({ ...prev, [jobId]: detail })); if (detail.status === 'pending' || detail.status === 'running') startPolling(jobId); } catch { setError('加载任务详情失败'); } }; return (
{/* 左侧:模型列表 */} {/* 右侧:结果区 */}
价格爬取
{error &&
错误:{error}
} {history.length > 0 && (
历史记录
    {history.slice((historyPage - 1) * historyPageSize, historyPage * historyPageSize).map(job => { const expanded = expandedJobs[job.job_id]; const isOpen = !!expanded; return (
  • handleHistoryClick(job.job_id)}> {job.job_id.slice(0, 8)}… { job.status === 'done' ? '完成' : job.status === 'failed' ? '失败' : job.status === 'running' ? '运行中' : '等待中' } {new Date(job.created_at).toLocaleString()} {isOpen ? '▲' : '▼'}
    {isOpen && (
    {expanded.status === 'failed' && (
    爬取失败
    {expanded.error}
    )} {(expanded.status === 'pending' || expanded.status === 'running') && (
    爬取中,请稍候…
    )} {expanded.status === 'done' && expanded.results && expanded.results.map(r => ( ))}
    )}
  • ); })}
{/* 分页 */} {(() => { const totalPages = Math.ceil(history.length / historyPageSize); return (
共 {history.length} 条 每页 {historyPage}
); })()}
)}
); }