|
| 1 | +/** |
| 2 | + * SessionDetailPanel — 会话详情面板(原料 / 产物 / 笔记预览三视图)。 |
| 3 | + * |
| 4 | + * @ai-context: v0.7.1 自 SessionsPage 拆出(豁免清单登记拆分计划)——质量报告、 |
| 5 | + * 大纲、视图模式为面板内部状态(仅依赖 sessionId),与列表页解耦, |
| 6 | + * 列表页聚焦管理操作(筛选/批量/转化)。 |
| 7 | + * @ai-context: REQ-031(融合停止异步化):fusing 时显示"融合中"标记, |
| 8 | + * session:fused 到达后父层自动刷新 detail 重挂本面板。 |
| 9 | + * @ai-context: REQ-080 降级分级:live:asr-degraded 一次性横幅(父层透传)。 |
| 10 | + */ |
| 11 | +import { useEffect, useState } from "react"; |
| 12 | +import { invoke } from "@tauri-apps/api/core"; |
| 13 | +import ArtifactView from "../components/ArtifactView"; |
| 14 | +import ImageGallery from "../components/ImageGallery"; |
| 15 | +import NotePreviewView from "../components/NotePreviewView"; |
| 16 | +import type { OutlineEntry, QualityReport, SessionDetail } from "../types"; |
| 17 | +import { fmtMs } from "../utils/fmt"; |
| 18 | + |
| 19 | +const btn: React.CSSProperties = { padding: "5px 10px", cursor: "pointer", fontSize: 12 }; |
| 20 | + |
| 21 | +const SOURCE_LABEL: Record<string, string> = { |
| 22 | + subtitle: "字幕", |
| 23 | + asr: "语音", |
| 24 | + fused: "融合", |
| 25 | +}; |
| 26 | +const STATUS_LABEL: Record<string, string> = { |
| 27 | + recording: "录制中", |
| 28 | + finished: "已完成", |
| 29 | + failed: "异常中断", |
| 30 | +}; |
| 31 | + |
| 32 | +interface Props { |
| 33 | + detail: SessionDetail; |
| 34 | + /** 本会话是否融合中(父层 fusingId === detail.session.id) */ |
| 35 | + fusing: boolean; |
| 36 | + /** 关键降级一次性横幅(null=无) */ |
| 37 | + degradedBanner: string | null; |
| 38 | + /** 转为笔记(父层负责 toast 反馈与列表刷新) */ |
| 39 | + onToNote: (id: number) => void; |
| 40 | + /** 删除会话(父层负责确认/反馈/刷新) */ |
| 41 | + onRemove: (id: number) => void; |
| 42 | +} |
| 43 | + |
| 44 | +export default function SessionDetailPanel({ detail, fusing, degradedBanner, onToNote, onRemove }: Props) { |
| 45 | + // v0.5.0 M7(REQ-052)+ v0.6.0 M6(REQ-081):三视图 |
| 46 | + const [viewMode, setViewMode] = useState<"raw" | "artifact" | "preview">("raw"); |
| 47 | + // M6(REQ-076):质量报告(可信度总览卡片) |
| 48 | + const [quality, setQuality] = useState<QualityReport | null>(null); |
| 49 | + // M6(REQ-077):大纲(产物视图侧边导航) |
| 50 | + const [outline, setOutline] = useState<OutlineEntry[]>([]); |
| 51 | + const sessionId = detail.session.id; |
| 52 | + |
| 53 | + // 质量报告 + 大纲随详情加载(失败不阻断详情展示) |
| 54 | + useEffect(() => { |
| 55 | + setQuality(null); |
| 56 | + setOutline([]); |
| 57 | + setViewMode("raw"); |
| 58 | + void invoke<QualityReport>("session_quality_report", { id: sessionId }) |
| 59 | + .then(setQuality) |
| 60 | + .catch(() => undefined); |
| 61 | + void invoke<OutlineEntry[]>("session_outline", { id: sessionId }) |
| 62 | + .then(setOutline) |
| 63 | + .catch(() => undefined); |
| 64 | + }, [sessionId]); |
| 65 | + |
| 66 | + return ( |
| 67 | + <> |
| 68 | + {degradedBanner && ( |
| 69 | + <div |
| 70 | + style={{ |
| 71 | + fontSize: 12, |
| 72 | + color: "#b45309", |
| 73 | + background: "#fffbeb", |
| 74 | + border: "1px solid #f59e0b", |
| 75 | + borderRadius: 6, |
| 76 | + padding: "6px 10px", |
| 77 | + marginBottom: 8, |
| 78 | + }} |
| 79 | + > |
| 80 | + ⚠ {degradedBanner}(恢复后自动消失) |
| 81 | + </div> |
| 82 | + )} |
| 83 | + |
| 84 | + <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 12 }}> |
| 85 | + <h2 style={{ margin: 0, fontSize: 16 }}>{detail.session.title}</h2> |
| 86 | + <span style={{ fontSize: 11, color: "#6b7280" }}> |
| 87 | + {STATUS_LABEL[detail.session.status]} · {detail.segments.length} 段转写 ·{" "} |
| 88 | + {detail.ocr_blocks.length} 块画面 |
| 89 | + </span> |
| 90 | + {fusing && ( |
| 91 | + <span |
| 92 | + style={{ |
| 93 | + fontSize: 11, |
| 94 | + fontWeight: 600, |
| 95 | + color: "#b45309", |
| 96 | + background: "#fffbeb", |
| 97 | + border: "1px solid #f59e0b", |
| 98 | + borderRadius: 10, |
| 99 | + padding: "2px 8px", |
| 100 | + }} |
| 101 | + > |
| 102 | + ⏳ 融合中(字幕/语音轴将自动升级) |
| 103 | + </span> |
| 104 | + )} |
| 105 | + <div style={{ marginLeft: "auto", display: "flex", gap: 6 }}> |
| 106 | + <button |
| 107 | + style={{ ...btn, background: "#0d9488", color: "#fff", border: "none", borderRadius: 6 }} |
| 108 | + onClick={() => onToNote(sessionId)} |
| 109 | + > |
| 110 | + 📝 转为笔记 |
| 111 | + </button> |
| 112 | + <button style={btn} onClick={() => onRemove(sessionId)}> |
| 113 | + 删除 |
| 114 | + </button> |
| 115 | + </div> |
| 116 | + </div> |
| 117 | + |
| 118 | + {/* M6(REQ-076):可信度总览卡片 */} |
| 119 | + {quality && ( |
| 120 | + <div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 8 }}> |
| 121 | + {( |
| 122 | + [ |
| 123 | + ["低置信段", quality.low_confidence_count, quality.low_confidence_count > 0 ? "#dc2626" : "#6b7280"], |
| 124 | + ["OCR 低分", quality.low_score_ocr_count, "#b45309"], |
| 125 | + ["unknown 区", quality.unknown_region_count, "#7c3aed"], |
| 126 | + ["AI 复核候选", quality.ai_candidate_count, "#2563eb"], |
| 127 | + ] as const |
| 128 | + ).map(([label, count, color]) => ( |
| 129 | + <span |
| 130 | + key={label} |
| 131 | + style={{ |
| 132 | + fontSize: 11, |
| 133 | + color, |
| 134 | + background: "#f9fafb", |
| 135 | + border: "1px solid #e5e7eb", |
| 136 | + borderRadius: 10, |
| 137 | + padding: "2px 8px", |
| 138 | + }} |
| 139 | + > |
| 140 | + {label} {count} |
| 141 | + </span> |
| 142 | + ))} |
| 143 | + {quality.low_confidence_segments.length > 0 && ( |
| 144 | + <details style={{ fontSize: 11, color: "#6b7280" }}> |
| 145 | + <summary style={{ cursor: "pointer" }}>低置信列表({quality.low_confidence_segments.length})</summary> |
| 146 | + {quality.low_confidence_segments.map((s) => ( |
| 147 | + <div key={s.segment_id} style={{ marginTop: 3 }}> |
| 148 | + [{fmtMs(s.start_ms)}] {s.text}({s.confidence.toFixed(2)}) |
| 149 | + </div> |
| 150 | + ))} |
| 151 | + </details> |
| 152 | + )} |
| 153 | + </div> |
| 154 | + )} |
| 155 | + |
| 156 | + {/* 三视图切换(原料 / 产物 / 笔记预览——REQ-081) */} |
| 157 | + <div style={{ display: "flex", gap: 6, marginBottom: 10 }}> |
| 158 | + {( |
| 159 | + [ |
| 160 | + ["raw", "原料视图"], |
| 161 | + ["artifact", "产物视图"], |
| 162 | + ["preview", "笔记预览"], |
| 163 | + ] as const |
| 164 | + ).map(([mode, label]) => ( |
| 165 | + <button |
| 166 | + key={mode} |
| 167 | + onClick={() => setViewMode(mode)} |
| 168 | + style={{ |
| 169 | + ...btn, |
| 170 | + borderRadius: 6, |
| 171 | + border: viewMode === mode ? "1px solid #0d9488" : "1px solid #e5e7eb", |
| 172 | + background: viewMode === mode ? "#ccfbf1" : "#fff", |
| 173 | + color: viewMode === mode ? "#0f766e" : "#374151", |
| 174 | + }} |
| 175 | + > |
| 176 | + {label} |
| 177 | + </button> |
| 178 | + ))} |
| 179 | + </div> |
| 180 | + |
| 181 | + {viewMode === "preview" ? ( |
| 182 | + <NotePreviewView sessionId={sessionId} /> |
| 183 | + ) : viewMode === "artifact" ? ( |
| 184 | + <div style={{ display: "flex", gap: 12 }}> |
| 185 | + {/* M6(REQ-077):大纲侧边导航(点击跳转时间轴) */} |
| 186 | + {outline.length > 0 && ( |
| 187 | + <div style={{ width: 180, flexShrink: 0, borderRight: "1px solid #e5e7eb", paddingRight: 10 }}> |
| 188 | + <div style={{ fontSize: 12, fontWeight: 600, color: "#0f766e", marginBottom: 6 }}>📑 大纲</div> |
| 189 | + {outline.map((o, i) => ( |
| 190 | + <div |
| 191 | + key={i} |
| 192 | + onClick={() => |
| 193 | + document.getElementById(`ocr-${sessionId}-${o.time_ms}`)?.scrollIntoView({ block: "center" }) |
| 194 | + } |
| 195 | + style={{ fontSize: 12, color: "#374151", cursor: "pointer", padding: "3px 0", borderBottom: "1px dashed #f3f4f6" }} |
| 196 | + title={`${fmtMs(o.time_ms)}`} |
| 197 | + > |
| 198 | + <span style={{ color: "#9ca3af", marginRight: 4, fontVariantNumeric: "tabular-nums" }}> |
| 199 | + {fmtMs(o.time_ms)} |
| 200 | + </span> |
| 201 | + {o.text} |
| 202 | + </div> |
| 203 | + ))} |
| 204 | + </div> |
| 205 | + )} |
| 206 | + <div style={{ flex: 1, minWidth: 0 }}> |
| 207 | + <ArtifactView sessionId={sessionId} /> |
| 208 | + </div> |
| 209 | + </div> |
| 210 | + ) : ( |
| 211 | + <> |
| 212 | + {/* 转写时间轴(字幕为主,语音/融合弱化;段 id 锚点供大纲/搜索跳转) */} |
| 213 | + <h3 style={{ fontSize: 13, margin: "12px 0 6px" }}>转写时间轴</h3> |
| 214 | + <div style={{ display: "flex", flexDirection: "column", gap: 4 }}> |
| 215 | + {detail.segments.length === 0 && <p style={{ fontSize: 12, color: "#9ca3af" }}>本会话无转写段</p>} |
| 216 | + {detail.segments.map((seg) => ( |
| 217 | + <div key={seg.id} id={`seg-${sessionId}-${seg.id}`} style={{ display: "flex", gap: 8, alignItems: "baseline" }}> |
| 218 | + <span style={{ fontSize: 11, color: "#9ca3af", width: 70, flexShrink: 0, fontVariantNumeric: "tabular-nums" }}> |
| 219 | + {fmtMs(seg.start_ms)} – {fmtMs(seg.end_ms)} |
| 220 | + </span> |
| 221 | + <span style={{ fontSize: 11, flexShrink: 0, color: seg.source === "subtitle" ? "#0d9488" : "#9ca3af", width: 36 }}> |
| 222 | + {SOURCE_LABEL[seg.source] ?? seg.source} |
| 223 | + </span> |
| 224 | + <span style={{ fontSize: 13, color: seg.source === "fused" ? "#b45309" : "#374151" }}>{seg.text}</span> |
| 225 | + </div> |
| 226 | + ))} |
| 227 | + </div> |
| 228 | + |
| 229 | + {/* 画面要点 */} |
| 230 | + <h3 style={{ fontSize: 13, margin: "16px 0 6px" }}>画面要点(OCR)</h3> |
| 231 | + <div style={{ display: "flex", flexDirection: "column", gap: 3 }}> |
| 232 | + {detail.ocr_blocks.length === 0 && <p style={{ fontSize: 12, color: "#9ca3af" }}>本会话无画面识别内容</p>} |
| 233 | + {detail.ocr_blocks.map((b) => ( |
| 234 | + <div key={b.id} id={`ocr-${sessionId}-${b.timestamp_ms}`} style={{ fontSize: 12, color: "#4b5563" }}> |
| 235 | + <span style={{ color: "#9ca3af", fontVariantNumeric: "tabular-nums" }}>[{fmtMs(b.timestamp_ms)}]</span>{" "} |
| 236 | + {b.text} |
| 237 | + {b.region === "subtitle" && <span style={{ color: "#0d9488", marginLeft: 4 }}>字幕</span>} |
| 238 | + </div> |
| 239 | + ))} |
| 240 | + </div> |
| 241 | + |
| 242 | + {/* 参考图集(v0.5.0 M6:REQ-051 三层图结构) */} |
| 243 | + <h3 style={{ fontSize: 13, margin: "16px 0 6px" }}>参考图集</h3> |
| 244 | + <ImageGallery sessionId={sessionId} /> |
| 245 | + </> |
| 246 | + )} |
| 247 | + </> |
| 248 | + ); |
| 249 | +} |
0 commit comments