1111 */
1212import { useCallback , useEffect , useRef , useState } from "react" ;
1313import { invoke } from "@tauri-apps/api/core" ;
14+ import SopRunOverlay from "./SopRunOverlay" ;
15+
16+ interface SopTemplateView {
17+ id : number ;
18+ note_id : number ;
19+ name : string ;
20+ start_line : number ;
21+ end_line : number ;
22+ mode : string ;
23+ note_title : string ;
24+ }
1425
1526interface ActionRow {
1627 id : number ;
@@ -64,6 +75,7 @@ const cardStyle: React.CSSProperties = {
6475const btn : React . CSSProperties = { padding : "4px 10px" , cursor : "pointer" , fontSize : 12 , borderRadius : 6 , border : "1px solid #e5e7eb" , background : "#fff" , color : "#374151" } ;
6576const okBtn : React . CSSProperties = { ...btn , background : "#0d9488" , color : "#fff" , border : "none" } ;
6677const dangerBtn : React . CSSProperties = { ...btn , color : "#dc2626" } ;
78+ const ghostBtn : React . CSSProperties = { ...btn } ;
6779
6880const TYPE_LABEL : Record < string , string > = {
6981 done : "✓ 完成" ,
@@ -81,7 +93,16 @@ function fmtDate(ts: number): string {
8193}
8294
8395export default function ActionCenterOverlay ( { onClose, onChanged } : Props ) {
84- const [ tab , setTab ] = useState < "queue" | "history" > ( "queue" ) ;
96+ const [ tab , setTab ] = useState < "queue" | "history" | "sop" > ( "queue" ) ;
97+ // SOP 库(模板/新建/执行——REQ-296)
98+ const [ templates , setTemplates ] = useState < SopTemplateView [ ] > ( [ ] ) ;
99+ const [ notes , setNotes ] = useState < { id : number ; title : string } [ ] > ( [ ] ) ;
100+ const [ activeTemplate , setActiveTemplate ] = useState < SopTemplateView | null > ( null ) ;
101+ const [ sopNoteId , setSopNoteId ] = useState < number | null > ( null ) ;
102+ const [ sopName , setSopName ] = useState ( "" ) ;
103+ const [ sopStart , setSopStart ] = useState ( "0" ) ;
104+ const [ sopEnd , setSopEnd ] = useState ( "0" ) ;
105+ const [ suggestions , setSuggestions ] = useState < Record < number , string [ ] > > ( { } ) ;
85106 // 裁决队列(四分区同拉全量行,前端按 tab 分区展示——逾期标红/计划/搁置/提炼)
86107 const [ rows , setRows ] = useState < ActionRow [ ] > ( [ ] ) ;
87108 const [ history , setHistory ] = useState < HistoryRow [ ] > ( [ ] ) ;
@@ -134,8 +155,62 @@ export default function ActionCenterOverlay({ onClose, onChanged }: Props) {
134155 setUnrefined ( u ) ;
135156 } ) ( ) ;
136157 void reload ( ) ;
158+ void loadSop ( ) ;
159+ // eslint-disable-next-line react-hooks/exhaustive-deps
137160 } , [ fetchTab , reload ] ) ;
138161
162+ const loadSop = useCallback ( async ( ) => {
163+ try {
164+ const [ t , n ] = await Promise . all ( [
165+ invoke < SopTemplateView [ ] > ( "sop_template_list" , { noteId : null } ) ,
166+ invoke < { id : number ; title : string } [ ] > ( "list_notes" ) ,
167+ ] ) ;
168+ setTemplates ( t ) ;
169+ setNotes ( n ) ;
170+ if ( sopNoteId == null && n . length > 0 ) setSopNoteId ( n [ 0 ] . id ) ;
171+ } catch ( e ) {
172+ setErr ( `SOP 库加载失败: ${ e } ` ) ;
173+ }
174+ } , [ sopNoteId ] ) ;
175+
176+ const createTemplate = async ( ) => {
177+ const start = Number . parseInt ( sopStart , 10 ) ;
178+ const end = Number . parseInt ( sopEnd , 10 ) ;
179+ if ( sopNoteId == null ) { setErr ( "请先选择笔记" ) ; return ; }
180+ if ( ! sopName . trim ( ) ) { setErr ( "请输入模板名" ) ; return ; }
181+ if ( ! Number . isInteger ( start ) || ! Number . isInteger ( end ) || start < 0 || end < start ) {
182+ setErr ( "行范围非法(0 起整数,end ≥ start)" ) ;
183+ return ;
184+ }
185+ try {
186+ await invoke ( "sop_template_create" , { noteId : sopNoteId , name : sopName . trim ( ) , startLine : start , endLine : end , mode : null } ) ;
187+ setSopName ( "" ) ;
188+ setMsg ( `✓ 模板「${ sopName . trim ( ) } 」已创建(段落行引用,编辑正文即编辑模板)` ) ;
189+ await loadSop ( ) ;
190+ } catch ( e ) {
191+ setErr ( String ( e ) ) ;
192+ }
193+ } ;
194+
195+ const fetchSuggestions = async ( templateId : number ) => {
196+ try {
197+ const list = await invoke < string [ ] > ( "sop_revision_suggestions" , { templateId } ) ;
198+ setSuggestions ( ( m ) => ( { ...m , [ templateId ] : list } ) ) ;
199+ } catch ( e ) {
200+ setErr ( String ( e ) ) ;
201+ }
202+ } ;
203+
204+ const deleteTemplate = async ( t : SopTemplateView ) => {
205+ try {
206+ await invoke ( "sop_template_delete" , { templateId : t . id } ) ;
207+ setMsg ( `已删除模板「${ t . name } 」(正文未动)` ) ;
208+ await loadSop ( ) ;
209+ } catch ( e ) {
210+ setErr ( String ( e ) ) ;
211+ }
212+ } ;
213+
139214 const afterMutate = async ( info ?: string ) => {
140215 if ( info ) setMsg ( info ) ;
141216 onChanged ?.( ) ;
@@ -253,6 +328,7 @@ export default function ActionCenterOverlay({ onClose, onChanged }: Props) {
253328 [
254329 [ "queue" , `裁决队列(${ overdue . length + planned . length + rows . length + unrefined . length } )` ] ,
255330 [ "history" , `完成史(${ history . length } )` ] ,
331+ [ "sop" , `SOP 库(${ templates . length } )` ] ,
256332 ] as const
257333 ) . map ( ( [ k , label ] ) => (
258334 < button
@@ -301,7 +377,7 @@ export default function ActionCenterOverlay({ onClose, onChanged }: Props) {
301377 ) ) }
302378 </ div >
303379 </ >
304- ) : (
380+ ) : tab === "history" ? (
305381 < div style = { { display : "flex" , flexDirection : "column" , gap : 5 } } >
306382 { history . length === 0 ? empty ( "暂无完成记录——完成即证据,周回顾原料在此沉淀" ) : history . map ( ( h ) => (
307383 < div key = { h . id } style = { { display : "flex" , gap : 8 , fontSize : 12 , borderBottom : "1px solid #f3f4f6" , padding : "4px 2px" } } >
@@ -314,10 +390,67 @@ export default function ActionCenterOverlay({ onClose, onChanged }: Props) {
314390 </ div >
315391 ) ) }
316392 </ div >
393+ ) : (
394+ < >
395+ { /* SOP 库:模板行范围引用 + 执行(REQ-296) */ }
396+ < div style = { { fontSize : 12 , color : "#374151" , marginBottom : 8 } } >
397+ < b > SOP 模板({ templates . length } )</ b > { " " }
398+ < span style = { { color : "#9ca3af" , fontSize : 11 } } > 模板=笔记段落行范围引用(编辑正文即编辑模板,无双写);执行=步骤快照跑 run</ span >
399+ </ div >
400+ < div style = { { border : "1px solid #e5e7eb" , borderRadius : 8 , padding : 8 , marginBottom : 10 } } >
401+ < div style = { { display : "flex" , gap : 4 , flexWrap : "wrap" , alignItems : "center" } } >
402+ < span style = { { fontSize : 11 , color : "#6b7280" } } > 新建:</ span >
403+ < select value = { sopNoteId ?? undefined } onChange = { ( e ) => setSopNoteId ( Number ( e . target . value ) ) } style = { { fontSize : 12 , maxWidth : 180 } } >
404+ { notes . map ( ( n ) => < option key = { n . id } value = { n . id } > { n . title . slice ( 0 , 18 ) } </ option > ) }
405+ </ select >
406+ < input value = { sopName } onChange = { ( e ) => setSopName ( e . target . value ) } placeholder = "模板名" style = { { fontSize : 12 , width : 110 , border : "1px solid #e5e7eb" , borderRadius : 4 , padding : "2px 6px" } } />
407+ < span style = { { fontSize : 11 , color : "#9ca3af" } } > 行</ span >
408+ < input value = { sopStart } onChange = { ( e ) => setSopStart ( e . target . value ) } style = { { fontSize : 12 , width : 44 , border : "1px solid #e5e7eb" , borderRadius : 4 , padding : "2px 6px" } } />
409+ < span style = { { fontSize : 11 , color : "#9ca3af" } } > –</ span >
410+ < input value = { sopEnd } onChange = { ( e ) => setSopEnd ( e . target . value ) } style = { { fontSize : 12 , width : 44 , border : "1px solid #e5e7eb" , borderRadius : 4 , padding : "2px 6px" } } />
411+ < button style = { okBtn } onClick = { ( ) => void createTemplate ( ) } > 创建</ button >
412+ </ div >
413+ < div style = { { fontSize : 11 , color : "#9ca3af" , marginTop : 4 } } >
414+ 行号 0 起(标题=0);空行自动跳过;超 50 步拒绝。编辑器内选中段落生成入口在笔记工具栏接线(同款命令)。
415+ </ div >
416+ </ div >
417+ { templates . length === 0 ? (
418+ < p style = { { fontSize : 12 , color : "#9ca3af" } } > 暂无 SOP 模板——选中笔记步骤段落(行范围)即可创建</ p >
419+ ) : (
420+ < div style = { { display : "flex" , flexDirection : "column" , gap : 6 } } >
421+ { templates . map ( ( t ) => (
422+ < div key = { t . id } style = { { border : "1px solid #e5e7eb" , borderRadius : 8 , padding : "6px 10px" } } >
423+ < div style = { { display : "flex" , gap : 6 , alignItems : "center" } } >
424+ < span style = { { fontSize : 12.5 , fontWeight : 600 , color : "#111827" } } > { t . name } </ span >
425+ < span style = { { fontSize : 11 , color : "#9ca3af" } } > @{ t . note_title } · 行 { t . start_line } –{ t . end_line } · { t . mode === "confirm" ? "总览核对" : "逐步引导" } </ span >
426+ < span style = { { marginLeft : "auto" , display : "flex" , gap : 4 } } >
427+ < button style = { okBtn } onClick = { ( ) => setActiveTemplate ( t ) } > ▶ 执行</ button >
428+ < button style = { ghostBtn } onClick = { ( ) => void fetchSuggestions ( t . id ) } > 💡 修订建议</ button >
429+ < button style = { { ...ghostBtn , color : "#dc2626" } } onClick = { ( ) => void deleteTemplate ( t ) } > 删除</ button >
430+ </ span >
431+ </ div >
432+ { ( suggestions [ t . id ] ?. length ?? 0 ) > 0 && (
433+ < div style = { { fontSize : 11 , color : "#b45309" , marginTop : 4 } } >
434+ { suggestions [ t . id ] . map ( ( s , i ) => < div key = { i } > 💡 { s } </ div > ) }
435+ </ div >
436+ ) }
437+ </ div >
438+ ) ) }
439+ </ div >
440+ ) }
441+ </ >
317442 ) }
318443 < p style = { { fontSize : 11 , color : "#9ca3af" , marginTop : 10 } } >
319- 勾选任务行即被自动收录;产物 ☑️ 行可在上方「待提炼」区一键转标准任务行;🎴 转卡为规划中出口(G7 预留)。
444+ 勾选任务行即被自动收录;产物 ☑️ 行可在上方「待提炼」区一键转标准任务行;🎴 转卡为规划中出口(G7 预留)。SOP run 完成自动入完成史。
320445 </ p >
446+ { /* v0.20.3(REQ-296):SOP 执行器(嵌套 Overlay——覆盖本面板) */ }
447+ { activeTemplate && (
448+ < SopRunOverlay
449+ template = { activeTemplate }
450+ onClose = { ( ) => setActiveTemplate ( null ) }
451+ onChanged = { ( ) => { void loadSop ( ) ; onChanged ?.( ) ; } }
452+ />
453+ ) }
321454 </ div >
322455 </ div >
323456 ) ;
0 commit comments