77//! preview_session_note(只读预览)共用 note_filter 单一管线——
88//! 输出一致性由构造保证(同一过滤链/同一 Markdown 组装)。
99
10+ use std:: collections:: HashSet ;
11+
1012use tauri:: State ;
1113
1214use crate :: ai_protocol:: TextFilterDecision ;
1315use crate :: commands:: { normalize_title, AppState , TITLE_MAX_CHARS } ;
16+ use crate :: db:: Db ;
1417use crate :: db_sessions:: SESSION_STATUS_RECORDING ;
1518use crate :: note_filter:: { apply_ai_decisions, filter_note, NoteFilterResult } ;
1619use crate :: types:: {
17- NewNote , NewSession , NewSessionOcrBlock , NewSessionSegment , Note , Session , SessionDetail ,
18- SessionOcrBlock , SessionSegment ,
20+ BatchNoteResult , ConvertedNote , NewNote , NewSession , NewSessionOcrBlock , NewSessionSegment ,
21+ Note , Session , SessionDetail , SessionListItem , SessionOcrBlock , SessionSegment , SkippedNote ,
1922} ;
23+ use crate :: ui_junk:: UiJunkList ;
2024
2125/// 会话列表单页上限。
2226const LIST_LIMIT_MAX : u64 = 200 ;
@@ -46,14 +50,14 @@ pub async fn finish_session(state: State<'_, AppState>, id: i64) -> Result<bool,
4650 state. db . finish_session ( id) . map_err ( |e| e. to_string ( ) )
4751}
4852
49- /// 列出会话(关键词可选;默认第 1 页 50 条,新→旧)。
53+ /// 列出会话(关键词可选;默认第 1 页 50 条,新→旧;v0.7.1 起携带转化状态标记 )。
5054#[ tauri:: command]
5155pub async fn list_sessions (
5256 state : State < ' _ , AppState > ,
5357 keyword : Option < String > ,
5458 limit : Option < u64 > ,
5559 offset : Option < u64 > ,
56- ) -> Result < Vec < Session > , String > {
60+ ) -> Result < Vec < SessionListItem > , String > {
5761 let limit = limit. unwrap_or ( 50 ) . min ( LIST_LIMIT_MAX ) ;
5862 state
5963 . db
@@ -145,53 +149,131 @@ pub async fn add_session_ocr_block(
145149
146150/// 加载会话笔记原料(会话 + 转写段 + OCR 块;单一管线双出口共用)。
147151///
148- /// @ai-context: REQ-081:预览与转笔记从同一原料装载开始,保证口径一致。
152+ /// @ai-context: REQ-081:预览与转笔记从同一原料装载开始,保证口径一致;
153+ /// v0.7.1 起注入 &Db(不再依赖 AppState)——批量编排可单测。
149154fn load_note_material (
150- state : & AppState ,
155+ db : & Db ,
151156 id : i64 ,
152157) -> Result < ( Session , Vec < SessionSegment > , Vec < SessionOcrBlock > ) , String > {
153158 if id <= 0 {
154159 return Err ( "无效的会话 id" . to_string ( ) ) ;
155160 }
156- let session = state
157- . db
161+ let session = db
158162 . get_session ( id)
159163 . map_err ( |e| e. to_string ( ) ) ?
160164 . ok_or_else ( || format ! ( "会话不存在: {}" , id) ) ?;
161165 if session. status == SESSION_STATUS_RECORDING {
162166 return Err ( "进行中的会话不能生成笔记,请先结束会话" . to_string ( ) ) ;
163167 }
164- let segments = state . db . list_segments ( id) . map_err ( |e| e. to_string ( ) ) ?;
165- let ocr_blocks = state . db . list_ocr_blocks ( id) . map_err ( |e| e. to_string ( ) ) ?;
168+ let segments = db. list_segments ( id) . map_err ( |e| e. to_string ( ) ) ?;
169+ let ocr_blocks = db. list_ocr_blocks ( id) . map_err ( |e| e. to_string ( ) ) ?;
166170 Ok ( ( session, segments, ocr_blocks) )
167171}
168172
169- /// 会话 → 笔记:转写段 + OCR 块经 note_filter 净化管线,一键落库(REQ-010 闭环 )。
173+ /// 会话 → 笔记核心(v0.7.1 提取:单条与批量共用同一管线 )。
170174///
171175/// @ai-context: REQ-082:过滤链(UI 垃圾/重复合并/碎片/低置信)与预览共用;
172176/// ai_decisions(REQ-085)可选叠加——前端把预览中已确认的 AI
173177/// 判定结果回传,落库与预览输出保持一致(默认 None=纯规则)。
174- /// @ai-context: 只允许 finished/failed 会话转换;source 沿用 classroom。
175- #[ tauri:: command]
176- pub async fn session_to_note (
177- state : State < ' _ , AppState > ,
178+ /// @ai-context: 只允许 finished/failed 会话转换;source 沿用 classroom;
179+ /// v0.7.1 起落库携带 session_id(列表 has_note/查看笔记跳转的数据源)。
180+ fn convert_to_note (
181+ db : & Db ,
182+ ui_junk : & UiJunkList ,
178183 id : i64 ,
179184 title : Option < String > ,
180185 ai_decisions : Option < Vec < TextFilterDecision > > ,
181186) -> Result < Note , String > {
182- let ( session, segments, ocr_blocks) = load_note_material ( & state , id) ?;
187+ let ( session, segments, ocr_blocks) = load_note_material ( db , id) ?;
183188 let fallback = format ! ( "{}(会话)" , session. title) ;
184189 let title = normalize_title ( title. unwrap_or_default ( ) , & fallback) ;
185- let mut result = filter_note ( & title, & segments, & ocr_blocks, & state . ui_junk ) ;
190+ let mut result = filter_note ( & title, & segments, & ocr_blocks, ui_junk) ;
186191 if let Some ( decisions) = ai_decisions {
187192 result = apply_ai_decisions ( result, & decisions) ;
188193 }
189194 let new = NewNote {
190195 title : result. title . clone ( ) ,
191196 content : result. markdown . clone ( ) ,
192197 source : "classroom" . to_string ( ) ,
198+ session_id : Some ( id) ,
193199 } ;
194- state. db . create_note ( & new) . map_err ( |e| e. to_string ( ) )
200+ db. create_note ( & new) . map_err ( |e| e. to_string ( ) )
201+ }
202+
203+ /// 会话 → 笔记:转写段 + OCR 块经 note_filter 净化管线,一键落库(REQ-010 闭环)。
204+ #[ tauri:: command]
205+ pub async fn session_to_note (
206+ state : State < ' _ , AppState > ,
207+ id : i64 ,
208+ title : Option < String > ,
209+ ai_decisions : Option < Vec < TextFilterDecision > > ,
210+ ) -> Result < Note , String > {
211+ convert_to_note ( & state. db , & state. ui_junk , id, title, ai_decisions)
212+ }
213+
214+ /// 批量转笔记核心编排(v0.7.1:部分成功语义——单条失败不阻塞其他)。
215+ ///
216+ /// @ai-context: 纯编排纯函数(注入 Db + UiJunkList,无 Tauri 依赖,可单测);
217+ /// 跳过规则显式回传原因(不静默):无效 id / 进行中 / 已转 / 会话不存在;
218+ /// DB 读错误视为硬失败中止(库损坏时继续处理无意义)。
219+ pub fn run_batch_conversion (
220+ db : & Db ,
221+ ui_junk : & UiJunkList ,
222+ ids : Vec < i64 > ,
223+ ) -> Result < BatchNoteResult , String > {
224+ if ids. len ( ) > 50 {
225+ return Err ( "批量转换上限 50 条" . to_string ( ) ) ;
226+ }
227+ let mut converted = Vec :: new ( ) ;
228+ let mut skipped = Vec :: new ( ) ;
229+ let mut seen = HashSet :: new ( ) ;
230+ for id in ids {
231+ if id <= 0 {
232+ skipped. push ( SkippedNote { session_id : id, reason : "无效的会话 id" . to_string ( ) } ) ;
233+ continue ;
234+ }
235+ if !seen. insert ( id) {
236+ continue ; // 重复 id 静默去重(同一会话只处理一次)
237+ }
238+ match db. get_session ( id) {
239+ Ok ( Some ( s) ) if s. status == SESSION_STATUS_RECORDING => {
240+ skipped. push ( SkippedNote {
241+ session_id : id,
242+ reason : "进行中的会话不能生成笔记" . to_string ( ) ,
243+ } ) ;
244+ }
245+ Ok ( Some ( _) ) => {
246+ // 已转跳过(防重复笔记);详情页单条转换保留"有意重新生成"路径
247+ if db. find_note_by_session ( id) . map_err ( |e| e. to_string ( ) ) ?. is_some ( ) {
248+ skipped. push ( SkippedNote {
249+ session_id : id,
250+ reason : "已转笔记(批量转跳过,避免重复)" . to_string ( ) ,
251+ } ) ;
252+ continue ;
253+ }
254+ match convert_to_note ( db, ui_junk, id, None , None ) {
255+ Ok ( note) => converted. push ( ConvertedNote { session_id : id, note_id : note. id } ) ,
256+ Err ( reason) => skipped. push ( SkippedNote { session_id : id, reason } ) ,
257+ }
258+ }
259+ Ok ( None ) => {
260+ skipped. push ( SkippedNote { session_id : id, reason : "会话不存在" . to_string ( ) } ) ;
261+ }
262+ Err ( e) => {
263+ skipped. push ( SkippedNote { session_id : id, reason : e. to_string ( ) } ) ;
264+ }
265+ }
266+ }
267+ Ok ( BatchNoteResult { converted, skipped } )
268+ }
269+
270+ /// 批量转笔记(v0.7.1 会话体验:列表勾选批量转化)。
271+ #[ tauri:: command]
272+ pub async fn batch_session_to_note (
273+ state : State < ' _ , AppState > ,
274+ ids : Vec < i64 > ,
275+ ) -> Result < BatchNoteResult , String > {
276+ run_batch_conversion ( & state. db , & state. ui_junk , ids)
195277}
196278
197279/// 会话笔记预览(REQ-081):过滤后只读预览——不落库、不改库。
@@ -204,7 +286,7 @@ pub async fn preview_session_note(
204286 state : State < ' _ , AppState > ,
205287 id : i64 ,
206288) -> Result < NoteFilterResult , String > {
207- let ( session, segments, ocr_blocks) = load_note_material ( & state, id) ?;
289+ let ( session, segments, ocr_blocks) = load_note_material ( & state. db , id) ?;
208290 Ok ( filter_note ( & session. title , & segments, & ocr_blocks, & state. ui_junk ) )
209291}
210292
@@ -278,12 +360,12 @@ fn is_cjk_num(c: char) -> bool {
278360 "零〇一二三四五六七八九十百" . contains ( c)
279361}
280362
281- /// 课程分组(会话列表按课程键分组,组内新→旧)。
363+ /// 课程分组(会话列表按课程键分组,组内新→旧;v0.7.1:组内携带转化状态标记 )。
282364#[ derive( Debug , Clone , serde:: Serialize ) ]
283365#[ serde( rename_all = "camelCase" ) ]
284366pub struct CourseGroup {
285367 pub course : String ,
286- pub sessions : Vec < Session > ,
368+ pub sessions : Vec < SessionListItem > ,
287369}
288370
289371/// 会话列表按课程分组(REQ-078:长列表可折叠)。
@@ -293,9 +375,10 @@ pub async fn list_session_courses(state: State<'_, AppState>) -> Result<Vec<Cour
293375 . db
294376 . list_sessions ( None , LIST_LIMIT_MAX , 0 )
295377 . map_err ( |e| e. to_string ( ) ) ?;
296- let mut groups: std:: collections:: BTreeMap < String , Vec < Session > > = std:: collections:: BTreeMap :: new ( ) ;
378+ let mut groups: std:: collections:: BTreeMap < String , Vec < SessionListItem > > =
379+ std:: collections:: BTreeMap :: new ( ) ;
297380 for s in sessions {
298- groups. entry ( course_of ( & s. title ) ) . or_default ( ) . push ( s) ;
381+ groups. entry ( course_of ( & s. session . title ) ) . or_default ( ) . push ( s) ;
299382 }
300383 Ok ( groups
301384 . into_iter ( )
@@ -329,7 +412,8 @@ pub async fn search_session_segments(
329412 return Ok ( Vec :: new ( ) ) ;
330413 }
331414 let mut hits = Vec :: new ( ) ;
332- for session in state. db . list_sessions ( None , LIST_LIMIT_MAX , 0 ) . map_err ( |e| e. to_string ( ) ) ? {
415+ for item in state. db . list_sessions ( None , LIST_LIMIT_MAX , 0 ) . map_err ( |e| e. to_string ( ) ) ? {
416+ let session = & item. session ;
333417 for s in state. db . list_segments ( session. id ) . map_err ( |e| e. to_string ( ) ) ? {
334418 if s. text . to_lowercase ( ) . contains ( & kw) {
335419 hits. push ( SegmentHit {
@@ -384,3 +468,8 @@ fn snippet_around(text: &str, kw_lower: &str) -> String {
384468 }
385469 snippet
386470}
471+
472+ /// 批量转笔记单测独立文件(保持本文件 ≤300 行目标,AGENTS.md §3)。
473+ #[ cfg( test) ]
474+ #[ path = "commands_session_batch_tests.rs" ]
475+ mod batch_tests;
0 commit comments