// 文案二创独立链路 · 通用进度组件
//
// 三个页面共用(拆解爆款 / 复刻爆款 / 质检合成):URL 带 ?text_rewrite_job=xxx
// 时在该页对应位置内联渲染。组件内部职责:
// - SSE 订阅 /api/text-rewrite/jobs/{id}/stream,兜底 5s 轮询 /api/.../get
// - 渲染 7 stage 步骤条 + 当前阶段消息 + 百分比
// - 「展开工作内容」可折叠:根据当前 stage 拉对应产物预览
// · chunk_init 完成 → 展示 chunks_doubao 表(句切,d_001...)
// · chunk_segment 完成 → chunks_segment 表(语义自然段,s_001...)
// · rewrite 完成 → chunks_rewrite 表(段级二创,含可编辑 rewrite_text)
// - 交互按钮:
// · 编辑(rewrite stage 完成后)→ 调 PATCH /chunks/{cid}/rewrite
// · 跳到拆解爆款 / 跳到复刻爆款 / 跳到质检合成 → 带 ?text_rewrite_job=xxx 跳转
const TR_STAGES = [
{ id: "downloader", label: "1 · 链接下载" },
{ id: "audio_extract", label: "2 · 抽音轨" },
{ id: "asr", label: "3 · ASR 转写" },
{ id: "transcript_correct", label: "4 · LLM 修字" },
{ id: "chunk_init", label: "5 · 句切(豆包)" },
{ id: "chunk_segment", label: "6 · 语义段切" },
{ id: "rewrite", label: "7 · 段级二创" },
];
// 三页面自动流转的"下一步"映射
const TR_NEXT_ROUTE = {
teardown: { label: "进入复刻爆款", path: "/remix", id: "remix" },
remix: { label: "传送到质检合成", path: "/qc", id: "qc" },
qc: null, // 已是终点
};
// 跳转按钮组:去三个页面看同一个 text_rewrite job 的进度
const TRJumpButtons = ({ jobId, currentRoute }) => {
const targets = [
{ label: "拆解爆款", path: "/workspace", id: "teardown" },
{ label: "复刻爆款", path: "/remix", id: "remix" },
{ label: "质检合成", path: "/qc", id: "qc" },
];
return (
);
};
// 流转 CTA:任务在当前页"准备好"时给突出按钮,引导去下一页
const TRFlowCTA = ({ jobId, currentRoute, status, allMatched }) => {
const next = TR_NEXT_ROUTE[currentRoute];
if (!next) return null;
// 拆解页:任务 completed 即可去复刻
// 复刻页:任务 completed + 所有素材匹配完成才能去质检
const ready =
currentRoute === "teardown" ? status === "completed"
: currentRoute === "remix" ? (status === "completed" && allMatched)
: false;
const hint = !ready ? (
currentRoute === "teardown" ? "等待文案拆解完成…"
: currentRoute === "remix" ? "请先把所有段的分镜素材都匹配/选定"
: ""
) : "";
const url = `${next.path}?text_rewrite_job=${encodeURIComponent(jobId)}`;
return (
e.preventDefault()}
style={{
display: "inline-flex", alignItems: "center", gap: 6,
padding: "8px 14px", borderRadius: 4, fontSize: 12, fontWeight: 600,
textDecoration: "none", cursor: ready ? "pointer" : "not-allowed",
background: ready ? "var(--cyan)" : "var(--bg-2)",
color: ready ? "var(--bg-0)" : "var(--fg-3)",
border: `1px solid ${ready ? "var(--cyan)" : "var(--line-1)"}`,
}}
title={hint}>
{ready ? "➡" : "⏳"} {next.label}
{!ready && hint && (
· {hint}
)}
);
};
// 单条 stage 步骤标签
const TRStageTag = ({ stage, state, message, pct }) => {
const colors = {
done: { fg: "var(--green)", bg: "color-mix(in oklch, var(--green) 12%, var(--bg-1))" },
cur: { fg: "var(--cyan)", bg: "color-mix(in oklch, var(--cyan) 16%, var(--bg-1))" },
locked: { fg: "var(--fg-3)", bg: "var(--bg-1)" },
failed: { fg: "var(--pink)", bg: "color-mix(in oklch, var(--pink) 14%, var(--bg-1))" },
};
const c = colors[state] || colors.locked;
return (
{state === "done" ? "✓ " : state === "cur" ? "▶ " : ""}{stage.label}
{state === "cur" && (
{pct}%
)}
);
};
// chunks_doubao / chunks_segment / chunks_rewrite 表
const TRChunksTable = ({ kind, rows, jobId, onRewriteEdited }) => {
const [editing, setEditing] = React.useState(null); // {chunk_id, draft}
const [busy, setBusy] = React.useState(false);
if (!rows || !rows.length) {
return 暂无数据
;
}
const submitEdit = async () => {
if (!editing || !jobId) return;
setBusy(true);
try {
const res = await API.textRewrite.patchRewrite(jobId, editing.chunk_id, {
rewrite_text: editing.draft,
});
onRewriteEdited && onRewriteEdited(editing.chunk_id, editing.draft, res);
setEditing(null);
} catch (e) {
alert("保存失败:" + (e.message || e));
} finally {
setBusy(false);
}
};
return (
| chunk_id |
{kind === "doubao" && 起止 (s) | }
{kind === "segment" && 来源 d_chunks | }
{kind === "rewrite" ? "段级二创" : "文本"} |
{kind === "rewrite" && 操作 | }
{rows.map((r, i) => {
const cid = r.chunk_id;
const isEdit = editing && editing.chunk_id === cid;
return (
| {cid} |
{kind === "doubao" && (
{(r.t_start || 0).toFixed(2)}–{(r.t_end || 0).toFixed(2)} |
)}
{kind === "segment" && (
{(r.source_chunk_ids || []).join(", ")} |
)}
{kind === "rewrite" && isEdit ? (
|
{kind === "rewrite" && (
{isEdit ? (
) : (
)}
|
)}
);
})}
);
};
const TR_TH = { textAlign: "left", padding: "4px 6px", fontWeight: 600,
color: "var(--fg-2)", fontSize: 10, fontFamily: "var(--mono)" };
const TR_TD = { padding: "4px 6px", verticalAlign: "top", fontSize: 11,
color: "var(--fg-1)", lineHeight: 1.4 };
const TR_TD_MONO = { ...TR_TD, fontFamily: "var(--mono)", fontSize: 10,
color: "var(--purple)", whiteSpace: "nowrap" };
const TR_BTN_PRIMARY = { fontSize: 10, padding: "2px 8px", borderRadius: 3,
background: "var(--cyan)", color: "var(--bg-0)",
border: "1px solid var(--cyan)", cursor: "pointer" };
const TR_BTN_SECONDARY = { fontSize: 10, padding: "2px 8px", borderRadius: 3,
background: "var(--bg-1)", color: "var(--fg-1)",
border: "1px solid var(--line-1)", cursor: "pointer" };
// 主组件
const TextRewriteProgress = ({ jobId, currentRoute, expanded: initialExpanded = true,
compact = false }) => {
const [job, setJob] = React.useState(null); // 状态/进度元
const [result, setResult] = React.useState(null); // 完整 result.json(按需)
const [matchData, setMatchData] = React.useState(null); // match-table(看 allMatched 用)
const [err, setErr] = React.useState("");
const [expanded, setExpanded] = React.useState(initialExpanded);
const [activeTab, setActiveTab] = React.useState("rewrite"); // doubao / segment / rewrite
// 拉初始 + 订阅
React.useEffect(() => {
if (!jobId) return;
let cancelled = false;
let es = null;
let pollTimer = null;
const refresh = async () => {
try {
const j = await API.textRewrite.get(jobId);
if (cancelled) return;
setJob(j);
// 进度推到一定阶段后拉 result
const step = (j.progress && j.progress.step) || "";
const stageIdx = TR_STAGES.findIndex(s => s.id === step);
if (stageIdx >= 4 || j.status === "completed") {
try {
const r = await API.textRewrite.result(jobId);
if (!cancelled) setResult(r);
} catch (_) {}
}
// 完成后拉 match-table,用于"是否所有段都匹配到素材"的判断
if (j.status === "completed") {
try {
const mt = await API.textRewrite.matchTable(jobId);
if (!cancelled) setMatchData(mt);
} catch (_) {}
}
} catch (e) {
if (!cancelled) setErr(String(e.message || e));
}
};
refresh();
// SSE 实时进度
try {
es = new EventSource(API.textRewrite.streamUrl(jobId));
es.addEventListener("progress", (ev) => {
if (cancelled) return;
try {
const data = JSON.parse(ev.data);
setJob((prev) => prev ? { ...prev, progress: { ...(prev.progress || {}), ...data } } : prev);
} catch (_) {}
});
es.addEventListener("status", (ev) => {
if (cancelled) return;
try {
const data = JSON.parse(ev.data);
setJob((prev) => prev ? { ...prev, status: data.status || prev.status } : prev);
} catch (_) {}
});
es.addEventListener("done", (ev) => {
if (cancelled) return;
try {
const data = JSON.parse(ev.data);
setJob((prev) => prev ? { ...prev, status: data.status || prev.status, error: data.error } : prev);
// 完成后再拉一次完整 result
refresh();
} catch (_) {}
es && es.close();
});
es.onerror = () => {
// SSE 断开 → 启 5s 兜底轮询
if (es) { es.close(); es = null; }
if (!pollTimer) pollTimer = setInterval(refresh, 5000);
};
} catch (_) {
// 浏览器不支持 SSE → 直接轮询
pollTimer = setInterval(refresh, 5000);
}
return () => {
cancelled = true;
es && es.close();
pollTimer && clearInterval(pollTimer);
};
}, [jobId]);
if (!jobId) return null;
if (err && !job) {
return
加载文案二创任务失败:{err}
;
}
if (!job) {
return
加载文案二创任务 {jobId.slice(0, 8)}…
;
}
const status = job.status || "pending";
const progress = job.progress || {};
const curStep = progress.step || "";
const curPct = progress.pct || 0;
const curMessage = progress.message || "";
const stageDoneIdx = TR_STAGES.findIndex(s => s.id === curStep);
const stateOf = (idx) => {
if (status === "failed" && idx === stageDoneIdx) return "failed";
if (idx < stageDoneIdx) return "done";
if (idx === stageDoneIdx) return status === "completed" ? "done" : "cur";
if (status === "completed") return "done";
return "locked";
};
// 当前可展示的产物
const haveDoubao = !!(result && result.chunks_doubao && result.chunks_doubao.length);
const haveSegment = !!(result && result.chunks_segment && result.chunks_segment.length);
const haveRewrite = !!(result && result.chunks_rewrite && result.chunks_rewrite.length);
const onRewriteEdited = (cid, text, _res) => {
setResult((prev) => {
if (!prev) return prev;
const next = { ...prev };
next.chunks_rewrite = (prev.chunks_rewrite || []).map(c =>
c.chunk_id === cid ? { ...c, rewrite_text: text, edited_manually_at: new Date().toISOString() } : c
);
return next;
});
};
return (
📝 文案二创独立链路
{jobId.slice(0, 8)}
{status}
{expanded && (
{/* 跳转按钮 */}
视频→ASR→修字→句切→段切→段级二创 · 不做视觉拆解
{/* 自动流转 CTA:根据 currentRoute 决定下一步去哪 */}
{(() => {
const rows = matchData?.rows || [];
const allMatched = rows.length > 0 &&
rows.every(r => r.visual_remix && r.visual_remix.match);
return
;
})()}
{/* 7 stage 步骤条 */}
{TR_STAGES.map((s, i) => (
))}
{/* 当前消息 */}
{curMessage && (
▶ {TR_STAGES[stageDoneIdx]?.label || curStep} · {curPct}% · {curMessage}
)}
{/* 失败提示 */}
{status === "failed" && job.error && (
失败:{job.error}
)}
{/* 工作内容标签页 */}
{!compact && (haveDoubao || haveSegment || haveRewrite) && (
{haveDoubao && (
)}
{haveSegment && (
)}
{haveRewrite && (
)}
{activeTab === "doubao" && haveDoubao && (
)}
{activeTab === "segment" && haveSegment && (
)}
{activeTab === "rewrite" && haveRewrite && (
)}
)}
)}
);
};
const TR_TAB_ON = {
fontSize: 10.5, padding: "3px 10px", borderRadius: 3,
background: "var(--cyan)", color: "var(--bg-0)",
border: "1px solid var(--cyan)", cursor: "pointer", fontFamily: "var(--mono)",
};
const TR_TAB_OFF = {
fontSize: 10.5, padding: "3px 10px", borderRadius: 3,
background: "var(--bg-1)", color: "var(--fg-2)",
border: "1px solid var(--line-1)", cursor: "pointer", fontFamily: "var(--mono)",
};
// 解析 URL ?text_rewrite_job= 参数(三个页面共用)
const useTextRewriteJobIdFromQuery = () => {
const [jobId, setJobId] = React.useState(() => {
try { return new URL(window.location.href).searchParams.get("text_rewrite_job") || ""; }
catch { return ""; }
});
React.useEffect(() => {
const onPop = () => {
try {
setJobId(new URL(window.location.href).searchParams.get("text_rewrite_job") || "");
} catch {}
};
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
return jobId;
};
Object.assign(window, {
TextRewriteProgress,
TR_STAGES,
useTextRewriteJobIdFromQuery,
});