Skip to content

Commit 37ce5c1

Browse files
committed
fix(session): 图片时间戳数值排序+刷新后暂停态还原
- image_store list_images/list_crops 字典序跨位数错乱(999>1000、11000<2000)—— 用户反馈'最近画面未按时间轴排列',改为解析文件名数值升序(解析失败回退字典序兜底) - live_session_status 暴露 paused——recording/paused 事件只发一次,页面刷新后 右侧面板挂载晚于事件,靠拉取还原状态机(防'正在初始化…'卡死) - 单测 +2(跨位数数值排序回归);1066 测试全绿
1 parent 0834b38 commit 37ce5c1

7 files changed

Lines changed: 90 additions & 7 deletions

File tree

‎app/src-tauri/src/commands_live.rs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ pub struct LiveSessionStatus {
2525
pub session_id: Option<i64>,
2626
/// P3:引擎预热是否已就绪(前端"开始即录"提示)
2727
pub prepared: bool,
28+
/// 2026-08 修复:是否处于暂停(挂载拉取恢复右侧面板状态机用——
29+
/// recording 事件只发一次,刷新/重进页面后需靠此字段还原 phase)
30+
pub paused: bool,
2831
}
2932

3033
/// 启动实时捕获会话(REQ-007~012 汇总入口)。
@@ -126,7 +129,7 @@ fn trigger_auto_refine(state: &AppState, session_id: i64) {
126129
});
127130
}
128131

129-
/// 查询实时会话状态(活动会话 id + 预热就绪标记)。
132+
/// 查询实时会话状态(活动会话 id + 预热就绪标记 + 暂停标记)。
130133
#[tauri::command]
131134
pub fn live_session_status(state: State<'_, AppState>) -> LiveSessionStatus {
132135
LiveSessionStatus {
@@ -136,6 +139,7 @@ pub fn live_session_status(state: State<'_, AppState>) -> LiveSessionStatus {
136139
state.live_session.prepare_status(),
137140
crate::live_session_prepare::PrepareStatus::Ready
138141
),
142+
paused: state.live_session.is_paused(),
139143
}
140144
}
141145

‎app/src-tauri/src/image_store.rs‎

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,11 @@ impl SessionImageStore {
136136
Ok(format!("crop/{}", name))
137137
}
138138

139-
/// 已存裁剪图相对路径列表(crop/xxx.webp,按文件名=时间戳升序)。
139+
/// 已存裁剪图相对路径列表(crop/xxx.webp,按时间戳数值升序)。
140+
///
141+
/// @ai-context: 修复(用户反馈"最近画面未按时间轴排列"):文件名 = 毫秒
142+
/// 时间戳,位数不固定(999/1000/11000)——字典序排序在跨位数
143+
/// 时错乱("11000"<"2000"),必须按解析出的数值排序。
140144
pub fn list_crops(&self) -> Vec<String> {
141145
let mut paths: Vec<String> = std::fs::read_dir(self.session_dir.join("crop"))
142146
.map(|entries| {
@@ -147,7 +151,7 @@ impl SessionImageStore {
147151
.collect()
148152
})
149153
.unwrap_or_default();
150-
paths.sort();
154+
sort_by_timestamp(&mut paths);
151155
paths
152156
}
153157

@@ -193,7 +197,12 @@ impl SessionImageStore {
193197
Ok(format!("full/{}", name))
194198
}
195199

196-
/// 已存图片相对路径列表(full/xxx.webp,按文件名=时间戳升序)。
200+
/// 已存图片相对路径列表(full/xxx.webp,按时间戳数值升序)。
201+
///
202+
/// @ai-context: 修复(用户反馈"最近画面未按时间轴排列"):同 list_crops——
203+
/// 毫秒时间戳位数不固定,字典序在跨位数时错乱
204+
/// ("999">"1000"、"11000"<"2000"),必须数值排序。
205+
/// 前端"最近画面"条依赖此升序(新图追加在末尾)。
197206
pub fn list_images(&self) -> Vec<String> {
198207
let mut paths: Vec<String> = std::fs::read_dir(self.session_dir.join("full"))
199208
.map(|entries| {
@@ -204,11 +213,30 @@ impl SessionImageStore {
204213
.collect()
205214
})
206215
.unwrap_or_default();
207-
paths.sort();
216+
sort_by_timestamp(&mut paths);
208217
paths
209218
}
210219
}
211220

221+
/// 按文件名中的数值时间戳升序排序(full/<ts>.webp / crop/<ts>.webp)。
222+
///
223+
/// @ai-context: 纯函数(可单测);文件名解析失败(非 <ts>.webp 形态)回退
224+
/// 字典序兜底(防御异常文件不 panic、不丢条目)。
225+
fn sort_by_timestamp(paths: &mut Vec<String>) {
226+
fn ts_of(rel: &str) -> Option<u64> {
227+
rel.rsplit_once('/')
228+
.and_then(|(_, name)| name.strip_suffix(".webp"))
229+
.and_then(|ts| ts.parse::<u64>().ok())
230+
}
231+
paths.sort_by(|a, b| match (ts_of(a), ts_of(b)) {
232+
(Some(ta), Some(tb)) => ta.cmp(&tb),
233+
// 解析失败条目靠后(按字典序稳定兜底)
234+
(Some(_), None) => std::cmp::Ordering::Less,
235+
(None, Some(_)) => std::cmp::Ordering::Greater,
236+
(None, None) => a.cmp(b),
237+
});
238+
}
239+
212240
/// 统计目录内 WebP 文件数(预算恢复用;目录不存在/读失败按 0 计)。
213241
fn count_webp(dir: &Path) -> usize {
214242
std::fs::read_dir(dir)

‎app/src-tauri/src/image_store_tests.rs‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,36 @@ fn list_images_sorted_by_timestamp() {
178178
assert_eq!(list, vec!["full/1000.webp", "full/2000.webp", "full/3000.webp"]);
179179
}
180180

181+
#[test]
182+
fn list_images_sorted_numerically_across_digit_lengths() {
183+
// Arrange(回归:用户反馈"最近画面未按时间轴排列"——毫秒时间戳位数
184+
// 不固定,字典序跨位数错乱:"11000"<"2000"、"999">"1000")
185+
let dir = tempfile::tempdir().unwrap();
186+
let mut store = SessionImageStore::new(dir.path().to_path_buf()).unwrap();
187+
store.save_frame(11000, &seeded_frame(32, 32, 1), 32, 32).unwrap();
188+
store.save_frame(999, &seeded_frame(32, 32, 2), 32, 32).unwrap();
189+
store.save_frame(2000, &seeded_frame(32, 32, 3), 32, 32).unwrap();
190+
store.save_frame(1000, &seeded_frame(32, 32, 4), 32, 32).unwrap();
191+
// Act
192+
let list = store.list_images();
193+
// Assert:按数值时间戳升序(999→1000→2000→11000),而非字典序
194+
assert_eq!(list, vec!["full/999.webp", "full/1000.webp", "full/2000.webp", "full/11000.webp"]);
195+
}
196+
197+
#[test]
198+
fn list_crops_sorted_numerically_across_digit_lengths() {
199+
// Arrange(回归:与 list_images 同根因——裁剪图列表同样需要时间轴序)
200+
let dir = tempfile::tempdir().unwrap();
201+
let mut store = SessionImageStore::new(dir.path().to_path_buf()).unwrap();
202+
store.save_crop(1500, &seeded_frame(32, 32, 1), 32, 32).unwrap();
203+
store.save_crop(99, &seeded_frame(32, 32, 2), 32, 32).unwrap();
204+
store.save_crop(12000, &seeded_frame(32, 32, 3), 32, 32).unwrap();
205+
// Act
206+
let list = store.list_crops();
207+
// Assert:数值升序(99→1500→12000),字典序会排成 12000<1500<99
208+
assert_eq!(list, vec!["crop/99.webp", "crop/1500.webp", "crop/12000.webp"]);
209+
}
210+
181211
#[test]
182212
fn duplicate_frame_deduped_by_dual_fingerprint() {
183213
// Arrange:REQ-067 去重语义——连续保存相同内容帧 → 第二次返回首次路径且不重复存

‎app/src-tauri/src/live_session.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,12 @@ impl LiveSessionManager {
134134
self.session_info.snapshot()
135135
}
136136

137+
/// 是否处于暂停(2026-08 修复:live_session_status 拉取用——recording/
138+
/// paused 事件只发一次,页面刷新/重进后前端需拉取还原状态机)。
139+
pub fn is_paused(&self) -> bool {
140+
self.pause.paused.load(Ordering::SeqCst)
141+
}
142+
137143
/// 暂停活动会话(2026-08 A1 硬暂停:完全停采)。
138144
///
139145
/// @ai-context: 只置共享标志——实际暂停由捕获线程边沿检测执行

‎app/src/components/LiveActivityPanel.tsx‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { invoke } from "@tauri-apps/api/core";
1616
import { listen } from "@tauri-apps/api/event";
1717
// 2026-08 用户需求:实时转写中显示图片数据(转写 Tab 顶部"最近画面"条,独立区域不跳动)
1818
import LiveImageStrip from "./LiveImageStrip";
19-
import type { AsrFinalEvent, OcrEvent, SessionInfo, SubtitleEvent } from "../types";
19+
import type { AsrFinalEvent, LiveSessionStatus, OcrEvent, SessionInfo, SubtitleEvent } from "../types";
2020

2121
/** 定稿转写行(字幕或语音) */
2222
interface TranscriptLine {
@@ -128,12 +128,24 @@ export default function LiveActivityPanel({ sessionId }: { sessionId?: number |
128128
// 会话切换:清空旧会话信息 + 拉取兜底(live:session-info 事件在引擎就绪时
129129
// 发出,可能早于本面板挂载/监听注册——invoke 拉取保证信息条始终可见;
130130
// 拉取失败静默:无活动会话等场景语义正确)
131+
// 2026-08 修复(状态不一致):live:status recording / live:paused 事件只发
132+
// 一次——页面刷新/重进课堂助手后本面板挂载晚于事件,phase 永远停在
133+
// "正在初始化…",而左侧已由 live_session_status 拉取显示"采集中";
134+
// 挂载时拉取一次按 active/paused 还原状态机(事件仍为增量更新通道)
131135
useEffect(() => {
132136
setInfo(null);
133137
if (!sessionId) return;
134138
void invoke<SessionInfo>("live_session_info")
135139
.then(setInfo)
136140
.catch(() => undefined);
141+
void invoke<LiveSessionStatus>("live_session_status")
142+
.then((s) => {
143+
if (s.active) {
144+
setPhase(s.paused ? "⏸ 已暂停(时间轴冻结)" : "● 采集中");
145+
startedAtRef.current = startedAtRef.current ?? Date.now();
146+
}
147+
})
148+
.catch(() => undefined);
137149
}, [sessionId]);
138150

139151
// 时长计时(1s tick,仅展示)

‎app/src/components/LiveImageStrip.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ export default function LiveImageStrip({ sessionId }: { sessionId: number | null
8787

8888
if (!sessionId) return null;
8989
// 2026-08 用户需求:最近画面按时间先后排列——后端 list_session_images
90-
// 已按文件名(=时间戳)升序返回,此处不再 reverse(前→后 = 旧→新,新图追加在末尾)
90+
// 已按文件名中的时间戳**数值**升序返回(字典序在跨位数时错乱,已修复),
91+
// 此处不再 reverse(前→后 = 旧→新,新图追加在末尾)
9192
const shown = images.slice(-MAX_SHOWN);
9293

9394
return (

‎app/src/types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,8 @@ export interface LiveSessionStatus {
159159
sessionId: number | null;
160160
/** 引擎预热是否已就绪(P3:"开始即录"提示) */
161161
prepared: boolean;
162+
/** 是否处于暂停(2026-08 修复:刷新/重进页面后右侧面板状态机还原用) */
163+
paused: boolean;
162164
}
163165

164166
/** 视频导入进度(Rust ImportProgress,camelCase 契约) */

0 commit comments

Comments
 (0)