// 1 · 收集爆款 — 真实视频列表(筛选 + 分页 + 选中 → 发起拆解) // 多模态 LLM provider 白名单(与 multimodal_client.py 优先级对齐) const _MULTIMODAL_PROVIDERS = ["gemini", "openai_multimodal"]; // 行业候选清单(与后端 app/core/catalog.py:INDUSTRY_OPTIONS 对齐) const INDUSTRY_OPTIONS = [ { key: "", label: "全部行业" }, { key: "real_estate", label: "房地产" }, { key: "mother_baby", label: "母婴" }, { key: "travel", label: "文旅" }, { key: "hotel", label: "酒店" }, { key: "insurance", label: "保险" }, { key: "finance", label: "理财" }, { key: "automotive", label: "汽车" }, { key: "education", label: "教育" }, { key: "home_improvement", label: "家装" }, { key: "general", label: "通用" }, ]; // 数据排序字段:5 个数值 + 1 个入库时间 const SORT_FIELDS = [ { key: "digg", label: "点赞" }, { key: "comment", label: "评论" }, { key: "share", label: "分享" }, { key: "recommend", label: "推荐 / 播放" }, { key: "collect", label: "收藏" }, ]; const SORT_ACCESSOR = { received: (v) => v.receivedAt ? Date.parse(v.receivedAt) : 0, digg: (v) => v.digg || 0, comment: (v) => v.comment || 0, share: (v) => v.share || 0, recommend: (v) => v.recommend || 0, collect: (v) => v.collect || 0, }; // 由 jobs 列表反推一条 inbox 视频的拆解状态(与 teardown.jsx 同步保持一致) // - 路径 1:inbox.job_id 精确反查 // - 路径 2:title 兜底匹配最新一条 function _deriveStatus(v, jobs) { const map = (s) => { if (s === "completed") return "done"; if (s === "failed") return "failed"; if (s === "running" || s === "pending") return "in_progress"; return null; }; if (!v) return "untouched"; if (v.job_id) { const j = jobs.find(x => x.job_id === v.job_id); return j ? (map(j.status) || "untouched") : "untouched"; } if (v.title && jobs.length) { const sameTitle = jobs.filter(j => j.title && j.title === v.title); if (sameTitle.length) { sameTitle.sort((a, b) => (b.created_at || "").localeCompare(a.created_at || "")); return map(sameTitle[0].status) || "untouched"; } } return "untouched"; } const CollectPage = () => { const [pool, setPool] = React.useState([]); const [meta, setMeta] = React.useState({ count: 0, pull: null, pullHistory: [] }); const [loading, setLoading] = React.useState(true); const [loadError, setLoadError] = React.useState(""); const [pulling, setPulling] = React.useState(false); const [newIds, setNewIds] = React.useState(new Set()); const [page, setPage] = React.useState(1); const knownIdsRef = React.useRef(new Set()); const firstLoadRef = React.useRef(true); // ─── 拆解相关 state ─── const [jobs, setJobs] = React.useState([]); const [templates, setTemplates] = React.useState([]); const [credentials, setCredentials] = React.useState([]); const [rulePacks, setRulePacks] = React.useState([]); // 模板多选:Set。空集合 = 后端按默认模板单 job const [selTplIds, setSelTplIds] = React.useState(() => new Set()); const [selCredId, setSelCredId] = React.useState(null); // 规则模板(pack_id 字符串);空 = 无规则限制。localStorage 记忆上次选择 const [selRulePackId, setSelRulePackId] = React.useState(() => { try { return localStorage.getItem("collect.ruleId") || ""; } catch { return ""; } }); const [selRowIds, setSelRowIds] = React.useState(() => new Set()); const [listTab, setListTab] = React.useState("pending"); // pending | done const [starting, setStarting] = React.useState(false); const [batchProgress, setBatchProgress] = React.useState({ done: 0, total: 0 }); const [recentlyStarted, setRecentlyStarted] = React.useState([]); // ─── 大类切换:视频 / 图文 ─── const [viewKind, setViewKind] = React.useState("video"); // video | image // ─── 筛选器 state ─── const [filterCity, setFilterCity] = React.useState(""); const [filterMinDigg, setFilterMinDigg] = React.useState(""); const [filterMinComment, setFilterMinComment] = React.useState(""); const [filterMinCollect, setFilterMinCollect] = React.useState(""); const [filterIndustry, setFilterIndustry] = React.useState(""); // "" = 全部行业 const [filterReceived, setFilterReceived] = React.useState("all"); // all | today | 24h | 7d | 30d // ─── 排序 state ─── // sortKey: received | digg | comment | share | recommend | collect const [sortKey, setSortKey] = React.useState("received"); const [sortOrder, setSortOrder] = React.useState("desc"); // desc | asc const TODAY = new Date().toISOString().slice(0, 10); const PAGE_SIZE = 10; const formatWan = (n) => { const v = Number(n || 0); if (v >= 10000) return `${(v / 10000).toFixed(v >= 100000 ? 0 : 1)}w`; return String(v); }; const toBJ = (s) => { if (!s) return null; const d = new Date(s); if (isNaN(d.getTime())) return null; return d; }; const fmtDtBJ = (s) => { const d = toBJ(s); if (!d) return "—"; const M = String(d.getMonth() + 1).padStart(2, "0"); const D = String(d.getDate()).padStart(2, "0"); const h = String(d.getHours()).padStart(2, "0"); const m = String(d.getMinutes()).padStart(2, "0"); return `${M}-${D} ${h}:${m}`; }; const fmtDtSecBJ = (s) => { const d = toBJ(s); if (!d) return "—"; const M = String(d.getMonth() + 1).padStart(2, "0"); const D = String(d.getDate()).padStart(2, "0"); const h = String(d.getHours()).padStart(2, "0"); const m = String(d.getMinutes()).padStart(2, "0"); const sec = String(d.getSeconds()).padStart(2, "0"); return `${M}-${D} ${h}:${m}:${sec}`; }; const normalizeItem = (item) => { const stats = item.stats || {}; return { raw: item, id: item.id, t: item.title || item.external_id || "未命名视频", a: item.author ? `@${item.author}` : "@未知作者", p: item.platform || "douyin", v: formatWan(stats.diggCount || stats.collectCount || stats.shareCount || 0), url: item.video_url, isRealEstate: !!item.is_real_estate || item.industry === "real_estate", city: item.estate_city || "", estateType: item.estate_type || "", contentType: item.content_type || "video", // "video" | "image_post" externalId: item.external_id || item.aweme_id || "", digg: Number(stats.diggCount || 0), comment: Number(stats.commentCount || 0), collect: Number(stats.collectCount || 0), share: Number(stats.shareCount || 0), recommend: Number(stats.recommendCount || 0), capturedAt: item.captured_at, receivedAt: item.received_at, }; }; const loadInbox = async ({ silent = false } = {}) => { if (!silent) setLoading(true); setLoadError(""); try { const data = await API.inbox.list({ status: "all", limit: 5000 }); const items = (data.items || []).map(normalizeItem); const ids = new Set(items.map((item) => item.id)); if (!firstLoadRef.current) { const incoming = items .map((item) => item.id) .filter((id) => id && !knownIdsRef.current.has(id)); if (incoming.length) { setNewIds(new Set(incoming)); window.setTimeout(() => { setNewIds((prev) => { const next = new Set(prev); incoming.forEach((id) => next.delete(id)); return next; }); }, 8000); } } firstLoadRef.current = false; knownIdsRef.current = ids; setPool(items); setMeta({ count: data.total || data.count || items.length, pull: data.pull || null, pullHistory: data.pull_history || [], }); } catch (err) { setLoadError(String(err.message || err)); } finally { if (!silent) setLoading(false); } }; const pullOnce = async () => { setPulling(true); setLoadError(""); try { await API.inbox.pull({ page_size: 10 }); await loadInbox({ silent: true }); } catch (err) { setLoadError(String(err.message || err)); } finally { setPulling(false); } }; const loadJobs = async () => { try { const d = await API.jobs.list(); setJobs(d.items || []); } catch (_) {} }; const loadTemplates = async () => { try { // 拆解模板含 teardown(视频)+ image_teardown(图文)两个 category;都列给用户选 // 后端会按视频/图文实际形态匹配合适的模板;用户也能跨类目选 const [a, b] = await Promise.all([ API.templates.list({ category: "teardown" }).catch(() => ({ items: [] })), API.templates.list({ category: "image_teardown" }).catch(() => ({ items: [] })), ]); const items = [...(a.items || []), ...(b.items || [])]; setTemplates(items); // 首次加载:把默认模板预选上;用户清空后不再回填 const def = items.find(t => t.is_default); if (def) { setSelTplIds(prev => prev.size === 0 ? new Set([def.id]) : prev); } } catch (_) {} }; const loadRulePacks = async () => { try { const d = await API.rulePacks.list(); setRulePacks((d.items || []).filter(r => r.enabled !== false)); } catch (_) {} }; const loadCredentials = async () => { try { const results = await Promise.all(_MULTIMODAL_PROVIDERS.map( p => API.credentials.list(p).catch(() => ({ items: [] })) )); const all = results.flatMap(r => r.items || []); setCredentials(all); const def = all.find(c => c.provider === "gemini" && c.is_active) || all.find(c => c.is_active) || all[0]; if (def) setSelCredId(prev => prev ?? def.id); } catch (_) {} }; React.useEffect(() => { loadInbox(); loadJobs(); loadTemplates(); loadCredentials(); loadRulePacks(); const inboxTimer = window.setInterval(() => loadInbox({ silent: true }), 5000); const jobsTimer = window.setInterval(loadJobs, 5000); return () => { window.clearInterval(inboxTimer); window.clearInterval(jobsTimer); }; }, []); const [countdown, setCountdown] = React.useState(""); const lastPullAt = meta.pull?.last_pull_at; const intervalSec = meta.pull?.interval_sec || 300; React.useEffect(() => { if (!lastPullAt) return; const tick = () => { const last = new Date(lastPullAt).getTime(); const next = last + intervalSec * 1000; const diff = Math.max(0, Math.floor((next - Date.now()) / 1000)); if (diff <= 0) { setCountdown("即将拉取…"); } else { const m = Math.floor(diff / 60); const s = diff % 60; setCountdown(`${m}分${s.toString().padStart(2, "0")}秒`); } }; tick(); const t = window.setInterval(tick, 1000); return () => window.clearInterval(t); }, [lastPullAt, intervalSec]); const realEstateCount = pool.filter((v) => v.isRealEstate).length; const nonRealEstateCount = pool.length - realEstateCount; const lastResult = meta.pull?.last_result || {}; const pullHistory = meta.pullHistory || []; // ─── 城市选项(从已加载的 pool 动态去重)─── const cityOptions = React.useMemo(() => { const set = new Set(); for (const v of pool) if (v.city) set.add(v.city); return Array.from(set).sort(); }, [pool]); // ─── 大类拆分:视频在前、图文在后;统计两类总量 ─── // 大类切换在 filtered 之外做:让两个 tab 的计数恒等于"全部 pool 中该类的数量",不被其它筛选条件搅动 const kindCounts = React.useMemo(() => { let video = 0, image = 0; for (const v of pool) { if (v.contentType === "image_post") image++; else video++; } return { video, image }; }, [pool]); // ─── 主筛选管道(AND)─── const filtered = React.useMemo(() => { const minDigg = parseInt(filterMinDigg, 10) || 0; const minComment = parseInt(filterMinComment, 10) || 0; const minCollect = parseInt(filterMinCollect, 10) || 0; let receivedMin = 0; if (filterReceived !== "all") { const now = Date.now(); if (filterReceived === "today") { const d = new Date(); d.setHours(0, 0, 0, 0); receivedMin = d.getTime(); } else if (filterReceived === "24h") receivedMin = now - 24 * 3600 * 1000; else if (filterReceived === "7d") receivedMin = now - 7 * 24 * 3600 * 1000; else if (filterReceived === "30d") receivedMin = now - 30 * 24 * 3600 * 1000; } const arr = pool.filter(v => { // 大类 if (viewKind === "image" && v.contentType !== "image_post") return false; if (viewKind === "video" && v.contentType === "image_post") return false; // 行业(房产兼容:选房产时也接受 is_real_estate=true 的老数据) if (filterIndustry) { const matchIndustry = v.raw?.industry === filterIndustry || (filterIndustry === "real_estate" && v.isRealEstate); if (!matchIndustry) return false; } if (filterCity && v.city !== filterCity) return false; if (minDigg && (v.digg || 0) < minDigg) return false; if (minComment && (v.comment || 0) < minComment) return false; if (minCollect && (v.collect || 0) < minCollect) return false; if (receivedMin) { const t = v.receivedAt ? new Date(v.receivedAt).getTime() : 0; if (!t || t < receivedMin) return false; } return true; }); const get = SORT_ACCESSOR[sortKey] || SORT_ACCESSOR.received; arr.sort((a, b) => sortOrder === "asc" ? get(a) - get(b) : get(b) - get(a)); return arr; }, [pool, viewKind, filterIndustry, filterCity, filterMinDigg, filterMinComment, filterMinCollect, filterReceived, sortKey, sortOrder]); // ─── 按拆解状态分组(在 filtered 基础上)─── const groups = React.useMemo(() => { const done = [], pending = []; for (const v of filtered) { const status = _deriveStatus(v.raw, jobs); (status === "done" ? done : pending).push({ ...v, _status: status }); } return { done, pending }; }, [filtered, jobs]); const visible = listTab === "done" ? groups.done : groups.pending; const totalPages = Math.max(1, Math.ceil(visible.length / PAGE_SIZE)); const pagedItems = visible.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); // 筛选 / tab 变化时把 page 拉回 1 React.useEffect(() => { setPage(1); }, [viewKind, filterIndustry, filterCity, filterMinDigg, filterMinComment, filterMinCollect, filterReceived, listTab]); React.useEffect(() => { if (page > totalPages) setPage(totalPages); }, [visible.length, totalPages]); const hasFilter = !!filterIndustry || !!filterCity || !!filterMinDigg || !!filterMinComment || !!filterMinCollect || filterReceived !== "all"; // ─── 多选行 + 批量发起拆解 ─── // selRowIds 是 Set。从 pool 里把对应的视频 + 状态拎出来给「发起拆解」卡渲染。 const selVids = React.useMemo(() => { if (!selRowIds.size) return []; return pool.filter(v => selRowIds.has(v.id)) .map(v => ({ ...v, _status: _deriveStatus(v.raw, jobs) })); }, [selRowIds, pool, jobs]); const toggleRow = (id) => { setSelRowIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; // 当前页 vs 全部已选 — 用于头部 checkbox 的三态(none/some/all) const pagedAllSelected = pagedItems.length > 0 && pagedItems.every(v => selRowIds.has(v.id)); const pagedSomeSelected = pagedItems.some(v => selRowIds.has(v.id)); const togglePageAll = () => { setSelRowIds(prev => { const next = new Set(prev); if (pagedAllSelected) { for (const v of pagedItems) next.delete(v.id); } else { for (const v of pagedItems) next.add(v.id); } return next; }); }; // 可发起拆解的:有 url、不在拆解中 const launchable = selVids.filter(v => v.url && v._status !== "in_progress"); const skippedInProgress = selVids.filter(v => v._status === "in_progress").length; const skippedNoUrl = selVids.filter(v => !v.url).length; const startTeardown = async () => { if (!launchable.length) return; setStarting(true); const tplIds = Array.from(selTplIds); // 每条视频会 spawn N 个 job(N = 已选模板数,0 时后端按默认 = 1) const jobsPerVid = Math.max(1, tplIds.length); setBatchProgress({ done: 0, total: launchable.length * jobsPerVid }); const successes = []; const failures = []; let doneJobs = 0; // 串行避免一次打爆后端 + 让"最近发起"按顺序追加 for (let i = 0; i < launchable.length; i++) { const v = launchable[i]; try { const res = await API.analyze.link({ url: v.url, title: v.t || "", industry_key: v.raw?.industry || "", template_ids: tplIds, // 兼容老接口:单选时把首个 id 也填进去 template_id: tplIds.length === 1 ? tplIds[0] : null, multimodal_credential_id: selCredId, inbox_id: v.id, // 规则模板:空则后端按"无规则限制"处理 rule_pack_id: selRulePackId || null, }); // 后端返回 { job_ids: [...], job_id: <第一个>, count: N } const jobIds = Array.isArray(res.job_ids) && res.job_ids.length ? res.job_ids : [res.job_id]; for (const jid of jobIds) { successes.push({ jobId: jid, vidId: v.id, title: v.t, at: Date.now() }); } doneJobs += jobIds.length; } catch (e) { failures.push({ vidId: v.id, title: v.t, error: String(e.message || e) }); doneJobs += jobsPerVid; } setBatchProgress({ done: doneJobs, total: launchable.length * jobsPerVid }); } if (successes.length) { setRecentlyStarted(prev => { const successVids = new Set(successes.map(x => x.vidId)); const merged = [...successes, ...prev.filter(x => !successVids.has(x.vidId))]; return merged.slice(0, 20); }); } setStarting(false); setBatchProgress({ done: 0, total: 0 }); loadJobs(); loadInbox({ silent: true }); // 成功的清出选择(让用户能重新挑下一批);失败的留着方便重试 if (successes.length) { setSelRowIds(prev => { const next = new Set(prev); for (const s of successes) next.delete(s.vidId); return next; }); } if (failures.length) { alert(`部分发起失败(${failures.length}/${launchable.length}):\n` + failures.slice(0, 3).map(f => `- ${f.title}: ${f.error}`).join("\n") + (failures.length > 3 ? `\n…还有 ${failures.length - 3} 条` : "")); } }; const teardownBtn = (() => { if (!launchable.length) { if (skippedInProgress) return { text: "选中的均在拆解中", primary: false, disabled: true }; return { text: "开始拆解", primary: true, disabled: true }; } if (starting) { return { text: `发起中 ${batchProgress.done}/${batchProgress.total}…`, primary: false, disabled: true, }; } const tplCount = Math.max(1, selTplIds.size); const totalJobs = launchable.length * tplCount; const suffix = tplCount > 1 ? `(${launchable.length} 视频 × ${tplCount} 模板 = ${totalJobs} 任务)` : ""; if (launchable.length === 1) { const v = launchable[0]; if (v._status === "done") return { text: `重新拆解${suffix}`, primary: false, disabled: false }; if (v._status === "failed") return { text: `重新拆解${suffix}`, primary: false, disabled: false }; return { text: `开始拆解${suffix}`, primary: true, disabled: false }; } return { text: tplCount > 1 ? `批量拆解 ${suffix}` : `批量拆解 ${launchable.length} 条`, primary: true, disabled: false, }; })(); const TH = ({ children, style = {} }) => ( {children} ); const TD = ({ children, style = {} }) => ( {children} ); return ( <> {loading ? "加载中" : `${pool.length} 条入库${newIds.size ? ` · 新增 ${newIds.size}` : ""}`} } /> {/* 顶部 KPI */}
{[ { n: "01", l: "本地入库", v: pool.length.toLocaleString(), hi: true }, { n: "02", l: "房产视频", v: realEstateCount.toLocaleString() }, { n: "03", l: "非房产视频", v: nonRealEstateCount.toLocaleString() }, { n: "04", l: "单次拉取", v: (lastResult.page_size || 10).toLocaleString() + " 条" }, { n: "05", l: "本次去重", v: (lastResult.skipped || 0).toLocaleString() }, ].map((s, i) => (
{s.n} {s.l} {s.v}
))}
{/* 大类切换:视频爆款 / 图文爆款(视频在前) */}
{[ { k: "video", icon: "play", label: "视频爆款", count: kindCounts.video }, { k: "image", icon: "layers", label: "图文爆款", count: kindCounts.image }, ].map(t => { const on = viewKind === t.k; return ( ); })}
{/* 左:视频表格 */}
{loadError && (
{loadError}
)}
{/* 第一行:tab + 命中数 */}
{[ { id: "pending", label: "未拆解", count: groups.pending.length }, { id: "done", label: "已拆解", count: groups.done.length }, ].map(t => { const active = listTab === t.id; return ( ); })}
{(() => { if (loading && pool.length === 0) return "加载中…"; // 当前大类的总量(视频 / 图文 各自的池子) const kindTotal = viewKind === "image" ? kindCounts.image : kindCounts.video; if (hasFilter) return `${filtered.length} / ${kindTotal} 条(已筛选)`; return `共 ${kindTotal} 条`; })()} {newIds.size > 0 && ( +{newIds.size} 新 )}
{/* 第二行:数据排序面板(5 字段 + 入库时间,每个支持升降) */}
数据排序 {SORT_FIELDS.map(f => { const on = sortKey === f.key; return ( ); })}
{/* 第三行:行业 / 城市 / 时间 / 阈值(紧凑单行 + 自适应换行) */}
setFilterMinDigg(e.target.value)} /> setFilterMinComment(e.target.value)} /> setFilterMinCollect(e.target.value)} /> {hasFilter && ( )}
{pagedItems.map((v) => { const isNew = newIds.has(v.id); const isSel = selRowIds.has(v.id); return ( toggleRow(v.id)} style={{ background: isSel ? "color-mix(in oklch, var(--cyan) 8%, transparent)" : isNew ? "color-mix(in oklch, var(--orange) 6%, transparent)" : "transparent", cursor: "pointer", }} > ); })}
{ if (el) el.indeterminate = !pagedAllSelected && pagedSomeSelected; }} onChange={togglePageAll} title={pagedAllSelected ? "取消选择本页" : "选择本页全部"} style={{ cursor: "pointer" }} /> 平台 标题 作者 awemeId 房产 点赞 评论 收藏 分享 发布时间 入库时间 操作
toggleRow(v.id)} onClick={(e) => e.stopPropagation()} style={{ cursor: "pointer" }} />
{v.t} {v.contentType === "image_post" && ( 图文 )} {isNew && }
{v.a} {v.externalId} {v.isRealEstate ? ( 房产{v.city ? `·${v.city}` : ""} {v.estateType ? `·${v.estateType}` : ""} ) : ( 非房产 )} {formatWan(v.digg)} {formatWan(v.comment)} {formatWan(v.collect)} {formatWan(v.share)} {fmtDtBJ(v.capturedAt)} {fmtDtBJ(v.receivedAt)} {v.url ? ( e.stopPropagation()} > 打开 ) : ( )}
{pool.length === 0 && !loading && (
暂无视频数据,点击右上角「拉 10 条」开始采集
)} {pool.length === 0 && loading && (
正在加载视频列表…
)} {pool.length > 0 && visible.length === 0 && !loading && (
{hasFilter ? "当前筛选条件下没有匹配的视频,调整筛选条件试试" : (listTab === "done" ? "暂无已拆解视频" : "未拆解列表为空")}
)}
{visible.length > 0 && (
第 {page} / {totalPages} 页,共 {visible.length} 条{hasFilter && pool.length !== filtered.length ? `(筛后)` : ""}
)} {/* ─── 发起拆解 ─── */} {selVids.length === 0 ? (
请在上方表格中勾选要拆解的视频(支持多选)
勾选后下方出现「发起拆解」表单
) : (
发起拆解 {selVids.length === 1 ? `目标:${selVids[0].t} · ${selVids[0].a}` : `已选 ${selVids.length} 条`} {selVids.length === 1 && selVids[0]._status === "done" && 已拆解} {selVids.length === 1 && selVids[0]._status === "in_progress" && 拆解中} {selVids.length === 1 && selVids[0]._status === "failed" && 上次失败} {selVids.length > 1 && ( )}
{selVids.length > 1 && (skippedInProgress > 0 || skippedNoUrl > 0) && (
{skippedInProgress > 0 && <>已选 {skippedInProgress} 条正在拆解中,将自动跳过。} {skippedNoUrl > 0 && <>{skippedInProgress > 0 ? " " : ""}已选 {skippedNoUrl} 条无链接,将自动跳过。} {launchable.length > 0 && ` 实际发起 ${launchable.length} 条。`}
)}
模板 可多选 · 每个模板生成一套独立任务
{templates.map(t => { const on = selTplIds.has(t.id); return ( ); })} {!templates.length && ( 暂无可用模板 · 请先到 模板库 新建 )}
{selTplIds.size === 0 ? "未选 · 后端将自动用默认模板(每条视频 1 个任务)" : `已选 ${selTplIds.size} 个 · 每条视频会发起 ${selTplIds.size} 个并行任务`}
模型 {credentials.length ? ( ) : ( 暂无可用模型 · 请先在 模型管理 配置 )}
规则 {selRulePackId && ( ✓ 已绑定规则 )}
{selVids.length === 1 && !selVids[0].url && ( 该视频无链接,无法拆解 )}
)} {/* ─── 最近发起 ─── */} {recentlyStarted.length > 0 && (
最近发起({recentlyStarted.length}) 点击「查看进度」前往拆解爆款页查看完整进度
{recentlyStarted.map(r => { const j = jobs.find(x => x.job_id === r.jobId); const st = j?.status || "pending"; const tag = st === "completed" ? "green" : st === "failed" ? "pink" : "cyan"; const text = st === "completed" ? "已完成" : st === "failed" ? "失败" : st === "running" ? "进行中" : "排队中"; const pct = j?.progress?.pct ?? 0; return (
{r.title} {r.jobId.slice(0, 10)}… · 进度 {pct}%
); })}
)}
{/* 右:简洁信息面板 */}
{meta.pull?.last_error && (
⚠ 上次拉取失败
{meta.pull.last_error}
)}
采集状态
{[ { s: "光年总量", v: (lastResult.total || 0).toLocaleString() + " 条", ok: !!lastResult.total }, { s: "本地入库", v: pool.length.toLocaleString() + " 条", ok: pool.length > 0 }, { s: "上次拉取", v: (
{fmtDtSecBJ(lastPullAt) || "—"} 新增 {lastResult.inserted ?? 0} 重复 {lastResult.skipped || 0}
), ok: !!lastPullAt }, { s: "下次拉取", v: countdown || "—", ok: countdown && !countdown.includes("即将") }, ].map((r, i) => (
{r.ok ? "✓" : "—"} {r.s} {r.v}
))}
拉取历史(最近 {pullHistory.length} 次)
{pullHistory.length === 0 && ( 暂无拉取记录 )} {pullHistory.slice().reverse().map((h, i) => (
{fmtDtSecBJ(h.at)} {h.ok ? (
新增 {h.result?.inserted ?? 0} 重复 {h.result?.skipped || 0}
) : ( 失败 )}
))}
快速操作
数据源:/api/inbox
当前已入库 {pool.length.toLocaleString()} 条,其中房产 {realEstateCount.toLocaleString()} 条,非房产{" "} {nonRealEstateCount.toLocaleString()} 条。
页面每 5 秒刷新列表,后台每 {intervalSec} 秒自动拉取一次光年AI OpenAPI。
); }; Object.assign(window, { CollectPage });