// Dashboard — 总览数据看板(接 inbox + jobs 真实数据) const _PLATFORM_NAME = { douyin: "抖音", xhs: "小红书", ks: "快手", sph: "视频号" }; // localStorage 工具:记住用户上次选过的模板/规则,下次自动回显 const _lsGet = (k) => { try { return localStorage.getItem(k) || ""; } catch { return ""; } }; const _lsSet = (k, v) => { try { localStorage.setItem(k, v == null ? "" : String(v)); } catch {} }; const _formatWan = (n) => { if (typeof n !== "number" || isNaN(n)) return "0"; if (n >= 100000000) return (n / 100000000).toFixed(2) + " 亿"; if (n >= 10000) return (n / 10000).toFixed(1) + " w"; return n.toLocaleString(); }; // 顶部"链接快速拆解"卡片:粘链接 + 选模板 + 选规则 → 调 /api/analyze/link → 跳转到拆解工作台 const QuickLinkLaunch = ({ onLaunched }) => { const [url, setUrl] = React.useState(""); const [busy, setBusy] = React.useState(false); const [hint, setHint] = React.useState(null); // 模板/规则下拉数据 + 当前选中(空字符串 = 用户没选,走"未指定"语义) const [templates, setTemplates] = React.useState([]); const [rulePacks, setRulePacks] = React.useState([]); // localStorage 记住上次选过的,下次自动回显 const [templateId, setTemplateId] = React.useState(_lsGet("quickLink.tplId")); const [rulePackId, setRulePackId] = React.useState(_lsGet("quickLink.ruleId")); React.useEffect(() => { Promise.all([ API.templates.list({ category: "teardown" }).catch(() => ({ items: [] })), API.templates.list({ category: "image_teardown" }).catch(() => ({ items: [] })), ]).then(([a, b]) => { const merged = [...(a.items || []), ...(b.items || [])]; setTemplates(merged); // 默认偏好:用户没干预过 + 没存 localStorage → 优先用「图文爆款拆解 Skills 提示词模板」 if (!_lsGet("quickLink.tplId")) { const preferred = merged.find(t => t.name === "图文爆款拆解 Skills 提示词模板"); if (preferred) setTemplateId(String(preferred.id)); } }); API.rulePacks.list().catch(() => ({ items: [] })) .then(d => setRulePacks((d.items || []).filter(r => r.enabled !== false))); }, []); const onTplChange = (v) => { setTemplateId(v); _lsSet("quickLink.tplId", v); }; const onRuleChange = (v) => { setRulePackId(v); _lsSet("quickLink.ruleId", v); }; const submit = async () => { const raw = url.trim(); if (!raw) { setHint({ kind: "err", msg: "请先粘贴视频链接" }); return; } setBusy(true); setHint(null); try { const body = { url: raw, title: "" }; if (templateId) body.template_ids = [Number(templateId)]; if (rulePackId) body.rule_pack_id = rulePackId; const res = await API.analyze.link(body); const jobId = res.job_id || (res.job_ids && res.job_ids[0]); const usedTpl = templates.find(t => String(t.id) === String(templateId)); const usedRule = rulePacks.find(r => r.pack_id === rulePackId); const tplLabel = usedTpl ? `(模板:${usedTpl.name}` : "(默认模板"; const ruleLabel = usedRule ? ` · 规则:${usedRule.display_name})` : ")"; setHint({ kind: "ok", msg: `已发起拆解${tplLabel}${ruleLabel} · 任务 ${jobId}`, jobId }); setUrl(""); onLaunched && onLaunched(jobId); } catch (e) { setHint({ kind: "err", msg: String(e.message || e) }); } finally { setBusy(false); } }; const onKeyDown = (e) => { if (e.key === "Enter" && !busy) submit(); }; return (
链接快速拆解
支持抖音 / 小红书 / 快手 · 直接粘贴"复制链接"原文即可
前往工作台
setUrl(e.target.value)} onKeyDown={onKeyDown} disabled={busy} style={{ fontSize: 12 }} />
{hint && (
{hint.msg} {hint.kind === "ok" && hint.jobId && ( 查看进度 )}
)}
); }; // AIGC 混剪:粘文案 → /api/aigc-mix → 轮询进度 → 渲染成片预览 const AigcMixPanel = () => { const [text, setText] = React.useState( "当你打开自己家门的那一刻,什么都不用解释了。那套房,就是答案。现在就添加AI选房师。住进去那天,你笑了。", ); const [voice, setVoice] = React.useState("zh-CN-XiaoxiaoNeural"); const [seed, setSeed] = React.useState("42"); const [job, setJob] = React.useState(null); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(""); const [health, setHealth] = React.useState(null); const esRef = React.useRef(null); // EventSource const pollerRef = React.useRef(null); // 兜底 5s 轮询(仅 SSE 断流时启) React.useEffect(() => { API.aigcMix.health().then(setHealth).catch(() => setHealth(null)); return () => { if (esRef.current) { try { esRef.current.close(); } catch (_) {} esRef.current = null; } if (pollerRef.current) clearInterval(pollerRef.current); }; }, []); // 订阅一个 mix job 的 SSE 进度(替代 1.2s 轮询) const subscribe = (jobId) => { if (esRef.current) { try { esRef.current.close(); } catch (_) {} esRef.current = null; } if (pollerRef.current) { clearInterval(pollerRef.current); pollerRef.current = null; } let es; try { es = new EventSource(`/api/aigc-mix/jobs/${encodeURIComponent(jobId)}/stream`); } catch (_) { pollerRef.current = setInterval(async () => { try { setJob(await API.aigcMix.get(jobId)); } catch (_) {} }, 5000); return; } esRef.current = es; es.addEventListener("status", (ev) => { try { const d = JSON.parse(ev.data); setJob(prev => prev ? { ...prev, status: d.status || prev.status } : prev); } catch (_) {} }); es.addEventListener("progress", (ev) => { try { const d = JSON.parse(ev.data); setJob(prev => prev ? { ...prev, progress: typeof d.pct === "number" ? d.pct : prev.progress, message: d.message || prev.message, step: d.step || prev.step, } : prev); } catch (_) {} }); es.addEventListener("done", (ev) => { try { const d = JSON.parse(ev.data); setJob(prev => prev ? { ...prev, status: d.status || prev.status, error: d.error || prev.error, output_url: d.output_url || prev.output_url, unmatched_count: typeof d.unmatched_count === "number" ? d.unmatched_count : prev.unmatched_count, } : prev); } catch (_) {} setBusy(false); try { es.close(); } catch (_) {} esRef.current = null; }); es.onerror = () => { // SSE 断流 → 启 5s 兜底轮询,直到 status 终结 try { es.close(); } catch (_) {} esRef.current = null; if (!pollerRef.current) { pollerRef.current = setInterval(async () => { try { const next = await API.aigcMix.get(jobId); setJob(next); if (next.status === "completed" || next.status === "failed" || next.status === "cancelled") { clearInterval(pollerRef.current); pollerRef.current = null; setBusy(false); } } catch (_) {} }, 5000); } }; }; const startMix = async () => { if (!text.trim()) { setErr("请填写文案"); return; } setBusy(true); setErr(""); try { const j = await API.aigcMix.start({ text: text.trim(), voice, seed: seed === "" ? null : Number(seed), }); setJob(j); subscribe(j.job_id); } catch (e) { setErr(String(e.message || e)); setBusy(false); } }; const reset = () => { if (esRef.current) { try { esRef.current.close(); } catch (_) {} esRef.current = null; } if (pollerRef.current) { clearInterval(pollerRef.current); pollerRef.current = null; } setJob(null); setBusy(false); setErr(""); }; const pct = job?.progress || 0; const isDone = job?.status === "completed"; const isFail = job?.status === "failed"; return (
AIGC 混剪合成 {health && ( {health.ok ? `就绪 · ${health.clips_count} 分镜` : "脚本未就绪"} )}
文案 → edge-tts 口播 → ffmpeg 拼接分镜 + 烧字幕 + 三轨混音 → 成片
{job && ( )}
{!job && (