Skip to content

Commit f168fa0

Browse files
committed
feat(asr): REQ-268 离线精修裁决面板与详情入口
1 parent dad00da commit f168fa0

2 files changed

Lines changed: 320 additions & 0 deletions

File tree

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
/**
2+
* SecondPassPanel — 会话全量离线精修(第二遍)裁决面板(v0.20.2 / REQ-268)。
3+
*
4+
* @ai-context: 会话结束后把 S4 落盘音频全窗重跑 SenseVoice(后端 second_pass_* 命令),
5+
* 本面板呈现「现网基线 → 离线精修」段级 diff 供逐条/批量采纳·回退;
6+
* 原料 session_segments 永不变(可逆契约——采纳只影响读取/转笔记轴,
7+
* 原料视图始终显示原文,本面板即裁决与复核面)。
8+
* @ai-context: 运行态为内存注册表(崩溃后 pending 草稿可重跑清理)——面板以
9+
* session:refine2:* 事件驱动刷新(progress/done/failed/aborted)。
10+
*/
11+
import { useCallback, useEffect, useState } from "react";
12+
import { invoke } from "@tauri-apps/api/core";
13+
import { listen } from "@tauri-apps/api/event";
14+
15+
/** RefineDraft(Rust serde 默认 snake_case 字段——与后端契约一致) */
16+
export interface RefineDraftView {
17+
id: number;
18+
session_id: number;
19+
origin: string;
20+
start_ms: number;
21+
end_ms: number;
22+
base_text: string;
23+
refined_text: string;
24+
source: string;
25+
confidence: number | null;
26+
similarity: number | null;
27+
status: "pending" | "adopted" | "rejected";
28+
created_at: number;
29+
decided_at: number | null;
30+
}
31+
32+
export interface SecondPassView {
33+
running: boolean;
34+
total: number;
35+
pending: number;
36+
adopted: number;
37+
rejected: number;
38+
items: RefineDraftView[];
39+
}
40+
41+
interface Props {
42+
sessionId: number;
43+
/** 数据变更回调(采纳后刷新外部——转笔记装载为服务端合成,无需重拉) */
44+
onChanged?: () => void;
45+
onClose: () => void;
46+
}
47+
48+
const overlayStyle: React.CSSProperties = {
49+
position: "fixed",
50+
inset: 0,
51+
background: "rgba(0,0,0,.45)",
52+
zIndex: 1000,
53+
display: "flex",
54+
alignItems: "center",
55+
justifyContent: "center",
56+
};
57+
const cardStyle: React.CSSProperties = {
58+
background: "#fff",
59+
borderRadius: 12,
60+
width: 720,
61+
maxWidth: "92vw",
62+
maxHeight: "82vh",
63+
overflow: "auto",
64+
padding: 16,
65+
fontSize: 13,
66+
};
67+
const btn: React.CSSProperties = { padding: "5px 10px", cursor: "pointer", fontSize: 12, borderRadius: 6 };
68+
const okBtn: React.CSSProperties = { ...btn, background: "#0d9488", color: "#fff", border: "none" };
69+
const ghostBtn: React.CSSProperties = { ...btn, background: "#fff", border: "1px solid #e5e7eb", color: "#374151" };
70+
71+
/** 相对会话起点的 mm:ss(面板时间轴标签;纯本地格式化,避免时区陷阱) */
72+
function fmtClock(ms: number): string {
73+
const totalSec = Math.floor(ms / 1000);
74+
const m = Math.floor(totalSec / 60) % 60;
75+
const s = totalSec % 60;
76+
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
77+
}
78+
79+
export default function SecondPassPanel({ sessionId, onChanged, onClose }: Props) {
80+
const [view, setView] = useState<SecondPassView | null>(null);
81+
const [err, setErr] = useState("");
82+
const [busy, setBusy] = useState(false);
83+
const [runningMsg, setRunningMsg] = useState("");
84+
85+
const reload = useCallback(async () => {
86+
try {
87+
setErr("");
88+
const v = await invoke<SecondPassView>("second_pass_list", { sessionId });
89+
setView(v);
90+
if (v.running) setRunningMsg("第二遍进行中…");
91+
} catch (e) {
92+
setErr(`读取失败: ${e}`);
93+
}
94+
}, [sessionId]);
95+
96+
useEffect(() => {
97+
void reload();
98+
}, [reload]);
99+
100+
// session:refine2:* 事件驱动(进度/完成/失败/中止)
101+
useEffect(() => {
102+
const unlisteners: Promise<() => void>[] = [
103+
listen<{ sessionId: number; done: number; total: number }>(
104+
"session:refine2:progress",
105+
(e) => {
106+
if (e.payload.sessionId !== sessionId) return;
107+
setRunningMsg(`第二遍转写中 ${e.payload.done}/${e.payload.total}…`);
108+
},
109+
),
110+
listen<{ sessionId: number; proposals: number }>("session:refine2:done", (e) => {
111+
if (e.payload.sessionId !== sessionId) return;
112+
setRunningMsg("");
113+
void reload();
114+
onChanged?.();
115+
}),
116+
listen<{ sessionId: number; error: string }>("session:refine2:failed", (e) => {
117+
if (e.payload.sessionId !== sessionId) return;
118+
setRunningMsg("");
119+
setErr(`第二遍失败: ${e.payload.error}`);
120+
void reload();
121+
}),
122+
listen<number>("session:refine2:aborted", (e) => {
123+
if (e.payload !== sessionId) return;
124+
setRunningMsg("已取消(已完成窗口保留为待裁决草稿)");
125+
void reload();
126+
}),
127+
];
128+
return () => {
129+
unlisteners.forEach((p) => void p.then((fn) => fn()));
130+
};
131+
}, [sessionId, reload, onChanged]);
132+
133+
const start = async () => {
134+
setBusy(true);
135+
setErr("");
136+
try {
137+
await invoke("second_pass_start", { sessionId });
138+
setRunningMsg("启动中…");
139+
} catch (e) {
140+
setErr(String(e));
141+
} finally {
142+
setBusy(false);
143+
}
144+
};
145+
146+
const cancel = async () => {
147+
setBusy(true);
148+
try {
149+
await invoke("second_pass_cancel", { sessionId });
150+
setRunningMsg("正在停止…");
151+
} catch (e) {
152+
setErr(String(e));
153+
} finally {
154+
setBusy(false);
155+
}
156+
};
157+
158+
const decide = async (draftId: number, adopt: boolean) => {
159+
try {
160+
await invoke("second_pass_decide", { draftId, adopt });
161+
onChanged?.();
162+
await reload();
163+
} catch (e) {
164+
setErr(`裁决失败: ${e}`);
165+
}
166+
};
167+
168+
/** 批量采纳全部待裁决草稿(逐条裁决——单条失败即停并提示) */
169+
const adoptAllPending = async () => {
170+
if (!view) return;
171+
for (const d of view.items.filter((x) => x.status === "pending")) {
172+
await decide(d.id, true);
173+
}
174+
};
175+
176+
/** 全部回退(恢复原料轴;adopted → rejected 双向可翻转) */
177+
const rejectAllAdopted = async () => {
178+
if (!view) return;
179+
for (const d of view.items.filter((x) => x.status === "adopted")) {
180+
await decide(d.id, false);
181+
}
182+
};
183+
184+
const pending = view?.pending ?? 0;
185+
const adopted = view?.adopted ?? 0;
186+
187+
return (
188+
<div style={overlayStyle} onClick={onClose}>
189+
<div style={cardStyle} onClick={(e) => e.stopPropagation()}>
190+
<div style={{ display: "flex", alignItems: "center", marginBottom: 10 }}>
191+
<h3 style={{ margin: 0, fontSize: 15 }}>⚡ 离线精修(全量第二遍)</h3>
192+
<span style={{ fontSize: 11, color: "#6b7280", marginLeft: 8 }}>
193+
S4 音频全窗 SenseVoice → 段级 diff → 采纳/回退(原料永不变)
194+
</span>
195+
<button style={{ ...ghostBtn, marginLeft: "auto" }} onClick={onClose}>
196+
关闭
197+
</button>
198+
</div>
199+
200+
{runningMsg && (
201+
<div style={{ fontSize: 12, color: "#b45309", background: "#fffbeb", border: "1px solid #f59e0b", borderRadius: 6, padding: "6px 10px", marginBottom: 8 }}>
202+
⏳ {runningMsg}
203+
</div>
204+
)}
205+
{err && (
206+
<div style={{ fontSize: 12, color: "#dc2626", background: "#fef2f2", border: "1px solid #fca5a5", borderRadius: 6, padding: "6px 10px", marginBottom: 8 }}>
207+
{err}
208+
</div>
209+
)}
210+
211+
<div style={{ display: "flex", gap: 6, alignItems: "center", marginBottom: 10 }}>
212+
{!view?.running ? (
213+
<>
214+
<button style={okBtn} onClick={() => void start()} disabled={busy}>
215+
▶ 开始第二遍
216+
</button>
217+
<button style={ghostBtn} onClick={() => void adoptAllPending()} disabled={pending === 0 || busy}>
218+
全部采纳({pending})
219+
</button>
220+
<button style={ghostBtn} onClick={() => void rejectAllAdopted()} disabled={adopted === 0 || busy}>
221+
全部回退({adopted})
222+
</button>
223+
</>
224+
) : (
225+
<button style={{ ...ghostBtn, color: "#b45309" }} onClick={() => void cancel()} disabled={busy}>
226+
⏹ 取消
227+
</button>
228+
)}
229+
<span style={{ fontSize: 11, color: "#9ca3af", marginLeft: "auto" }}>
230+
待裁决 {pending} · 已采纳 {adopted} · 已回退 {view?.rejected ?? 0}
231+
</span>
232+
</div>
233+
234+
{view === null ? (
235+
<p style={{ color: "#9ca3af", fontSize: 12 }}>加载中…</p>
236+
) : view.total === 0 ? (
237+
<p style={{ color: "#9ca3af", fontSize: 12 }}>
238+
{view.running ? "任务已启动,等待首个窗口…" : "暂无精修草稿——点击「开始第二遍」用 S4 音频全窗重跑 SenseVoice,有实质差异的窗口会生成本页草稿。"}
239+
</p>
240+
) : (
241+
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
242+
{view.items.map((d) => {
243+
const unchanged = d.base_text === d.refined_text;
244+
return (
245+
<div
246+
key={d.id}
247+
style={{
248+
border: "1px solid #e5e7eb",
249+
borderRadius: 8,
250+
padding: "8px 10px",
251+
background: d.status === "adopted" ? "#ecfdf5" : d.status === "rejected" ? "#f9fafb" : "#fff",
252+
}}
253+
>
254+
<div style={{ display: "flex", gap: 8, alignItems: "center", marginBottom: 4 }}>
255+
<span style={{ fontSize: 11, color: "#9ca3af" }}>
256+
{fmtClock(d.start_ms)} – {fmtClock(d.end_ms)}
257+
</span>
258+
{d.similarity != null && (
259+
<span style={{ fontSize: 11, color: "#6b7280" }}>相似度 {(d.similarity * 100).toFixed(0)}%</span>
260+
)}
261+
<span
262+
style={{
263+
fontSize: 11,
264+
marginLeft: "auto",
265+
color: d.status === "adopted" ? "#047857" : d.status === "rejected" ? "#9ca3af" : "#b45309",
266+
}}
267+
>
268+
{d.status === "adopted" ? "已采纳 ✓" : d.status === "rejected" ? "已回退" : unchanged ? "" : "待裁决"}
269+
</span>
270+
</div>
271+
<div style={{ fontSize: 12.5, color: "#6b7280", marginBottom: 3, textDecoration: "line-through", opacity: 0.75 }}>
272+
{d.base_text || "(原链路无内容)"}
273+
</div>
274+
<div style={{ fontSize: 12.5, color: "#111827" }}>{d.refined_text}</div>
275+
{d.status === "pending" && (
276+
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
277+
<button style={{ ...okBtn, fontSize: 11 }} onClick={() => void decide(d.id, true)}>
278+
✓ 采纳
279+
</button>
280+
<button style={{ ...ghostBtn, fontSize: 11 }} onClick={() => void decide(d.id, false)}>
281+
回退
282+
</button>
283+
</div>
284+
)}
285+
{d.status === "adopted" && (
286+
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
287+
<button style={{ ...ghostBtn, fontSize: 11 }} onClick={() => void decide(d.id, false)}>
288+
↩ 撤销采纳(恢复原文)
289+
</button>
290+
</div>
291+
)}
292+
</div>
293+
);
294+
})}
295+
</div>
296+
)}
297+
<p style={{ fontSize: 11, color: "#9ca3af", marginTop: 10 }}>
298+
说明:采纳的替换在「笔记预览 / 转为笔记」时生效(服务端合成);原料视图恒显示原始转写便于复核。批量采纳为逐条裁决,失败即停。
299+
</p>
300+
</div>
301+
</div>
302+
);
303+
}

‎app/src/components/SessionDetailPanel.tsx‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { listen } from "@tauri-apps/api/event";
1919
import BoxSelectOverlay from "../components/BoxSelectOverlay";
2020
import ImageGallery from "../components/ImageGallery";
2121
import NotePreviewView from "../components/NotePreviewView";
22+
import SecondPassPanel from "../components/SecondPassPanel";
2223
import SpeakerSwitchCard from "../components/SpeakerSwitchCard";
2324
import type { GlossaryTerm, QualityReport, SessionDetail, SessionOcrBlock } from "../types";
2425
import { fmtMs } from "../utils/fmt";
@@ -92,6 +93,9 @@ export default function SessionDetailPanel({ detail, fusing, degradedBanner, onT
9293
const [renameValue, setRenameValue] = useState("");
9394
const [renameErr, setRenameErr] = useState("");
9495
const [renameBusy, setRenameBusy] = useState(false);
96+
// v0.20.2(REQ-268):离线精修(第二遍)裁决面板显隐(会话切换即关)
97+
const [showPass2, setShowPass2] = useState(false);
98+
useEffect(() => setShowPass2(false), [sessionId]);
9599

96100
// toast 定时器卸载清理(防卸载后 setState)
97101
useEffect(
@@ -397,6 +401,16 @@ export default function SessionDetailPanel({ detail, fusing, degradedBanner, onT
397401
>
398402
{refining ? "精修中…" : "🔬 课后精修"}
399403
</button>
404+
{/* v0.20.2(REQ-268):全量离线精修(第二遍)——仅已结束非图文会话
405+
(需要 S4 落盘音频);面板内预览/采纳/回退,原料视图恒原文 */}
406+
{detail.session.status === "finished" && detail.session.kind !== "photo" && (
407+
<button
408+
style={{ ...btn, borderRadius: 6, border: "1px solid #7c3aed", background: "#f5f3ff", color: "#6d28d9" }}
409+
onClick={() => setShowPass2(true)}
410+
>
411+
⚡ 离线精修
412+
</button>
413+
)}
400414
</div>
401415
{refineMsg && (
402416
<div style={{ fontSize: 11, color: refining ? "#b45309" : "#0d9488", marginBottom: 6 }}>
@@ -599,6 +613,9 @@ export default function SessionDetailPanel({ detail, fusing, degradedBanner, onT
599613
<ImageGallery sessionId={sessionId} />
600614
</>
601615
)}
616+
617+
{/* v0.20.2(REQ-268):离线精修(第二遍)裁决面板 */}
618+
{showPass2 && <SecondPassPanel sessionId={sessionId} onClose={() => setShowPass2(false)} />}
602619
</>
603620
);
604621
}

0 commit comments

Comments
 (0)