|
| 1 | +//! SOP 功能区命令面(v0.20.3 / REQ-296/297)。 |
| 2 | +//! |
| 3 | +//! @ai-context: 只做校验与编排(AGENTS.md §6):模板=行范围引用(无双写—— |
| 4 | +//! 编辑段落即编辑模板);run 启动校验行范围/步数,证据路径白名单 |
| 5 | +//! `notes-images/`(G3:图片三入口落盘目录);结束写完成史 |
| 6 | +//! (sop_run——完成即证据);修订建议 v1 纯本地聚合(步骤 N |
| 7 | +//! 失败 M 次提示——AI 拟稿后置,AiSettings 闸门默认关,G4)。 |
| 8 | +
|
| 9 | +use tauri::State; |
| 10 | + |
| 11 | +use crate::commands::AppState; |
| 12 | +use crate::db_sop::{ |
| 13 | + lines_to_steps, EVIDENCE_PREFIX, MAX_SOP_STEPS, MODE_CONFIRM, MODE_READDO, RUN_ABORTED, |
| 14 | + RUN_DONE, SopRunDetail, SopTemplate, |
| 15 | +}; |
| 16 | + |
| 17 | +/// 建模板(编辑器工具栏「生成 SOP」入口:当前选中段落行范围)。 |
| 18 | +#[tauri::command] |
| 19 | +pub fn sop_template_create( |
| 20 | + state: State<'_, AppState>, |
| 21 | + note_id: i64, |
| 22 | + name: String, |
| 23 | + start_line: i64, |
| 24 | + end_line: i64, |
| 25 | + mode: Option<String>, |
| 26 | +) -> Result<i64, String> { |
| 27 | + let name = name.trim(); |
| 28 | + if name.is_empty() { |
| 29 | + return Err("模板名不能为空".to_string()); |
| 30 | + } |
| 31 | + if name.chars().count() > 60 { |
| 32 | + return Err("模板名过长(≤60 字符)".to_string()); |
| 33 | + } |
| 34 | + if start_line < 0 || end_line < start_line { |
| 35 | + return Err("行范围非法".to_string()); |
| 36 | + } |
| 37 | + let note = state |
| 38 | + .db |
| 39 | + .get_note(note_id) |
| 40 | + .map_err(|e| e.to_string())? |
| 41 | + .ok_or_else(|| "笔记不存在".to_string())?; |
| 42 | + let steps = lines_to_steps(¬e.content, start_line, end_line); |
| 43 | + if steps.is_empty() { |
| 44 | + return Err("所选段落无内容(无法生成空 SOP)".to_string()); |
| 45 | + } |
| 46 | + if steps.len() > MAX_SOP_STEPS { |
| 47 | + return Err(format!("所选段落超过 {} 步上限——请缩小范围", MAX_SOP_STEPS)); |
| 48 | + } |
| 49 | + let mode = mode.unwrap_or_else(|| MODE_READDO.to_string()); |
| 50 | + state |
| 51 | + .db |
| 52 | + .create_sop_template(note_id, name, start_line, end_line, &mode) |
| 53 | + .map_err(|e| e.to_string()) |
| 54 | +} |
| 55 | + |
| 56 | +/// 模板列表(None=全部;行动中心「SOP 库」页签数据源)。 |
| 57 | +#[tauri::command] |
| 58 | +pub fn sop_template_list( |
| 59 | + state: State<'_, AppState>, |
| 60 | + note_id: Option<i64>, |
| 61 | +) -> Result<Vec<SopTemplate>, String> { |
| 62 | + state.db.list_sop_templates(note_id).map_err(|e| e.to_string()) |
| 63 | +} |
| 64 | + |
| 65 | +/// 删除模板(只删引用不动正文)。 |
| 66 | +#[tauri::command] |
| 67 | +pub fn sop_template_delete(state: State<'_, AppState>, template_id: i64) -> Result<(), String> { |
| 68 | + let ok = state |
| 69 | + .db |
| 70 | + .delete_sop_template(template_id) |
| 71 | + .map_err(|e| e.to_string())?; |
| 72 | + if !ok { |
| 73 | + return Err("模板不存在".to_string()); |
| 74 | + } |
| 75 | + Ok(()) |
| 76 | +} |
| 77 | + |
| 78 | +/// 启动 run(快照当前行范围步骤;返回 run id)。 |
| 79 | +#[tauri::command] |
| 80 | +pub fn sop_run_start(state: State<'_, AppState>, template_id: i64) -> Result<i64, String> { |
| 81 | + let tmpl = state |
| 82 | + .db |
| 83 | + .get_sop_template(template_id) |
| 84 | + .map_err(|e| e.to_string())? |
| 85 | + .ok_or_else(|| "模板不存在".to_string())?; |
| 86 | + // 有进行中的 run 拒绝并发执行(同一模板同时只跑一个——防双开重复证据) |
| 87 | + let running = state |
| 88 | + .db |
| 89 | + .list_sop_runs(template_id, 50) |
| 90 | + .map_err(|e| e.to_string())? |
| 91 | + .iter() |
| 92 | + .any(|r| r.status == "active"); |
| 93 | + if running { |
| 94 | + return Err("该模板已有进行中的执行——请先结算或中止".to_string()); |
| 95 | + } |
| 96 | + state.db.start_sop_run(&tmpl).map_err(|e| e.to_string()) |
| 97 | +} |
| 98 | + |
| 99 | +/// run 详情(执行面数据源:轨迹/步骤/保鲜 diff)。 |
| 100 | +#[tauri::command] |
| 101 | +pub fn sop_run_detail(state: State<'_, AppState>, run_id: i64) -> Result<SopRunDetail, String> { |
| 102 | + state |
| 103 | + .db |
| 104 | + .sop_run_detail(run_id) |
| 105 | + .map_err(|e| e.to_string())? |
| 106 | + .ok_or_else(|| "run 不存在".to_string()) |
| 107 | +} |
| 108 | + |
| 109 | +/// 步更新(done|skipped|failed;证据路径白名单 notes-images/;failed 可留因)。 |
| 110 | +#[tauri::command] |
| 111 | +pub fn sop_step_update( |
| 112 | + state: State<'_, AppState>, |
| 113 | + run_id: i64, |
| 114 | + step_no: i64, |
| 115 | + status: String, |
| 116 | + evidence_path: Option<String>, |
| 117 | + failure_note: Option<String>, |
| 118 | +) -> Result<(), String> { |
| 119 | + if !matches!(status.as_str(), "done" | "skipped" | "failed") { |
| 120 | + return Err("非法步状态(done/skipped/failed)".to_string()); |
| 121 | + } |
| 122 | + if let Some(ev) = &evidence_path { |
| 123 | + if !ev.starts_with(EVIDENCE_PREFIX) { |
| 124 | + return Err(format!("证据路径须在 {} 下", EVIDENCE_PREFIX)); |
| 125 | + } |
| 126 | + if ev.len() > 260 { |
| 127 | + return Err("证据路径过长".to_string()); |
| 128 | + } |
| 129 | + } |
| 130 | + if let Some(n) = &failure_note { |
| 131 | + if n.chars().count() > 500 { |
| 132 | + return Err("失败原因过长(≤500 字)".to_string()); |
| 133 | + } |
| 134 | + } |
| 135 | + if let Some(run) = state.db.get_sop_run(run_id).map_err(|e| e.to_string())? { |
| 136 | + if run.status != "active" { |
| 137 | + return Err("run 已结束,不能再更新步骤".to_string()); |
| 138 | + } |
| 139 | + } |
| 140 | + let ok = state |
| 141 | + .db |
| 142 | + .update_sop_step(run_id, step_no, &status, evidence_path.as_deref(), failure_note.as_deref()) |
| 143 | + .map_err(|e| e.to_string())?; |
| 144 | + if !ok { |
| 145 | + return Err("步骤不存在(run 已结束或步号越界)".to_string()); |
| 146 | + } |
| 147 | + Ok(()) |
| 148 | +} |
| 149 | + |
| 150 | +/// 结算 run(done=全流程完成;aborted=中止;写完成史 + 保鲜 diff 回传)。 |
| 151 | +#[tauri::command] |
| 152 | +pub fn sop_run_finish( |
| 153 | + state: State<'_, AppState>, |
| 154 | + run_id: i64, |
| 155 | + status: String, |
| 156 | +) -> Result<SopRunDetail, String> { |
| 157 | + let status = if status == RUN_ABORTED { RUN_ABORTED } else { RUN_DONE }; |
| 158 | + state.db.finish_sop_run(run_id, status).map_err(|e| e.to_string()) |
| 159 | +} |
| 160 | + |
| 161 | +/// run 轨迹回看(模板维度)。 |
| 162 | +#[tauri::command] |
| 163 | +pub fn sop_run_list( |
| 164 | + state: State<'_, AppState>, |
| 165 | + template_id: i64, |
| 166 | + limit: Option<usize>, |
| 167 | +) -> Result<Vec<crate::db_sop::SopRun>, String> { |
| 168 | + state |
| 169 | + .db |
| 170 | + .list_sop_runs(template_id, limit.unwrap_or(20)) |
| 171 | + .map_err(|e| e.to_string()) |
| 172 | +} |
| 173 | + |
| 174 | +/// 修订建议(v1 纯本地:失败步聚合——"步骤 N 累计失败 M 次";AI 拟稿后置)。 |
| 175 | +#[tauri::command] |
| 176 | +pub fn sop_revision_suggestions( |
| 177 | + state: State<'_, AppState>, |
| 178 | + template_id: i64, |
| 179 | +) -> Result<Vec<String>, String> { |
| 180 | + let agg = state |
| 181 | + .db |
| 182 | + .sop_failure_aggregate(template_id) |
| 183 | + .map_err(|e| e.to_string())?; |
| 184 | + Ok(agg |
| 185 | + .into_iter() |
| 186 | + .map(|(no, text, count)| { |
| 187 | + format!("步骤 {}({})累计失败 {} 次——建议复核该步做法或预期结果", no, truncate(&text, 20), count) |
| 188 | + }) |
| 189 | + .collect()) |
| 190 | +} |
| 191 | + |
| 192 | +fn truncate(s: &str, n: usize) -> String { |
| 193 | + let t: String = s.chars().take(n).collect(); |
| 194 | + if s.chars().count() > n { format!("{}…", t) } else { t } |
| 195 | +} |
0 commit comments