|
| 1 | +//! 结构图命令层(REQ-182/183/184 / v0.7.7):批量捕获 / 手动框选 / 列表 / 删除。 |
| 2 | +//! |
| 3 | +//! @ai-context: IPC 安全边界:全部入参校验(session_id>0、归一化坐标 0-1、 |
| 4 | +//! 最小尺寸);文件访问限定会话图片目录(data_dir/session-images/<id>, |
| 5 | +//! AGENTS.md 安全红线);事件 session:structures-updated 驱动前端图库刷新。 |
| 6 | +//! @ai-context: 停止后自动触发与手动重跑共用同一管线(capture_session_structures)—— |
| 7 | +//! 去重幂等,重跑不重复入库。 |
| 8 | +
|
| 9 | +use tauri::Emitter; |
| 10 | +use tauri::State; |
| 11 | + |
| 12 | +use crate::commands::AppState; |
| 13 | +use crate::db_structures::StructureImageRecord; |
| 14 | +use crate::structure_capture::CaptureSummary; |
| 15 | + |
| 16 | +/// 会话图片目录(与 commands_images 同约定)。 |
| 17 | +fn session_images_dir(data_dir: &std::path::Path, session_id: i64) -> std::path::PathBuf { |
| 18 | + data_dir.join("session-images").join(session_id.to_string()) |
| 19 | +} |
| 20 | + |
| 21 | +/// 批量捕获结构图(停止后自动触发 / 图库「重新捕获」重跑;幂等)。 |
| 22 | +#[tauri::command] |
| 23 | +pub async fn capture_session_structures( |
| 24 | + state: State<'_, AppState>, |
| 25 | + session_id: i64, |
| 26 | +) -> Result<CaptureSummary, String> { |
| 27 | + if session_id <= 0 { |
| 28 | + return Err("无效的会话 id".to_string()); |
| 29 | + } |
| 30 | + let state: AppState = (*state).clone(); |
| 31 | + let summary = tauri::async_runtime::spawn_blocking(move || { |
| 32 | + let now = std::time::SystemTime::now() |
| 33 | + .duration_since(std::time::UNIX_EPOCH) |
| 34 | + .map(|d| d.as_millis() as u64) |
| 35 | + .unwrap_or(0); |
| 36 | + crate::structure_capture::capture_session_structures( |
| 37 | + &state.db, |
| 38 | + &state.data_dir, |
| 39 | + session_id, |
| 40 | + now, |
| 41 | + ) |
| 42 | + }) |
| 43 | + .await |
| 44 | + .map_err(|e| format!("任务调度失败: {}", e))? |
| 45 | + .map_err(|e| e.to_string())?; |
| 46 | + // 图库刷新事件(前端 ImageGallery 监听) |
| 47 | + let _ = state.app.emit("session:structures-updated", &summary); |
| 48 | + Ok(summary) |
| 49 | +} |
| 50 | + |
| 51 | +/// 手动框选截取(REQ-184):屏卡全帧图归一化坐标 → 裁剪入库。 |
| 52 | +/// |
| 53 | +/// @ai-context: 入参归一化 0-1(前端框选与图同坐标系);像素换算按 image_ref |
| 54 | +/// 解码帧的宽高;越界钳制;<32×32 拒绝(误触防护);原样裁剪 |
| 55 | +/// (不做白边——用户意图优先);手动不设预算上限。 |
| 56 | +#[tauri::command] |
| 57 | +pub async fn capture_structure_manual( |
| 58 | + state: State<'_, AppState>, |
| 59 | + session_id: i64, |
| 60 | + screen_id: Option<i64>, |
| 61 | + x: f32, |
| 62 | + y: f32, |
| 63 | + w: f32, |
| 64 | + h: f32, |
| 65 | +) -> Result<StructureImageRecord, String> { |
| 66 | + if session_id <= 0 { |
| 67 | + return Err("无效的会话 id".to_string()); |
| 68 | + } |
| 69 | + // 归一化坐标校验(防负数/越界/空框) |
| 70 | + if !(0.0..=1.0).contains(&x) || !(0.0..=1.0).contains(&y) || !(0.0..=1.0).contains(&w) || !(0.0..=1.0).contains(&h) { |
| 71 | + return Err("框选坐标无效(须 0-1 归一化)".to_string()); |
| 72 | + } |
| 73 | + let state: AppState = (*state).clone(); |
| 74 | + tauri::async_runtime::spawn_blocking(move || { |
| 75 | + manual_capture_inner(&state, session_id, screen_id, x, y, w, h) |
| 76 | + }) |
| 77 | + .await |
| 78 | + .map_err(|e| format!("任务调度失败: {}", e))? |
| 79 | +} |
| 80 | + |
| 81 | +/// 手动捕获核心(IO;独立函数便于单测)。 |
| 82 | +fn manual_capture_inner( |
| 83 | + state: &AppState, |
| 84 | + session_id: i64, |
| 85 | + screen_id: Option<i64>, |
| 86 | + x: f32, |
| 87 | + y: f32, |
| 88 | + w: f32, |
| 89 | + h: f32, |
| 90 | +) -> std::result::Result<StructureImageRecord, String> { |
| 91 | + // ① 定位屏卡 image_ref(复用屏卡体系;无图 → 明确错误,前端禁用按钮) |
| 92 | + let blocks = state.db.list_ocr_blocks(session_id).map_err(|e| e.to_string())?; |
| 93 | + let images_dir = session_images_dir(&state.data_dir, session_id); |
| 94 | + let screens = crate::screens::build_screens(&blocks, Some(&images_dir)); |
| 95 | + let screen = screens |
| 96 | + .iter() |
| 97 | + .find(|s| s.screen_id == screen_id) |
| 98 | + .ok_or_else(|| "未找到对应屏卡".to_string())?; |
| 99 | + let image_ref = screen |
| 100 | + .image_ref |
| 101 | + .clone() |
| 102 | + .ok_or_else(|| "该屏无图像,无法框选截取".to_string())?; |
| 103 | + // ② 解码全帧图 |
| 104 | + let frame = image::open(images_dir.join(&image_ref)) |
| 105 | + .map_err(|e| format!("屏图解码失败: {}", e))? |
| 106 | + .to_rgb8(); |
| 107 | + let (fw, fh) = (frame.width(), frame.height()); |
| 108 | + // ③ 归一化 → 像素坐标(钳制越界;最小尺寸拒绝) |
| 109 | + let px = |v: f32, dim: u32| (v * dim as f32).round().min(dim as f32) as u32; |
| 110 | + let (cx, cy) = (px(x, fw), px(y, fh)); |
| 111 | + let (cw, ch) = (px(w, fw).min(fw - cx), px(h, fh).min(fh - cy)); |
| 112 | + if cw < 32 || ch < 32 { |
| 113 | + return Err("框选区域过小(≥32×32 像素)".to_string()); |
| 114 | + } |
| 115 | + let crop = image::imageops::crop_imm(&frame, cx, cy, cw, ch).to_image(); |
| 116 | + // ④ 手动入库(不设预算/不去重)+ 记录 |
| 117 | + let mut store = |
| 118 | + crate::structure_store::StructureImageStore::new(images_dir.clone()).map_err(|e| e.to_string())?; |
| 119 | + let now = std::time::SystemTime::now() |
| 120 | + .duration_since(std::time::UNIX_EPOCH) |
| 121 | + .map(|d| d.as_millis() as u64) |
| 122 | + .unwrap_or(0); |
| 123 | + let rel = store |
| 124 | + .save_manual(now, &rgb_to_bgra(&crop), crop.width(), crop.height()) |
| 125 | + .map_err(|e| e.to_string())?; |
| 126 | + let rec = StructureImageRecord { |
| 127 | + id: 0, |
| 128 | + session_id, |
| 129 | + screen_id: screen.screen_id, |
| 130 | + kind: "manual".to_string(), |
| 131 | + bbox: format!(r#"{{"x":{},"y":{},"w":{},"h":{}}}"#, cx, cy, cw, ch), |
| 132 | + source_ts_ms: screen.first_seen_ms, |
| 133 | + crop_path: rel, |
| 134 | + source: "manual".to_string(), |
| 135 | + created_at: now, |
| 136 | + }; |
| 137 | + let id = crate::db_structures::insert_structure_image(&state.db, &rec).map_err(|e| e.to_string())?; |
| 138 | + let mut saved = rec; |
| 139 | + saved.id = id; |
| 140 | + let _ = state.app.emit("session:structures-updated", ()); |
| 141 | + Ok(saved) |
| 142 | +} |
| 143 | + |
| 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 | + |
| 154 | +/// 会话结构图列表(图库数据源;按入库时间升序)。 |
| 155 | +#[tauri::command] |
| 156 | +pub fn list_session_structure_images( |
| 157 | + state: State<'_, AppState>, |
| 158 | + session_id: i64, |
| 159 | +) -> Result<Vec<StructureImageRecord>, String> { |
| 160 | + if session_id <= 0 { |
| 161 | + return Err("无效的会话 id".to_string()); |
| 162 | + } |
| 163 | + crate::db_structures::list_structure_images(&state.db, session_id).map_err(|e| e.to_string()) |
| 164 | +} |
| 165 | + |
| 166 | +/// 删除结构图(记录驱动:删记录 + 删文件;文件仅被本表引用可安全删)。 |
| 167 | +#[tauri::command] |
| 168 | +pub fn delete_structure_image( |
| 169 | + state: State<'_, AppState>, |
| 170 | + id: i64, |
| 171 | +) -> Result<bool, String> { |
| 172 | + if id <= 0 { |
| 173 | + return Err("无效的记录 id".to_string()); |
| 174 | + } |
| 175 | + let rec = crate::db_structures::delete_structure_image(&state.db, id) |
| 176 | + .map_err(|e| e.to_string())?; |
| 177 | + if let Some(r) = rec { |
| 178 | + let images_dir = session_images_dir(&state.data_dir, r.session_id); |
| 179 | + let store = crate::structure_store::StructureImageStore::new(images_dir) |
| 180 | + .map_err(|e| e.to_string())?; |
| 181 | + store.delete_image(&r.crop_path).map_err(|e| e.to_string())?; |
| 182 | + } |
| 183 | + let _ = state.app.emit("session:structures-updated", ()); |
| 184 | + Ok(true) |
| 185 | +} |
0 commit comments