// ———— Collect Page ———— function StatusBadge({ status, workerDead }) { if (workerDead && ["queued","pending","collecting","analyzing"].includes(status)) { return Worker 未运行; } const map = { "pending": { label: "待处理", cls: "neutral", dot: false }, "queued": { label: "队列中", cls: "neutral", dot: true }, "collecting": { label: "采集中", cls: "info", dot: true }, "analyzing": { label: "分析中", cls: "warning", dot: true }, "done": { label: "完成", cls: "success", dot: false, check: true }, "skipped_dup": { label: "已存在", cls: "neutral", dot: false }, "skipped_repost": { label: "搬运视频", cls: "neutral", dot: false }, "failed": { label: "失败", cls: "danger", dot: false }, }; const m = map[status] || { label: status, cls: "neutral", dot: false }; return ( {m.dot && } {m.check && } {m.label} ); } // ── 格式化工具函数 ────────────────────────────────────────────────────────── function fmtViews(n) { if (n === null || n === undefined) return "—"; if (n >= 100_000_000) return (n / 100_000_000).toFixed(1) + "亿"; if (n >= 10_000) return (n / 10_000).toFixed(1) + "万"; return n.toLocaleString(); } function fmtDuration(sec) { if (!sec && sec !== 0) return "—"; const m = Math.floor(sec / 60); const s = sec % 60; return `${m}:${String(s).padStart(2, "0")}`; } function daysSince(dateStr) { if (!dateStr) return null; return Math.floor((Date.now() - new Date(dateStr).getTime()) / 86400000); } // ── 关键词搜索子组件 ──────────────────────────────────────────────────────── function KeywordSearchPanel({ onJobsCreated }) { const [keyword, setKeyword] = useState(""); const [platform, setPlatform] = useState("youtube"); const [maxResults, setMaxResults] = useState(20); const [searching, setSearching] = useState(false); const [searchError,setSearchError]= useState(""); const [results, setResults] = useState([]); // PreviewItem[] // 过滤条件 const [filterMinViews, setFilterMinViews] = useState(""); const [filterMaxDays, setFilterMaxDays] = useState(""); const [filterMaxMinutes, setFilterMaxMinutes] = useState(""); // 已选 URL 集合 const [selected, setSelected] = useState(new Set()); const [batching, setBatching] = useState(false); const [batchMsg, setBatchMsg] = useState(""); // 计算过滤后的结果 const filtered = results.filter(item => { if (filterMinViews) { const minV = Number(filterMinViews) * 10000; // 单位:万 if (item.views === null || item.views === undefined || item.views < minV) return false; } if (filterMaxDays && item.published_at) { const days = daysSince(item.published_at); if (days === null || days > Number(filterMaxDays)) return false; } if (filterMaxMinutes && item.duration_sec !== null && item.duration_sec !== undefined) { if (item.duration_sec > Number(filterMaxMinutes) * 60) return false; } return true; }); const selectedFiltered = filtered.filter(r => selected.has(r.url)); const allFilteredSelected = filtered.length > 0 && filtered.every(r => selected.has(r.url)); const toggleSelect = (url) => { setSelected(prev => { const next = new Set(prev); next.has(url) ? next.delete(url) : next.add(url); return next; }); }; const selectAll = () => setSelected(prev => { const next = new Set(prev); filtered.forEach(r => next.add(r.url)); return next; }); const clearAll = () => setSelected(prev => { const next = new Set(prev); filtered.forEach(r => next.delete(r.url)); return next; }); const doSearch = async () => { if (!keyword.trim()) return; setSearching(true); setSearchError(""); setResults([]); setSelected(new Set()); setBatchMsg(""); try { const data = await window.API.searchPreview(keyword.trim(), platform, maxResults); setResults(data); } catch (e) { setSearchError(e.message); } finally { setSearching(false); } }; const doBatch = async () => { const urls = selectedFiltered.map(r => r.url); if (!urls.length) return; setBatching(true); setBatchMsg(""); try { const res = await window.API.batchCollect(urls); setBatchMsg(`已提交 ${res.count} 个任务到队列`); setSelected(new Set()); if (onJobsCreated) onJobsCreated(); } catch (e) { setBatchMsg(`提交失败: ${e.message}`); } finally { setBatching(false); } }; const platforms = [ { value: "youtube", label: "YouTube" }, ]; return (
{/* 搜索表单 */}
setKeyword(e.target.value)} onKeyDown={e => e.key === "Enter" && doSearch()} placeholder="输入搜索关键词,回车或点击搜索" />
setMaxResults(Number(e.target.value))} />
{searchError && (
{searchError}
)} {/* 搜索结果区 */} {results.length > 0 && ( <> {/* 过滤条件行 */}
过滤: {filtered.length}/{results.length} 条
{/* 全选/清空行 */}
{selected.size > 0 && ( )} 已选 {selected.size}
{/* 结果表格 */}
{filtered.map((item, idx) => { const isSelected = selected.has(item.url); return ( toggleSelect(item.url)} style={{ cursor: "pointer", background: isSelected ? "oklch(0.97 0.02 258)" : idx % 2 === 0 ? "transparent" : "var(--bg-muted)", transition: "background 0.1s", }} > ); })} {filtered.length === 0 && ( )}
标题 / 频道 播放量 时长 发布
toggleSelect(item.url)} onClick={e => e.stopPropagation()} />
{item.title || "—"}
{item.channel || "—"}
{fmtViews(item.views)} {fmtDuration(item.duration_sec)} {item.published_at ? item.published_at.slice(0, 7) : "—"}
没有符合过滤条件的结果,请调整过滤条件
{/* 批量采集按钮 */} {batchMsg && (
{batchMsg}
)} )} {!searching && results.length === 0 && !searchError && (
输入关键词并点击搜索,预览结果后再批量采集
)}
); } // ── 主页面 ────────────────────────────────────────────────────────────────── function CollectPage() { const [mode, setMode] = useState("url"); // "url" | "keyword" const [url, setUrl] = useState(""); const [jobs, setJobs] = useState([]); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(""); const [expanded, setExpanded] = useState(null); // Worker 健康状态 const [workerAlive, setWorkerAlive] = useState(true); const [workerCount, setWorkerCount] = useState(null); const [healthChecked, setHealthChecked] = useState(false); const loadJobs = async () => { try { const data = await window.API.listJobs(); setJobs(data); } catch (_) {} }; const checkHealth = async () => { try { const h = await window.API.health(); setWorkerCount(h.worker_count); setWorkerAlive(h.redis_connected && h.worker_count > 0); } catch (_) { setWorkerCount(0); setWorkerAlive(false); } finally { setHealthChecked(true); } }; useEffect(() => { loadJobs(); checkHealth(); const jobTimer = setInterval(loadJobs, 3000); const healthTimer = setInterval(checkHealth, 5000); return () => { clearInterval(jobTimer); clearInterval(healthTimer); }; }, []); const submitUrl = async () => { if (!url.trim()) return; setSubmitting(true); setSubmitError(""); const urls = url.split("\n").map(s => s.trim()).filter(Boolean); try { for (const u of urls) await window.API.submitCollect(u); setUrl(""); loadJobs(); } catch (e) { setSubmitError(e.message); } finally { setSubmitting(false); } }; const workerDead = healthChecked && !workerAlive; const activeJobs = jobs.filter(j => ["collecting","analyzing","queued","pending"].includes(j.status)); const doneCount = jobs.filter(j => ["done","skipped_dup","skipped_repost"].includes(j.status)).length; const failCount = jobs.filter(j => j.status === "failed").length; const stuckCount = workerDead ? activeJobs.length : 0; // Tab 样式 const tabStyle = (active) => ({ padding: "6px 14px", fontSize: 13, fontWeight: active ? 600 : 400, color: active ? "var(--primary)" : "var(--ink-3)", borderBottom: active ? "2px solid var(--primary)" : "2px solid transparent", cursor: "pointer", background: "none", border: "none", borderBottom: active ? "2px solid var(--primary)" : "2px solid transparent", transition: "all 0.15s", }); return ( <>

采集任务

提交链接或搜索关键词,后台自动采集、分析并写入飞书。任务每 3 秒更新,Worker 状态每 5 秒检测。
{!healthChecked && ( 检测中 )} {healthChecked && workerAlive && ( Worker 运行中 ×{workerCount} )} {healthChecked && !workerAlive && ( ⚠ Worker 已停止 )}
{/* Worker 崩溃警告横幅 */} {workerDead && activeJobs.length > 0 && (
Worker 未运行,{stuckCount} 个任务卡在队列中无法执行。 请在终端运行: python scripts/run_worker.py
)}
{/* Left: 输入区 */}
{/* Tab 切换 */}
{/* ── 链接采集 Tab ── */} {mode === "url" && ( <> {submitError && (
{submitError}
)}