Skip to content

Commit bca36da

Browse files
committed
fix(ocr): 审查修复——预算/错误区分+删除顺序+屏定位first_seen+误触防护等
1 parent a399365 commit bca36da

7 files changed

Lines changed: 68 additions & 40 deletions

File tree

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

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,13 @@ pub async fn capture_session_structures(
5353
/// @ai-context: 入参归一化 0-1(前端框选与图同坐标系);像素换算按 image_ref
5454
/// 解码帧的宽高;越界钳制;<32×32 拒绝(误触防护);原样裁剪
5555
/// (不做白边——用户意图优先);手动不设预算上限。
56+
/// @ai-context: 审查修复:屏定位改用 first_seen_ms(屏号对旧数据聚类屏不唯一
57+
/// ——多聚类屏 screen_id 均为 NULL,按屏号匹配会错屏)。
5658
#[tauri::command]
5759
pub async fn capture_structure_manual(
5860
state: State<'_, AppState>,
5961
session_id: i64,
60-
screen_id: Option<i64>,
62+
first_seen_ms: u64,
6163
x: f32,
6264
y: f32,
6365
w: f32,
@@ -72,7 +74,7 @@ pub async fn capture_structure_manual(
7274
}
7375
let state: AppState = (*state).clone();
7476
tauri::async_runtime::spawn_blocking(move || {
75-
manual_capture_inner(&state, session_id, screen_id, x, y, w, h)
77+
manual_capture_inner(&state, session_id, first_seen_ms, x, y, w, h)
7678
})
7779
.await
7880
.map_err(|e| format!("任务调度失败: {}", e))?
@@ -82,19 +84,20 @@ pub async fn capture_structure_manual(
8284
fn manual_capture_inner(
8385
state: &AppState,
8486
session_id: i64,
85-
screen_id: Option<i64>,
87+
first_seen_ms: u64,
8688
x: f32,
8789
y: f32,
8890
w: f32,
8991
h: f32,
9092
) -> std::result::Result<StructureImageRecord, String> {
91-
// ① 定位屏卡 image_ref(复用屏卡体系;无图 → 明确错误,前端禁用按钮)
93+
// ① 定位屏卡 image_ref(复用屏卡体系;按 first_seen 精确定位——聚类屏
94+
// 屏号不唯一;无图 → 明确错误,前端禁用按钮)
9295
let blocks = state.db.list_ocr_blocks(session_id).map_err(|e| e.to_string())?;
9396
let images_dir = session_images_dir(&state.data_dir, session_id);
9497
let screens = crate::screens::build_screens(&blocks, Some(&images_dir));
9598
let screen = screens
9699
.iter()
97-
.find(|s| s.screen_id == screen_id)
100+
.find(|s| s.first_seen_ms == first_seen_ms)
98101
.ok_or_else(|| "未找到对应屏卡".to_string())?;
99102
let image_ref = screen
100103
.image_ref
@@ -110,7 +113,7 @@ fn manual_capture_inner(
110113
let (cx, cy) = (px(x, fw), px(y, fh));
111114
let (cw, ch) = (px(w, fw).min(fw - cx), px(h, fh).min(fh - cy));
112115
if cw < 32 || ch < 32 {
113-
return Err("框选区域过小(≥32×32 像素)".to_string());
116+
return Err("框选区域无效(过小或超出画面边缘)".to_string());
114117
}
115118
let crop = image::imageops::crop_imm(&frame, cx, cy, cw, ch).to_image();
116119
// ④ 手动入库(不设预算/不去重)+ 记录
@@ -121,7 +124,7 @@ fn manual_capture_inner(
121124
.map(|d| d.as_millis() as u64)
122125
.unwrap_or(0);
123126
let rel = store
124-
.save_manual(now, &rgb_to_bgra(&crop), crop.width(), crop.height())
127+
.save_manual(now, &crate::structure_capture::rgb_to_bgra(&crop), crop.width(), crop.height())
125128
.map_err(|e| e.to_string())?;
126129
let rec = StructureImageRecord {
127130
id: 0,
@@ -141,16 +144,6 @@ fn manual_capture_inner(
141144
Ok(saved)
142145
}
143146

144-
/// RGB → BGRA(结构图存储契约;与 structure_capture 内部同实现——命令层
145-
/// 独立小函数,避免跨模块依赖私有工具)。
146-
fn rgb_to_bgra(rgb: &image::RgbImage) -> Vec<u8> {
147-
let mut out = Vec::with_capacity((rgb.width() * rgb.height() * 4) as usize);
148-
for p in rgb.pixels() {
149-
out.extend_from_slice(&[p[2], p[1], p[0], 255]);
150-
}
151-
out
152-
}
153-
154147
/// 会话结构图列表(图库数据源;按入库时间升序)。
155148
#[tauri::command]
156149
pub fn list_session_structure_images(
@@ -163,7 +156,8 @@ pub fn list_session_structure_images(
163156
crate::db_structures::list_structure_images(&state.db, session_id).map_err(|e| e.to_string())
164157
}
165158

166-
/// 删除结构图(记录驱动:删记录 + 删文件;文件仅被本表引用可安全删)。
159+
/// 删除结构图(记录驱动:先删文件后删记录——文件删除失败时记录保留可重试,
160+
/// 避免记录已删而文件残留的不一致;审查修复)。
167161
#[tauri::command]
168162
pub fn delete_structure_image(
169163
state: State<'_, AppState>,
@@ -172,13 +166,15 @@ pub fn delete_structure_image(
172166
if id <= 0 {
173167
return Err("无效的记录 id".to_string());
174168
}
175-
let rec = crate::db_structures::delete_structure_image(&state.db, id)
169+
let rec = crate::db_structures::get_structure_image(&state.db, id)
176170
.map_err(|e| e.to_string())?;
177171
if let Some(r) = rec {
178172
let images_dir = session_images_dir(&state.data_dir, r.session_id);
179173
let store = crate::structure_store::StructureImageStore::new(images_dir)
180174
.map_err(|e| e.to_string())?;
181175
store.delete_image(&r.crop_path).map_err(|e| e.to_string())?;
176+
crate::db_structures::delete_structure_image(&state.db, id)
177+
.map_err(|e| e.to_string())?;
182178
}
183179
let _ = state.app.emit("session:structures-updated", ());
184180
Ok(true)

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

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -87,18 +87,25 @@ pub fn list_structure_images(db: &Db, session_id: i64) -> Result<Vec<StructureIm
8787
Ok(rows)
8888
}
8989

90+
/// 按 id 查询记录(纯读;不存在 → Ok(None))。
91+
/// @ai-context: 审查修复(删除顺序调整):命令层先取记录删文件、再删记录——
92+
/// 文件删除失败时记录保留可重试(避免记录已删文件残留的不一致)。
93+
pub fn get_structure_image(db: &Db, id: i64) -> Result<Option<StructureImageRecord>> {
94+
let conn = db.conn.lock().unwrap();
95+
conn.query_row(
96+
"SELECT id, session_id, screen_id, kind, bbox, source_ts_ms, crop_path, source, created_at
97+
FROM session_structure_images WHERE id = ?1",
98+
params![id],
99+
row_to_record,
100+
)
101+
.optional()
102+
.map_err(Into::into)
103+
}
90104
/// 删除记录(返回被删记录——命令层据此删文件;不存在 → Ok(None))。
91105
pub fn delete_structure_image(db: &Db, id: i64) -> Result<Option<StructureImageRecord>> {
92-
let conn = db.conn.lock().unwrap();
93-
let existing = conn
94-
.query_row(
95-
"SELECT id, session_id, screen_id, kind, bbox, source_ts_ms, crop_path, source, created_at
96-
FROM session_structure_images WHERE id = ?1",
97-
params![id],
98-
row_to_record,
99-
)
100-
.optional()?;
106+
let existing = get_structure_image(db, id)?;
101107
if existing.is_some() {
108+
let conn = db.conn.lock().unwrap();
102109
conn.execute("DELETE FROM session_structure_images WHERE id = ?1", params![id])?;
103110
}
104111
Ok(existing)

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

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ pub fn capture_session_structures(
8484
);
8585
// ⑥ 逐区域裁剪 → 存储 → 记录
8686
for r in frame_regions {
87+
// 预算前置检查(save_auto 内部同检查——前置拦截避免单区域编码/IO
88+
// 错误被误判为预算耗尽而终止整个会话:审查修复)
89+
if store.remaining_budget() == 0 {
90+
summary.budget_exhausted = true;
91+
return Ok(summary);
92+
}
8793
let Some(crop) = crop_region(frame, &r) else { continue };
8894
let bgra = rgb_to_bgra(&crop);
8995
seq += 1;
@@ -109,10 +115,9 @@ pub fn capture_session_structures(
109115
)?;
110116
summary.captured += 1;
111117
}
112-
// 预算耗尽:终止本会话后续捕获(已捕获保留)
113-
Err(_) => {
114-
summary.budget_exhausted = true;
115-
return Ok(summary);
118+
// 单区域失败(编码/IO)不阻断后续区域——仅留日志(预算已前置)
119+
Err(e) => {
120+
eprintln!("[Structures] 结构图入库失败(跳过该区域): {e}");
116121
}
117122
}
118123
}
@@ -187,7 +192,9 @@ fn crop_region(frame: &image::RgbImage, r: &LayoutRegion) -> Option<image::RgbIm
187192
}
188193

189194
/// RGB → BGRA(结构图存储契约;纯函数)。
190-
fn rgb_to_bgra(rgb: &image::RgbImage) -> Vec<u8> {
195+
/// @ai-context: pub(crate)(审查修复):commands_structures 手动捕获复用——
196+
/// 消除两处重复实现。
197+
pub(crate) fn rgb_to_bgra(rgb: &image::RgbImage) -> Vec<u8> {
191198
let mut out = Vec::with_capacity((rgb.width() * rgb.height() * 4) as usize);
192199
for p in rgb.pixels() {
193200
out.extend_from_slice(&[p[2], p[1], p[0], 255]);

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,5 +240,4 @@ fn grid_from_rgb_degenerate_sizes() {
240240
assert_eq!(grid.rows, 1);
241241
let luma = (10u32 * 299 + 20 * 587 + 30 * 114) / 1000;
242242
assert_eq!(grid.cells[0], luma as u8);
243-
let _: FrameGrid = grid;
244243
}

‎app/src/components/BoxSelectOverlay.tsx‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import { invoke } from "@tauri-apps/api/core";
1212
interface Props {
1313
src: string;
1414
sessionId: number;
15-
screenId: number | null;
15+
/** 屏定位键(first_seen_ms——旧数据聚类屏号不唯一,审查修复) */
16+
firstSeenMs: number;
1617
/** 保存成功回调(父层 toast + 图库自动刷新走事件) */
1718
onDone: () => void;
1819
onCancel: () => void;
@@ -25,9 +26,10 @@ interface Box {
2526
h: number;
2627
}
2728

28-
export default function BoxSelectOverlay({ src, sessionId, screenId, onDone, onCancel }: Props) {
29+
export default function BoxSelectOverlay({ src, sessionId, firstSeenMs, onDone, onCancel }: Props) {
2930
const wrapRef = useRef<HTMLDivElement>(null);
3031
const [dragging, setDragging] = useState(false);
32+
const [moved, setMoved] = useState(false); // 审查修复:区分"拖拽"与"单击"(防误触全屏)
3133
const [box, setBox] = useState<Box | null>(null);
3234
const [confirm, setConfirm] = useState<Box | null>(null);
3335
const [saving, setSaving] = useState(false);
@@ -50,6 +52,7 @@ export default function BoxSelectOverlay({ src, sessionId, screenId, onDone, onC
5052
if (!p) return;
5153
setBox(p);
5254
setDragging(true);
55+
setMoved(false);
5356
setConfirm(null);
5457
setError("");
5558
};
@@ -58,6 +61,7 @@ export default function BoxSelectOverlay({ src, sessionId, screenId, onDone, onC
5861
if (!dragging || !box) return;
5962
const p = norm(e.clientX, e.clientY);
6063
if (!p) return;
64+
setMoved(true);
6165
setBox({
6266
x: Math.min(box.x, p.x),
6367
y: Math.min(box.y, p.y),
@@ -69,6 +73,11 @@ export default function BoxSelectOverlay({ src, sessionId, screenId, onDone, onC
6973
const onMouseUp = () => {
7074
if (!dragging || !box) return;
7175
setDragging(false);
76+
// 单击未拖动(初始占位 w=h=1)→ 视为误触忽略(审查修复:此前会确认全屏框)
77+
if (!moved) {
78+
setBox(null);
79+
return;
80+
}
7281
// 过小框(<6% 宽高)视为误触:忽略本次拖拽
7382
if (box.w < 0.06 || box.h < 0.06) {
7483
setBox(null);
@@ -84,7 +93,7 @@ export default function BoxSelectOverlay({ src, sessionId, screenId, onDone, onC
8493
try {
8594
await invoke("capture_structure_manual", {
8695
sessionId,
87-
screenId,
96+
firstSeenMs,
8897
x: confirm.x,
8998
y: confirm.y,
9099
w: confirm.w,
@@ -131,7 +140,13 @@ export default function BoxSelectOverlay({ src, sessionId, screenId, onDone, onC
131140
onMouseDown={onMouseDown}
132141
onMouseMove={onMouseMove}
133142
onMouseUp={onMouseUp}
134-
onMouseLeave={() => dragging && setDragging(false)}
143+
onMouseLeave={() => {
144+
// 审查修复:拖出框外时清理拖拽状态与残留框(防虚线框残留)
145+
if (dragging) {
146+
setDragging(false);
147+
setBox(null);
148+
}
149+
}}
135150
style={{
136151
position: "absolute",
137152
inset: 0,

‎app/src/components/SessionDetailPanel.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ export default function SessionDetailPanel({ detail, fusing, degradedBanner, onT
322322
<BoxSelectOverlay
323323
src={convertFileSrc(`${baseUrl}/${s.image_ref}`)}
324324
sessionId={sessionId}
325-
screenId={s.screen_id}
325+
firstSeenMs={s.first_seen_ms}
326326
onDone={() => {
327327
setSelectingScreen(null);
328328
setToastMsg("✓ 已保存为结构图(见图集「结构图」区段)");

‎app/src/components/StructureImageSection.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export default function StructureImageSection({ sessionId, baseUrl }: { sessionI
3030
const [loading, setLoading] = useState(false);
3131
const [busy, setBusy] = useState(false);
3232
const [error, setError] = useState("");
33+
// 审查修复:非错误反馈(重跑无新增等)与错误分离显示(错误红色/信息灰色)
34+
const [info, setInfo] = useState("");
3335
const [preview, setPreview] = useState<StructureImageRecord | null>(null);
3436

3537
const refresh = useCallback(async () => {
@@ -68,12 +70,13 @@ export default function StructureImageSection({ sessionId, baseUrl }: { sessionI
6870
const recapture = async () => {
6971
setBusy(true);
7072
setError("");
73+
setInfo("");
7174
try {
7275
const summary = await invoke<{ captured: number; screensScanned: number; budgetExhausted: boolean }>(
7376
"capture_session_structures",
7477
{ sessionId },
7578
);
76-
if (summary.captured === 0) setError("重新捕获完成:无新增结构图(已去重或本会话无结构区域)");
79+
if (summary.captured === 0) setInfo("重新捕获完成:无新增结构图(已去重或本会话无结构区域)");
7780
} catch (e) {
7881
setError(`重新捕获失败: ${e}`);
7982
} finally {
@@ -92,6 +95,7 @@ export default function StructureImageSection({ sessionId, baseUrl }: { sessionI
9295
{loading ? "加载中…" : "刷新"}
9396
</button>
9497
{error && <span style={{ fontSize: 11, color: "#dc2626" }}>{error}</span>}
98+
{!error && info && <span style={{ fontSize: 11, color: "#6b7280" }}>{info}</span>}
9599
</div>
96100
{images.length === 0 && !loading && (
97101
<div style={{ fontSize: 11, color: "#9ca3af" }}>

0 commit comments

Comments
 (0)