| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395 |
- 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<ScrapeJobDetail['results']>[number] }) {
- const { model_info, rate_limits, tool_prices, prices } = result;
- return (
- <div className="price-card">
- <div className="price-card-title">{result.model_name}</div>
- <div className="price-card-url">{result.url}</div>
- {/* 模型信息 */}
- {model_info && (
- <div className="info-section">
- <div className="info-section-title">模型信息</div>
- {model_info.display_tags && model_info.display_tags.length > 0 && (
- <div className="tag-row">
- {model_info.display_tags.map(t => <span key={t} className="tag">{t}</span>)}
- </div>
- )}
- {model_info.description && (
- <div className="info-desc">{model_info.description}</div>
- )}
- {(model_info.input_modalities?.length || model_info.output_modalities?.length) ? (
- <div className="modality-row">
- {model_info.input_modalities?.length ? (
- <span className="modality">输入:{model_info.input_modalities.join(' / ')}</span>
- ) : null}
- {model_info.output_modalities?.length ? (
- <span className="modality">输出:{model_info.output_modalities.join(' / ')}</span>
- ) : null}
- </div>
- ) : null}
- {model_info.features && (
- <div className="feature-grid">
- {Object.entries(model_info.features).map(([k, v]) => (
- <span key={k} className={`feature-item ${v ? 'feature-item--on' : 'feature-item--off'}`}>
- {v ? '✓' : '✗'} {k}
- </span>
- ))}
- </div>
- )}
- </div>
- )}
- {/* 限流与上下文 */}
- {rate_limits && Object.keys(rate_limits).length > 0 && (
- <div className="info-section">
- <div className="info-section-title">限流与上下文</div>
- <div className="kv-grid">
- {Object.entries(rate_limits).map(([k, v]) => (
- <div key={k} className="kv-item">
- <span className="kv-key">{k}</span>
- <span className="kv-val">{v ?? '-'}</span>
- </div>
- ))}
- </div>
- </div>
- )}
- {/* 工具调用价格 */}
- {tool_prices && tool_prices.length > 0 && (
- <div className="info-section">
- <div className="info-section-title">工具调用价格</div>
- {tool_prices.map((t, i) => (
- <div key={i} className="price-entry">
- <span className="price-key">{t.label}</span>
- <span className="price-val">
- {t.price === 0 ? '免费' : `${t.price} ${t.unit ?? '元/千次'}`}
- {t.note ? <span className="price-note"> ({t.note})</span> : null}
- </span>
- </div>
- ))}
- </div>
- )}
- {/* Token 价格 */}
- {Object.keys(prices).length > 0 && (
- <div className="info-section">
- <div className="info-section-title">Token 价格</div>
- {Object.entries(prices).map(([tier, val]) => (
- <div key={tier} className="price-entry">
- <span className="price-key">{tier}</span>
- <span className="price-val">{JSON.stringify(val)}</span>
- </div>
- ))}
- </div>
- )}
- <div className="price-time">爬取时间:{new Date(result.scraped_at).toLocaleString()}</div>
- </div>
- );
- }
- export function Scraper() {
- const [models, setModels] = useState<Model[]>([]);
- const [selected, setSelected] = useState<Set<number>>(new Set());
- const [showAdd, setShowAdd] = useState(false);
- const [newName, setNewName] = useState('');
- const [newUrl, setNewUrl] = useState('');
- const [addError, setAddError] = useState<string | null>(null);
- const [schedule, setSchedule] = useState<Schedule | null>(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<Record<string, ScrapeJobDetail>>({});
- const [history, setHistory] = useState<ScrapeJob[]>([]);
- const [error, setError] = useState<string | null>(null);
- const [historyPage, setHistoryPage] = useState(1);
- const [historyPageSize, setHistoryPageSize] = useState(10);
- const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
- const resultRef = useRef<HTMLDivElement>(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 (
- <div className="scraper-page scraper-layout">
- {/* 左侧:模型列表 */}
- <aside className="model-sidebar">
- <div className="sidebar-header">
- <span className="sidebar-title">模型列表</span>
- <button className="icon-btn" onClick={() => setShowAdd(v => !v)} title="添加模型">+</button>
- </div>
- {showAdd && (
- <div className="add-form">
- <input className="add-input" placeholder="模型名称" value={newName} onChange={e => setNewName(e.target.value)} />
- <input className="add-input" placeholder="URL" value={newUrl} onChange={e => setNewUrl(e.target.value)} />
- {addError && <div className="add-error">{addError}</div>}
- <div className="add-actions">
- <button className="add-confirm-btn" onClick={handleAdd}>确认添加</button>
- <button className="add-cancel-btn" onClick={() => { setShowAdd(false); setAddError(null); }}>取消</button>
- </div>
- </div>
- )}
- <div className="model-list-header">
- <label className="check-label">
- <input type="checkbox" checked={models.length > 0 && selected.size === models.length} onChange={toggleAll} />
- <span>全选</span>
- </label>
- <span className="selected-count">{selected.size} / {models.length}</span>
- </div>
- <ul className="model-list">
- {models.length === 0 && <li className="empty-msg">暂无模型,点击 + 添加</li>}
- {models.map(m => (
- <li key={m.id} className={`model-item ${selected.has(m.id) ? 'model-item--selected' : ''}`}>
- <label className="check-label">
- <input type="checkbox" checked={selected.has(m.id)} onChange={() => toggleSelect(m.id)} />
- <span className="model-name">{m.name}</span>
- </label>
- <button className="del-btn" onClick={() => handleDelete(m.id)} title="删除">✕</button>
- </li>
- ))}
- </ul>
- <button className="submit-btn scrape-btn" onClick={handleScrape} disabled={submitting || selected.size === 0}>
- {submitting ? '爬取中…' : `▶ 爬取已选 (${selected.size})`}
- </button>
- {/* 定时爬取配置 */}
- {schedule && (
- <div className="schedule-box">
- <div className="schedule-header">
- <span className="schedule-title">定时爬取</span>
- <button
- className={`toggle-btn ${schedule.enabled ? 'toggle-btn--on' : ''}`}
- onClick={handleToggleSchedule}
- disabled={scheduleSaving}
- >
- {schedule.enabled ? '已开启' : '已关闭'}
- </button>
- </div>
- <div className="schedule-row">
- <label className="schedule-label">每隔</label>
- <input
- type="number" min={1} max={365}
- className="schedule-input"
- value={scheduleEdit.interval_days}
- onChange={e => setScheduleEdit(v => ({ ...v, interval_days: Number(e.target.value) }))}
- />
- <span className="schedule-unit">天</span>
- </div>
- <div className="schedule-row">
- <label className="schedule-label">开始时间</label>
- <input
- type="number" min={0} max={23}
- className="schedule-input schedule-input--sm"
- value={scheduleEdit.start_hour}
- onChange={e => setScheduleEdit(v => ({ ...v, start_hour: Number(e.target.value) }))}
- />
- <span className="schedule-unit">时整</span>
- </div>
- <button className="add-confirm-btn" onClick={handleSaveSchedule} disabled={scheduleSaving}>
- 保存配置
- </button>
- </div>
- )}
- </aside>
- {/* 右侧:结果区 */}
- <main className="scraper-main">
- <header className="scraper-header" ref={resultRef}>
- <span className="scraper-title">价格爬取</span>
- </header>
- {error && <div className="error-banner">错误:{error}</div>}
- {history.length > 0 && (
- <section className="history-section">
- <div className="section-title">历史记录</div>
- <ul className="history-list">
- {history.slice((historyPage - 1) * historyPageSize, historyPage * historyPageSize).map(job => {
- const expanded = expandedJobs[job.job_id];
- const isOpen = !!expanded;
- return (
- <li key={job.job_id} className={`history-item history-item--${job.status}`}>
- <div className="history-row" onClick={() => handleHistoryClick(job.job_id)}>
- <span className="history-id">{job.job_id.slice(0, 8)}…</span>
- <span className="history-status">{
- job.status === 'done' ? '完成' :
- job.status === 'failed' ? '失败' :
- job.status === 'running' ? '运行中' : '等待中'
- }</span>
- <span className="history-time">{new Date(job.created_at).toLocaleString()}</span>
- <span className="history-toggle">{isOpen ? '▲' : '▼'}</span>
- </div>
- {isOpen && (
- <div className="history-detail">
- {expanded.status === 'failed' && (
- <div className="error-card">
- <div className="error-card-title">爬取失败</div>
- <pre className="error-detail">{expanded.error}</pre>
- </div>
- )}
- {(expanded.status === 'pending' || expanded.status === 'running') && (
- <div className="empty-msg">爬取中,请稍候…</div>
- )}
- {expanded.status === 'done' && expanded.results && expanded.results.map(r => (
- <PriceCard key={r.url} result={r} />
- ))}
- </div>
- )}
- </li>
- );
- })}
- </ul>
- {/* 分页 */}
- {(() => {
- const totalPages = Math.ceil(history.length / historyPageSize);
- return (
- <div className="pagination">
- <span className="pagination-info">共 {history.length} 条</span>
- <span>每页</span>
- <select className="page-size-select" value={historyPageSize} onChange={e => { setHistoryPageSize(Number(e.target.value)); setHistoryPage(1); }}>
- {[5, 10, 20, 50].map(n => <option key={n} value={n}>{n}</option>)}
- </select>
- <button className="page-btn" disabled={historyPage <= 1} onClick={() => setHistoryPage(p => p - 1)}>上一页</button>
- <span className="page-num">{historyPage}</span>
- <button className="page-btn" disabled={historyPage >= totalPages} onClick={() => setHistoryPage(p => p + 1)}>下一页</button>
- </div>
- );
- })()}
- </section>
- )}
- </main>
- </div>
- );
- }
|