// 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 && (
)}
{job && (
任务 {job.job_id} · {job.status}
{pct}%
{job.message || (isFail ? (job.error || "失败") : "运行中…")}
{(job.log_tail || []).length > 0 && (
{(job.log_tail || []).slice(-8).join("\n")}
)}
{isDone && (
)}
)}
);
};
// 链接 → 文案二创独立链路(送到 /remix?text_rewrite_job=)
const QuickTextRewriteLaunch = () => {
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([]);
const [templateId, setTemplateId] = React.useState(_lsGet("quickRewrite.tplId"));
const [rulePackId, setRulePackId] = React.useState(_lsGet("quickRewrite.ruleId"));
React.useEffect(() => {
// 当前 /text-rewrite/templates 表是空的,这里合并拉 teardown + image_teardown
// 两个 category 的拆解模板,让用户能一次看到所有可选(视频拆解 + 图文拆解)
Promise.all([
API.textRewrite.listTemplates().catch(() => ({ items: [] })),
API.templates.list({ category: "teardown" }).catch(() => ({ items: [] })),
API.templates.list({ category: "image_teardown" }).catch(() => ({ items: [] })),
]).then(([a, b, c]) => {
const merged = [...(a.items || []), ...(b.items || []), ...(c.items || [])];
setTemplates(merged);
if (!_lsGet("quickRewrite.tplId")) {
const preferred = merged.find(t => t.name === "[内置·拆解] 房地产 AIDIS 五段拆解");
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("quickRewrite.tplId", v); };
const onRuleChange = (v) => { setRulePackId(v); _lsSet("quickRewrite.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.rewrite_template_id = Number(templateId);
if (rulePackId) body.rule_pack_id = rulePackId;
const res = await API.textRewrite.startLink(body);
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} · 任务 ${res.job_id}`,
jobId: res.job_id });
setUrl("");
window.history.pushState({}, "",
`/remix?text_rewrite_job=${encodeURIComponent(res.job_id)}`);
window.dispatchEvent(new PopStateEvent("popstate"));
} catch (e) {
setHint({ kind: "err", msg: String(e.message || e) });
} finally { setBusy(false); }
};
return (
文案二创独立链路
纯文案 · 7 stage
链接 → ASR → 修字 → 句切 → 段切(语义自然段)→ 段级二创 · 不做视觉拆解
前往复刻看链路
setUrl(e.target.value)}
onKeyDown={e => e.key === "Enter" && !busy && submit()}
disabled={busy} style={{ fontSize: 12 }}/>
{hint && (
{hint.msg}
)}
);
};
const Dashboard = () => {
const [inbox, setInbox] = React.useState([]);
const [jobs, setJobs] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const load = async () => {
setLoading(true);
try {
const [d1, d2] = await Promise.all([
API.inbox.list({ status: "all", limit: 5000 }).catch(() => ({ items: [] })),
API.jobs.list().catch(() => ({ items: [] })),
]);
setInbox(d1.items || []);
setJobs(d2.items || []);
} finally {
setLoading(false);
}
};
React.useEffect(() => {
load();
const t = setInterval(load, 30000); // 30s 自动刷新
return () => clearInterval(t);
}, []);
// ─── 派生统计 ───
const stats = React.useMemo(() => {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const dayMs = 86400000;
const inboxTotal = inbox.length;
const completedJobs = jobs.filter(j => j.status === "completed");
const completedTotal = completedJobs.length;
const failedTotal = jobs.filter(j => j.status === "failed").length;
const runningTotal = jobs.filter(j => j.status === "running" || j.status === "pending").length;
// 14 天分桶(0 = 13 天前,13 = 今天);KPI mini-bar 用全部 14 天,趋势折线用后 7 天
const days14 = [];
for (let i = 13; i >= 0; i--) {
const dStart = today.getTime() - i * dayMs;
const dEnd = dStart + dayMs;
const collect = inbox.filter(v => {
const t = Date.parse(v.received_at);
return !isNaN(t) && t >= dStart && t < dEnd;
}).length;
const hit = completedJobs.filter(j => {
const t = Date.parse(j.updated_at);
return !isNaN(t) && t >= dStart && t < dEnd;
}).length;
days14.push({ collect, hit });
}
const collectBars = days14.map(d => d.collect);
const hitBars = days14.map(d => d.hit);
// 近 7 天 vs 上 7 天 增长率(KPI delta)
const sumLast7 = (arr) => arr.slice(7, 14).reduce((s, x) => s + x, 0);
const sumPrev7 = (arr) => arr.slice(0, 7).reduce((s, x) => s + x, 0);
const computeDelta = (arr) => {
const last = sumLast7(arr), prev = sumPrev7(arr);
if (prev === 0) return last > 0 ? "新增" : "—";
const pct = (last - prev) / prev * 100;
const sign = pct >= 0 ? "+" : "";
return `${sign}${pct.toFixed(1)}%`;
};
// 趋势折线(近 7 天每天命中数;如果命中全 0,回落到采集数让曲线有形)
const last7Hit = days14.slice(7).map(d => d.hit);
const last7Collect = days14.slice(7).map(d => d.collect);
const hasHit = last7Hit.some(v => v > 0);
const trend7 = hasHit ? last7Hit : last7Collect;
// 平台分布
const platformAgg = {};
for (const v of inbox) {
const p = v.platform || "douyin";
platformAgg[p] = (platformAgg[p] || 0) + 1;
}
const platformList = Object.entries(platformAgg)
.sort((a, b) => b[1] - a[1])
.map(([p, n]) => ({
p, name: _PLATFORM_NAME[p] || p, v: n,
pct: inboxTotal ? Math.round(n / inboxTotal * 100) : 0,
}));
// 实时爆款热榜 — inbox 按点赞排序 TOP 6
const hot = [...inbox]
.filter(v => (v.stats?.diggCount || 0) > 0)
.sort((a, b) => (b.stats?.diggCount || 0) - (a.stats?.diggCount || 0))
.slice(0, 6)
.map((v, i) => {
const digg = v.stats?.diggCount || 0;
// 简单 hot 分:log10(digg) 映射到 [1, 9.9]
const hotScore = digg > 0
? Math.min(9.9, Math.max(1, Math.log10(digg) * 1.5 + 1)).toFixed(1)
: "—";
return {
r: i + 1,
v,
digg,
hotScore,
formula: v.estate_type || (v.industry && v.industry !== "general" ? v.industry : null) || null,
};
});
// 题材分布 TOP 6(用 estate_type / industry / hashtag 兜底聚合)
const formulaAgg = {};
for (const v of inbox) {
let k = v.estate_type;
if (!k && v.industry && v.industry !== "general") k = v.industry;
if (!k && v.hashtag) {
const tags = String(v.hashtag).split(/[#\s,,]+/).filter(Boolean);
if (tags.length) k = tags[0];
}
if (!k) continue;
formulaAgg[k] = (formulaAgg[k] || 0) + 1;
}
const formulaList = Object.entries(formulaAgg)
.sort((a, b) => b[1] - a[1])
.slice(0, 6)
.map(([n, c]) => ({ n, c }));
return {
inboxTotal, completedTotal, failedTotal, runningTotal,
collectBars, hitBars,
collectDelta: computeDelta(collectBars),
hitDelta: computeDelta(hitBars),
trend7, trend7Label: hasHit ? "命中数" : "采集数",
platformList,
hot,
formulaList,
};
}, [inbox, jobs]);
// 折线 X 轴 4 个 label(近 7 天均匀分布)
const trendLabels = React.useMemo(() => {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
return [0, 2, 4, 6].map(i => {
const d = new Date(today.getTime() - (6 - i) * 86400000);
return `${String(d.getMonth() + 1).padStart(2, "0")}/${String(d.getDate()).padStart(2, "0")}`;
});
}, []);
const renderDelta = (d) => {
if (d === "—" || d === "未启用") {
return {d};
}
const isUp = d === "新增" || d.startsWith("+");
return {isUp ? "▲" : "▼"} {d};
};
return (
<>
>}
/>
{/* 链接快速拆解:粘链接直接发起拆解 */}
load()}/>
{/* 文案二创独立链路:链接 → 二创文案 → 跳 /remix */}
{/* AIGC 混剪:文案 → 成片 */}
{/* KPI 行 */}
{[
{ l: "采集视频", v: stats.inboxTotal.toLocaleString(), u: "条", d: stats.collectDelta, chart: stats.collectBars, col: "cyan" },
{ l: "爆款命中", v: stats.completedTotal.toLocaleString(), u: "条", d: stats.hitDelta, chart: stats.hitBars, col: "orange" },
{ l: "拆解失败", v: stats.failedTotal.toLocaleString(), u: "条", d: "—", chart: Array(14).fill(0), col: "pink" },
{ l: "进行中", v: stats.runningTotal.toLocaleString(), u: "条", d: "—", chart: Array(14).fill(0), col: "purple" },
].map((k, i) => (
{k.l}
{k.v}
{k.u}
{renderDelta(k.d)}
))}
{/* 爆款趋势 + 平台分布 */}
爆款命中趋势
daily · {stats.trend7Label}
{stats.trend7Label}
{stats.trend7.some(v => v > 0) ? (
<>
{trendLabels.map(l => {l})}
>
) : (
近 7 天暂无活跃数据
)}
平台分布
基于 inbox · 全部
{stats.platformList.length ? stats.platformList.map((r, i) => (
{r.v.toLocaleString()}
{r.pct}%
)) : (
暂无平台数据
)}
{/* 实时热榜 + 题材分布 */}
实时爆款热榜
TOP {stats.hot.length} · 按点赞排序
查看全部
{stats.hot.length ? stats.hot.map((row) => (
{String(row.r).padStart(2, "0")}
{row.v.title || "(无标题)"}
{row.v.author ? `@${row.v.author}` : "@未知"}
{row.formula && {row.formula}}
{_formatWan(row.digg)}
点赞
{row.hotScore}
HOT
拆解
)) : (
暂无 inbox 数据 · 前往「1·收集爆款」入库
)}
题材分布
已采集 · TOP 6
{stats.formulaList.length ? stats.formulaList.map((f, i) => (
{i + 1}
{f.n}
{f.c} 条
{stats.inboxTotal ? ((f.c / stats.inboxTotal) * 100).toFixed(1) : "0"}%
占比
)) : (
暂无题材数据
)}
>
);
};
Object.assign(window, { Dashboard });