Scraper.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. import { useEffect, useRef, useState } from 'react';
  2. import { createModel, deleteModel, fetchModels, fetchScrapeJob, fetchScrapeJobs, fetchSchedule, postScrape, updateSchedule } from '../api';
  3. import type { Model, Schedule } from '../api';
  4. import type { ScrapeJob, ScrapeJobDetail } from '../types';
  5. import './Scraper.css';
  6. function PriceCard({ result }: { result: NonNullable<ScrapeJobDetail['results']>[number] }) {
  7. const { model_info, rate_limits, tool_prices, prices } = result;
  8. return (
  9. <div className="price-card">
  10. <div className="price-card-title">{result.model_name}</div>
  11. <div className="price-card-url">{result.url}</div>
  12. {/* 模型信息 */}
  13. {model_info && (
  14. <div className="info-section">
  15. <div className="info-section-title">模型信息</div>
  16. {model_info.display_tags && model_info.display_tags.length > 0 && (
  17. <div className="tag-row">
  18. {model_info.display_tags.map(t => <span key={t} className="tag">{t}</span>)}
  19. </div>
  20. )}
  21. {model_info.description && (
  22. <div className="info-desc">{model_info.description}</div>
  23. )}
  24. {(model_info.input_modalities?.length || model_info.output_modalities?.length) ? (
  25. <div className="modality-row">
  26. {model_info.input_modalities?.length ? (
  27. <span className="modality">输入:{model_info.input_modalities.join(' / ')}</span>
  28. ) : null}
  29. {model_info.output_modalities?.length ? (
  30. <span className="modality">输出:{model_info.output_modalities.join(' / ')}</span>
  31. ) : null}
  32. </div>
  33. ) : null}
  34. {model_info.features && (
  35. <div className="feature-grid">
  36. {Object.entries(model_info.features).map(([k, v]) => (
  37. <span key={k} className={`feature-item ${v ? 'feature-item--on' : 'feature-item--off'}`}>
  38. {v ? '✓' : '✗'} {k}
  39. </span>
  40. ))}
  41. </div>
  42. )}
  43. </div>
  44. )}
  45. {/* 限流与上下文 */}
  46. {rate_limits && Object.keys(rate_limits).length > 0 && (
  47. <div className="info-section">
  48. <div className="info-section-title">限流与上下文</div>
  49. <div className="kv-grid">
  50. {Object.entries(rate_limits).map(([k, v]) => (
  51. <div key={k} className="kv-item">
  52. <span className="kv-key">{k}</span>
  53. <span className="kv-val">{v ?? '-'}</span>
  54. </div>
  55. ))}
  56. </div>
  57. </div>
  58. )}
  59. {/* 工具调用价格 */}
  60. {tool_prices && tool_prices.length > 0 && (
  61. <div className="info-section">
  62. <div className="info-section-title">工具调用价格</div>
  63. {tool_prices.map((t, i) => (
  64. <div key={i} className="price-entry">
  65. <span className="price-key">{t.label}</span>
  66. <span className="price-val">
  67. {t.price === 0 ? '免费' : `${t.price} ${t.unit ?? '元/千次'}`}
  68. {t.note ? <span className="price-note"> ({t.note})</span> : null}
  69. </span>
  70. </div>
  71. ))}
  72. </div>
  73. )}
  74. {/* Token 价格 */}
  75. {Object.keys(prices).length > 0 && (
  76. <div className="info-section">
  77. <div className="info-section-title">Token 价格</div>
  78. {Object.entries(prices).map(([tier, val]) => (
  79. <div key={tier} className="price-entry">
  80. <span className="price-key">{tier}</span>
  81. <span className="price-val">{JSON.stringify(val)}</span>
  82. </div>
  83. ))}
  84. </div>
  85. )}
  86. <div className="price-time">爬取时间:{new Date(result.scraped_at).toLocaleString()}</div>
  87. </div>
  88. );
  89. }
  90. export function Scraper() {
  91. const [models, setModels] = useState<Model[]>([]);
  92. const [selected, setSelected] = useState<Set<number>>(new Set());
  93. const [showAdd, setShowAdd] = useState(false);
  94. const [newName, setNewName] = useState('');
  95. const [newUrl, setNewUrl] = useState('');
  96. const [addError, setAddError] = useState<string | null>(null);
  97. const [schedule, setSchedule] = useState<Schedule | null>(null);
  98. const [scheduleEdit, setScheduleEdit] = useState({ interval_days: 1, start_hour: 2 });
  99. const [scheduleSaving, setScheduleSaving] = useState(false);
  100. const [submitting, setSubmitting] = useState(false);
  101. const [expandedJobs, setExpandedJobs] = useState<Record<string, ScrapeJobDetail>>({});
  102. const [history, setHistory] = useState<ScrapeJob[]>([]);
  103. const [error, setError] = useState<string | null>(null);
  104. const [historyPage, setHistoryPage] = useState(1);
  105. const [historyPageSize, setHistoryPageSize] = useState(10);
  106. const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
  107. const resultRef = useRef<HTMLDivElement>(null);
  108. const loadModels = () => fetchModels().then(setModels).catch(() => {});
  109. const loadHistory = () => fetchScrapeJobs().then(setHistory).catch(() => {});
  110. useEffect(() => {
  111. loadModels();
  112. loadHistory();
  113. fetchSchedule().then(s => {
  114. setSchedule(s);
  115. setScheduleEdit({ interval_days: s.interval_days, start_hour: s.start_hour });
  116. }).catch(() => {});
  117. }, []);
  118. const stopPolling = () => {
  119. if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
  120. };
  121. const startPolling = (jobId: string) => {
  122. stopPolling();
  123. const deadline = Date.now() + 10 * 60 * 1000; // 最多轮询 10 分钟
  124. pollRef.current = setInterval(async () => {
  125. if (Date.now() > deadline) {
  126. stopPolling();
  127. setExpandedJobs(prev => {
  128. const job = prev[jobId];
  129. if (!job || job.status === 'done' || job.status === 'failed') return prev;
  130. return { ...prev, [jobId]: { ...job, status: 'failed', error: '轮询超时,请刷新页面查看最新状态' } };
  131. });
  132. loadHistory();
  133. return;
  134. }
  135. try {
  136. const detail = await fetchScrapeJob(jobId);
  137. setExpandedJobs(prev => ({ ...prev, [jobId]: detail }));
  138. if (detail.status === 'done' || detail.status === 'failed') {
  139. stopPolling();
  140. loadHistory();
  141. }
  142. } catch { stopPolling(); }
  143. }, 2000);
  144. };
  145. useEffect(() => () => stopPolling(), []);
  146. const toggleSelect = (id: number) => {
  147. setSelected(prev => {
  148. const next = new Set(prev);
  149. next.has(id) ? next.delete(id) : next.add(id);
  150. return next;
  151. });
  152. };
  153. const toggleAll = () => {
  154. if (selected.size === models.length) setSelected(new Set());
  155. else setSelected(new Set(models.map(m => m.id)));
  156. };
  157. const handleScrape = async () => {
  158. const urls = models.filter(m => selected.has(m.id)).map(m => m.url);
  159. if (urls.length === 0) return;
  160. setSubmitting(true);
  161. setError(null);
  162. try {
  163. const job = await postScrape(urls);
  164. setExpandedJobs(prev => ({ ...prev, [job.job_id]: { ...job, results: undefined } }));
  165. startPolling(job.job_id);
  166. loadHistory();
  167. } catch (e) {
  168. setError(e instanceof Error ? e.message : String(e));
  169. } finally {
  170. setSubmitting(false);
  171. }
  172. };
  173. const handleAdd = async () => {
  174. if (!newName.trim() || !newUrl.trim()) return;
  175. setAddError(null);
  176. try {
  177. await createModel(newName.trim(), newUrl.trim());
  178. setNewName(''); setNewUrl(''); setShowAdd(false);
  179. loadModels();
  180. } catch (e) {
  181. setAddError(e instanceof Error ? e.message : String(e));
  182. }
  183. };
  184. const handleToggleSchedule = async () => {
  185. if (!schedule) return;
  186. setScheduleSaving(true);
  187. try {
  188. const updated = await updateSchedule({ ...scheduleEdit, enabled: !schedule.enabled });
  189. setSchedule(updated);
  190. } finally { setScheduleSaving(false); }
  191. };
  192. const handleSaveSchedule = async () => {
  193. if (!schedule) return;
  194. setScheduleSaving(true);
  195. try {
  196. const updated = await updateSchedule({ ...scheduleEdit, enabled: schedule.enabled });
  197. setSchedule(updated);
  198. } finally { setScheduleSaving(false); }
  199. };
  200. const handleDelete = async (id: number) => {
  201. await deleteModel(id);
  202. setSelected(prev => { const n = new Set(prev); n.delete(id); return n; });
  203. loadModels();
  204. };
  205. const handleHistoryClick = async (jobId: string) => {
  206. // 已展开则收起
  207. if (expandedJobs[jobId]) {
  208. setExpandedJobs(prev => { const n = { ...prev }; delete n[jobId]; return n; });
  209. return;
  210. }
  211. try {
  212. const detail = await fetchScrapeJob(jobId);
  213. setExpandedJobs(prev => ({ ...prev, [jobId]: detail }));
  214. if (detail.status === 'pending' || detail.status === 'running') startPolling(jobId);
  215. } catch { setError('加载任务详情失败'); }
  216. };
  217. return (
  218. <div className="scraper-page scraper-layout">
  219. {/* 左侧:模型列表 */}
  220. <aside className="model-sidebar">
  221. <div className="sidebar-header">
  222. <span className="sidebar-title">模型列表</span>
  223. <button className="icon-btn" onClick={() => setShowAdd(v => !v)} title="添加模型">+</button>
  224. </div>
  225. {showAdd && (
  226. <div className="add-form">
  227. <input className="add-input" placeholder="模型名称" value={newName} onChange={e => setNewName(e.target.value)} />
  228. <input className="add-input" placeholder="URL" value={newUrl} onChange={e => setNewUrl(e.target.value)} />
  229. {addError && <div className="add-error">{addError}</div>}
  230. <div className="add-actions">
  231. <button className="add-confirm-btn" onClick={handleAdd}>确认添加</button>
  232. <button className="add-cancel-btn" onClick={() => { setShowAdd(false); setAddError(null); }}>取消</button>
  233. </div>
  234. </div>
  235. )}
  236. <div className="model-list-header">
  237. <label className="check-label">
  238. <input type="checkbox" checked={models.length > 0 && selected.size === models.length} onChange={toggleAll} />
  239. <span>全选</span>
  240. </label>
  241. <span className="selected-count">{selected.size} / {models.length}</span>
  242. </div>
  243. <ul className="model-list">
  244. {models.length === 0 && <li className="empty-msg">暂无模型,点击 + 添加</li>}
  245. {models.map(m => (
  246. <li key={m.id} className={`model-item ${selected.has(m.id) ? 'model-item--selected' : ''}`}>
  247. <label className="check-label">
  248. <input type="checkbox" checked={selected.has(m.id)} onChange={() => toggleSelect(m.id)} />
  249. <span className="model-name">{m.name}</span>
  250. </label>
  251. <button className="del-btn" onClick={() => handleDelete(m.id)} title="删除">✕</button>
  252. </li>
  253. ))}
  254. </ul>
  255. <button className="submit-btn scrape-btn" onClick={handleScrape} disabled={submitting || selected.size === 0}>
  256. {submitting ? '爬取中…' : `▶ 爬取已选 (${selected.size})`}
  257. </button>
  258. {/* 定时爬取配置 */}
  259. {schedule && (
  260. <div className="schedule-box">
  261. <div className="schedule-header">
  262. <span className="schedule-title">定时爬取</span>
  263. <button
  264. className={`toggle-btn ${schedule.enabled ? 'toggle-btn--on' : ''}`}
  265. onClick={handleToggleSchedule}
  266. disabled={scheduleSaving}
  267. >
  268. {schedule.enabled ? '已开启' : '已关闭'}
  269. </button>
  270. </div>
  271. <div className="schedule-row">
  272. <label className="schedule-label">每隔</label>
  273. <input
  274. type="number" min={1} max={365}
  275. className="schedule-input"
  276. value={scheduleEdit.interval_days}
  277. onChange={e => setScheduleEdit(v => ({ ...v, interval_days: Number(e.target.value) }))}
  278. />
  279. <span className="schedule-unit">天</span>
  280. </div>
  281. <div className="schedule-row">
  282. <label className="schedule-label">开始时间</label>
  283. <input
  284. type="number" min={0} max={23}
  285. className="schedule-input schedule-input--sm"
  286. value={scheduleEdit.start_hour}
  287. onChange={e => setScheduleEdit(v => ({ ...v, start_hour: Number(e.target.value) }))}
  288. />
  289. <span className="schedule-unit">时整</span>
  290. </div>
  291. <button className="add-confirm-btn" onClick={handleSaveSchedule} disabled={scheduleSaving}>
  292. 保存配置
  293. </button>
  294. </div>
  295. )}
  296. </aside>
  297. {/* 右侧:结果区 */}
  298. <main className="scraper-main">
  299. <header className="scraper-header" ref={resultRef}>
  300. <span className="scraper-title">价格爬取</span>
  301. </header>
  302. {error && <div className="error-banner">错误:{error}</div>}
  303. {history.length > 0 && (
  304. <section className="history-section">
  305. <div className="section-title">历史记录</div>
  306. <ul className="history-list">
  307. {history.slice((historyPage - 1) * historyPageSize, historyPage * historyPageSize).map(job => {
  308. const expanded = expandedJobs[job.job_id];
  309. const isOpen = !!expanded;
  310. return (
  311. <li key={job.job_id} className={`history-item history-item--${job.status}`}>
  312. <div className="history-row" onClick={() => handleHistoryClick(job.job_id)}>
  313. <span className="history-id">{job.job_id.slice(0, 8)}…</span>
  314. <span className="history-status">{
  315. job.status === 'done' ? '完成' :
  316. job.status === 'failed' ? '失败' :
  317. job.status === 'running' ? '运行中' : '等待中'
  318. }</span>
  319. <span className="history-time">{new Date(job.created_at).toLocaleString()}</span>
  320. <span className="history-toggle">{isOpen ? '▲' : '▼'}</span>
  321. </div>
  322. {isOpen && (
  323. <div className="history-detail">
  324. {expanded.status === 'failed' && (
  325. <div className="error-card">
  326. <div className="error-card-title">爬取失败</div>
  327. <pre className="error-detail">{expanded.error}</pre>
  328. </div>
  329. )}
  330. {(expanded.status === 'pending' || expanded.status === 'running') && (
  331. <div className="empty-msg">爬取中,请稍候…</div>
  332. )}
  333. {expanded.status === 'done' && expanded.results && expanded.results.map(r => (
  334. <PriceCard key={r.url} result={r} />
  335. ))}
  336. </div>
  337. )}
  338. </li>
  339. );
  340. })}
  341. </ul>
  342. {/* 分页 */}
  343. {(() => {
  344. const totalPages = Math.ceil(history.length / historyPageSize);
  345. return (
  346. <div className="pagination">
  347. <span className="pagination-info">共 {history.length} 条</span>
  348. <span>每页</span>
  349. <select className="page-size-select" value={historyPageSize} onChange={e => { setHistoryPageSize(Number(e.target.value)); setHistoryPage(1); }}>
  350. {[5, 10, 20, 50].map(n => <option key={n} value={n}>{n}</option>)}
  351. </select>
  352. <button className="page-btn" disabled={historyPage <= 1} onClick={() => setHistoryPage(p => p - 1)}>上一页</button>
  353. <span className="page-num">{historyPage}</span>
  354. <button className="page-btn" disabled={historyPage >= totalPages} onClick={() => setHistoryPage(p => p + 1)}>下一页</button>
  355. </div>
  356. );
  357. })()}
  358. </section>
  359. )}
  360. </main>
  361. </div>
  362. );
  363. }