Skip to content

Commit d2de787

Browse files
committed
feat(sessions): 会话管理台 UI——徽标筛选批量与互跳
1 parent 0867749 commit d2de787

7 files changed

Lines changed: 1021 additions & 356 deletions

File tree

‎app/src/App.tsx‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ function App() {
2929
const [page, setPage] = useState<Page>("classroom");
3030
// 2026-08 A4:跨页直达目标会话(课堂助手融合完成 → 会话页自动打开详情)
3131
const [focusSessionId, setFocusSessionId] = useState<number | null>(null);
32+
// v0.7.1:跨页直达目标笔记(会话页"查看笔记" → 笔记页自动选中并滚动可见)
33+
const [focusNoteId, setFocusNoteId] = useState<number | null>(null);
3234
// 全局采集状态(ADR-007:与页面解耦,徽标常驻导航栏)
3335
const [capturing, setCapturing] = useState(false);
3436
const [recovering, setRecovering] = useState(false);
@@ -163,10 +165,25 @@ function App() {
163165
/>
164166
</div>
165167
<div style={{ flex: 1, display: page === "sessions" ? "block" : "none", overflow: "hidden" }}>
166-
<SessionsPage focusSessionId={focusSessionId} />
168+
{/* v0.7.1:active 驱动列表刷新(display:none 挂载不刷新根治)+ 查看笔记跨页直达 */}
169+
<SessionsPage
170+
focusSessionId={focusSessionId}
171+
active={page === "sessions"}
172+
onOpenNote={(id) => {
173+
setFocusNoteId(id);
174+
setPage("notes");
175+
}}
176+
/>
167177
</div>
168178
<div style={{ flex: 1, display: page === "notes" ? "block" : "none", overflow: "hidden" }}>
169-
<NotesPage />
179+
{/* v0.7.1:focusNoteId 定位 + 来源会话反向跳转(与课堂助手 onOpenSessions 同模式) */}
180+
<NotesPage
181+
focusNoteId={focusNoteId}
182+
onOpenSessions={(id) => {
183+
setFocusSessionId(id);
184+
setPage("sessions");
185+
}}
186+
/>
170187
</div>
171188
</main>
172189
</div>
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
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

Comments
 (0)