// 拆解结果报告 — SPA 内 React 组件,替代旧 result.html / app.js iframe 嵌入。 // 数据来自 GET /api/jobs/{id}/result,shape 对齐 schemas_v4.AnalysisResultV4。 // 12 个 section 只读展示,不带可编辑功能(M3.5 再说)。 const RR = (() => { // ============== 工具 ============== const safeArr = (x) => Array.isArray(x) ? x : []; const safeStr = (x) => typeof x === "string" ? x : x == null ? "" : String(x); const fmtSec = (s) => { const n = Number(s); if (!isFinite(n) || n < 0) return "—"; if (n < 60) return `${n.toFixed(1)}s`; const m = Math.floor(n / 60); const r = n - m * 60; return `${m}m${r.toFixed(0)}s`; }; const pct = (v) => `${Math.round((Number(v) || 0) * 100)}%`; // ============== 容器 ============== const Section = ({ idx, title, badge, children, style }) => (
§ {String(idx).padStart(2, "0")}

{title}

{badge && {badge}}
{children}
); const Card = ({ title, children, style }) => (
{title &&
{title}
}
{children}
); const KV = ({ k, v, mono = false }) => (
{k} {v || "—"}
); const Chip = ({ children, tone }) => ( {children} ); const Empty = ({ msg = "暂无数据" }) => (
{msg}
); const Loading = () => (
加载拆解结果…
); const ErrorBox = ({ message }) => (
无法加载拆解结果 {message}
); // ============== 1. Header ============== const ResultHeader = ({ meta = {}, jobId }) => { const dl = (kind) => { window.open(`/api/jobs/${encodeURIComponent(jobId)}/download/${kind}`, "_blank"); }; return (
JOB · {jobId?.slice(0, 12) || "—"}

{meta.title || meta.video_filename || "未命名拆解任务"}

{meta.industry && {meta.industry}} {meta.duration_sec && · 时长 {fmtSec(meta.duration_sec)}} {meta.resolution && · {meta.resolution}} {meta.platform_hint && · {meta.platform_hint}}
); }; // ============== 2. SummaryCards ============== const SummaryCards = ({ stream, dual }) => { const sv = stream || {}; const dd = dual || {}; const ct = dd.content_tone || {}; const rer = dd.real_estate_relevance || {}; return (
{sv.stream_label || "—"}
置信度 {pct(sv.confidence)}
{sv.reasoning &&
{sv.reasoning}
} {safeArr(sv.signals).length > 0 && (
{sv.signals.map((s, i) => {s})}
)}
{ct.tone_label || "—"}
置信度 {pct(ct.confidence)}
{ct.reasoning &&
{ct.reasoning}
} {safeArr(ct.key_evidence).length > 0 && (
{ct.key_evidence.map((s, i) => {s})}
)}
{rer.relevance_label || "—"}
置信度 {pct(rer.confidence)}
{rer.reasoning &&
{rer.reasoning}
} {safeArr(rer.hit_keywords).length > 0 && (
{rer.hit_keywords.map((s, i) => {s})}
)} {rer.migration_value && (
迁移价值:{rer.migration_value}
)}
{dd.overall_assessment || "—"}
); }; // ============== 3. HookSection ============== const HookSection = ({ hook }) => { const h = hook || {}; return (
{!h.hook_type && !h.reusable_template ? : ( <>
{h.reusable_template || "—"}
{safeArr(h.core_trigger_words).length === 0 ? : h.core_trigger_words.map((w, i) => {w})}
{h.emotional_trigger || "—"}
{h.why_it_works || "—"}
)}
); }; // ============== 4. CopyStructureTable ============== const CopyStructureTable = ({ cs }) => { const segs = safeArr(cs?.segments); return (
{segs.length === 0 ? : ( <> {cs.narrative_arc &&
{cs.narrative_arc}
} {segs.map((s, i) => ( ))}
#时间段类型转化钩子 文案要点功能描述
{i + 1} {fmtSec(s.start_sec)}–{fmtSec(s.end_sec)} {s.segment_type || "—"} {s.conversion_hook_type || "—"} {s.key_text || "—"} {s.function_description || "—"}
)}
); }; // ============== 5. VisualRhythmCards ============== const VisualRhythmCards = ({ visual }) => { const v = visual || {}; const dist = v.shot_distribution || {}; const distEntries = Object.entries(dist); const cuts = Number(v.avg_cut_frequency) || 0; return (
{distEntries.length > 0 && (
{distEntries.map(([k, val]) => (
{k}
{pct(val)}
))}
)} {safeArr(v.color_tone_keywords).length > 0 && (
{v.color_tone_keywords.map((c, i) => {c})}
)}
{v.visual_model_prompt && (
{v.visual_model_prompt}
)} {v.camera_language_notes && (
{v.camera_language_notes}
)}
); }; // ============== 6. EmotionCurveSVG ============== const EmotionCurveSVG = ({ emotion }) => { const e = emotion || {}; const points = safeArr(e.emotion_points); if (points.length === 0) return (
); // 轨迹归一化:横轴 = timestamp,纵轴 = intensity const W = 720, H = 140, P = 24; const ts = points.map(p => Number(p.timestamp_sec) || 0); const tsMax = Math.max(...ts, 1); const polyPoints = points.map((p, i) => { const x = P + (Number(p.timestamp_sec) / tsMax) * (W - 2 * P); const y = H - P - (Number(p.intensity) || 0) * (H - 2 * P); return `${x.toFixed(1)},${y.toFixed(1)}`; }).join(" "); return (
{e.emotion_arc_summary &&
{e.emotion_arc_summary}
}
{/* gridlines */} {[0.25, 0.5, 0.75].map(g => { const y = H - P - g * (H - 2 * P); return ; })} {/* baseline */} {/* curve */} {/* dots */} {points.map((p, i) => { const x = P + (Number(p.timestamp_sec) / tsMax) * (W - 2 * P); const y = H - P - (Number(p.intensity) || 0) * (H - 2 * P); return ( {`${fmtSec(p.timestamp_sec)} · ${p.emotion} · ${pct(p.intensity)}`} ); })}
{points.map((p, i) => ( {fmtSec(p.timestamp_sec)} · {p.emotion} · {pct(p.intensity)} {p.is_climax && " ★"} ))}
); }; // ============== 7. CommentInsightsCards ============== const CommentInsightsCards = ({ comments }) => { const c = comments || {}; return (
    {safeArr(c.high_freq_pain_points).map((p, i) =>
  • {p}
  • )} {safeArr(c.high_freq_pain_points).length === 0 &&
  • }
    {safeArr(c.question_blind_spots).map((p, i) =>
  • {p}
  • )} {safeArr(c.question_blind_spots).length === 0 &&
  • }
    {safeArr(c.negative_feedback_notes).map((p, i) =>
  • {p}
  • )} {safeArr(c.negative_feedback_notes).length === 0 &&
  • }
              {safeStr(c.viral_potential_assessment) || "—"}
            
{safeArr(c.insights).length > 0 && ( {c.insights.map((ins, i) => ( ))}
类型描述可执行建议置信度
{ins.insight_type || "—"} {ins.description || "—"} {ins.actionable || "—"} {pct(ins.confidence)}
)}
); }; // ============== 8. AidsTable ============== const AidsTable = ({ aids }) => { const a = aids || {}; if (!a.applicable) return (
{a.overall_comment || "本视频不适合用 AIDIS 框架(A·Attention / I·Interest / D·Desire / I·Information / S·Solution)拆解。"}
); const stages = safeArr(a.stages); return (
{a.overall_comment &&
{a.overall_comment}
} {stages.map((s, i) => ( ))}
阶段占比达标文案片段说明
{s.stage_label || s.stage}
{pct(s.char_percent)}
{s.meets_criterion ? : } {s.script_excerpt || "—"} {s.notes || "—"}
); }; // ============== 9. RawScriptViewer ============== const RawScriptViewer = ({ raw, timed }) => { const [tab, setTab] = useState("plain"); const hasTimed = safeArr(timed).length > 0; return (
{hasTimed && ( )}
{tab === "plain" ? (
{safeStr(raw) || "—"}
) : ( {timed.map((seg, i) => ( ))}
时间内容
{fmtSec(seg.start_sec)}–{fmtSec(seg.end_sec)} {seg.text}
)}
); }; // ============== 10. RewriteSection ============== const RewriteSection = ({ rewrite }) => { const r = rewrite || {}; if (!r.strategy || r.strategy === "not_applicable") return (
{r.skipped_reason || "本视频未生成二创建议(房产相关度判定为无迁移价值)。"}
); return (
{safeStr(r.rewritten_script) || "—"}
    {safeArr(r.preserved_elements).map((p, i) =>
  • {p}
  • )} {safeArr(r.preserved_elements).length === 0 &&
  • }
    {safeArr(r.changed_elements).map((p, i) =>
  • {p}
  • )} {safeArr(r.changed_elements).length === 0 &&
  • }
画面级 AIGC 提示词与时长打包属于复刻流程(chunk_regroup → r_chunks),见 /remix 复刻入口; aspect_ratio: {r.aspect_ratio || "9:16"}{r.negative_prompt ? ` · negative: ${r.negative_prompt}` : ""}
); }; // ============== 11. VideoGenSection ============== const VideoGenSection = ({ video }) => { const v = video || {}; if (!v.status || v.status === "pending") return null; const shots = safeArr(v.shots); return (
{v.status} } /> {v.error &&
{v.error}
} {v.final_video_path && (
); }; // ============== 12.0 RewriteTextCell · 段级二创可编辑单元格 (M5 加) ============== // 显示 rewrite_text;点"编辑"切到 textarea;保存调 PATCH /jobs/{id}/chunks/{cid}/rewrite-text const RewriteTextCell = ({ jobId, chunkId, value, editedAt, onSaved }) => { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(value || ""); const [busy, setBusy] = useState(false); const [err, setErr] = useState(""); React.useEffect(() => { setDraft(value || ""); }, [value]); if (!editing) { return (
{value ?
{value}
: }
{editedAt && ( · 已人工编辑 )}
); } const save = async () => { const trimmed = (draft || "").trim(); if (!trimmed) { setErr("不能为空"); return; } setBusy(true); setErr(""); try { await API.jobs.patchRewriteText(jobId, chunkId, trimmed); setEditing(false); onSaved && onSaved(); } catch (e) { setErr(e.message || String(e)); } finally { setBusy(false); } }; return (