@@ -69,13 +69,20 @@ def __init__(self, main_app):
69
69
self ._is_programmatic_tab_change = False
70
70
self .is_dialog_open = False
71
71
72
+ # 撤销/重做功能
73
+ self .undo_stack = []
74
+ self .redo_stack = []
75
+ self .undo_button = None
76
+ self .redo_button = None
77
+
72
78
# 为临时开关创建BooleanVar
73
79
self .auto_complete_var = tk .BooleanVar (value = self .main_app .config_handler .auto_complete_end_time )
74
80
self .auto_calculate_var = tk .BooleanVar (value = self .main_app .config_handler .auto_calculate_next_course )
75
81
76
82
self ._initialize_ui ()
77
83
self ._create_schedule_selector ()
78
84
self ._create_batch_operations_bar () # 添加批量操作按钮栏
85
+ self .window .after (100 , self ._initial_state_capture ) # 捕获初始状态
79
86
except Exception as e :
80
87
logger .log_error (e )
81
88
raise
@@ -205,6 +212,7 @@ def _rename_schedule(self):
205
212
self .schedule_combobox ['values' ] = list (self .main_app .schedule ["schedules" ].keys ())
206
213
self .schedule_combobox .set (new_name )
207
214
messagebox .showinfo ("成功" , "课表已重命名" )
215
+ self ._clear_history ()
208
216
except Exception as e :
209
217
logger .log_error (e )
210
218
messagebox .showerror ("错误" , f"重命名失败: { str (e )} " )
@@ -245,6 +253,7 @@ def _copy_schedule(self):
245
253
self .main_app .schedule ["current_schedule" ] = new_name
246
254
self ._update_ui_with_new_schedule ()
247
255
self ._reset_modified_flag ()
256
+ self ._clear_history ()
248
257
249
258
def _add_new_schedule (self ):
250
259
"""添加新课表"""
@@ -271,6 +280,7 @@ def _add_new_schedule(self):
271
280
self ._update_ui_with_new_schedule ()
272
281
# 标记为未修改
273
282
self ._reset_modified_flag ()
283
+ self ._clear_history ()
274
284
finally :
275
285
self .is_dialog_open = False
276
286
@@ -296,6 +306,7 @@ def _delete_schedule(self):
296
306
self .schedule_combobox ['values' ] = list (self .main_app .schedule ["schedules" ].keys ())
297
307
self .schedule_combobox .set (new_schedule )
298
308
self ._on_schedule_change ()
309
+ self ._clear_history ()
299
310
finally :
300
311
self .is_dialog_open = False
301
312
@@ -336,6 +347,7 @@ def _on_schedule_change(self, event=None):
336
347
self .schedule_times [new_schedule ] = []
337
348
self ._update_ui_with_new_schedule ()
338
349
self ._reset_modified_flag ()
350
+ self ._clear_history ()
339
351
finally :
340
352
self .is_dialog_open = False
341
353
@@ -414,8 +426,10 @@ def _on_tab_changed(self, event):
414
426
if response is True : # Yes
415
427
self ._save_day (self .previous_tab_index )
416
428
self ._reset_modified_flag ()
429
+ self ._clear_history ()
417
430
elif response is False : # No
418
431
self ._reset_modified_flag ()
432
+ self ._clear_history ()
419
433
else : # Cancel
420
434
self ._is_programmatic_tab_change = True
421
435
self .notebook .select (self .previous_tab_index )
@@ -425,6 +439,7 @@ def _on_tab_changed(self, event):
425
439
try :
426
440
self .create_day_ui (self .day_frames [new_tab_index ], str (new_tab_index ))
427
441
self .previous_tab_index = new_tab_index
442
+ self ._clear_history ()
428
443
except tk .TclError :
429
444
pass # 窗口关闭时可能会引发此错误
430
445
def _on_close (self ):
@@ -450,6 +465,115 @@ def _on_close(self):
450
465
finally :
451
466
self .is_dialog_open = False
452
467
468
+ def _initial_state_capture (self ):
469
+ """捕获初始状态"""
470
+ self ._capture_state (initial = True )
471
+
472
+ def _capture_state (self , initial = False ):
473
+ """捕获当前活动标签页的UI状态,并将其存入撤销栈"""
474
+ current_tab_index = self .notebook .index (self .notebook .select ())
475
+ day_frame = self .day_frames [current_tab_index ]
476
+
477
+ state = []
478
+ # 按Y坐标排序以确保顺序一致
479
+ visible_rows = sorted (
480
+ [row for row in day_frame .winfo_children () if isinstance (row , tk .Frame ) and hasattr (row , 'row_id' )],
481
+ key = lambda w : w .winfo_y ()
482
+ )
483
+
484
+ for row_frame in visible_rows :
485
+ entries = [w for w in row_frame .winfo_children () if isinstance (w , tk .Entry )]
486
+ if len (entries ) >= 3 :
487
+ state .append ({
488
+ "start_time" : entries [0 ].get (),
489
+ "end_time" : entries [1 ].get (),
490
+ "name" : entries [2 ].get ()
491
+ })
492
+
493
+ # 如果是初始状态,直接设置
494
+ if initial :
495
+ self .undo_stack .append (state )
496
+ self ._update_undo_redo_buttons ()
497
+ return
498
+
499
+ # 避免重复记录完全相同的状态
500
+ if self .undo_stack and self .undo_stack [- 1 ] == state :
501
+ return
502
+
503
+ self .undo_stack .append (state )
504
+ self .redo_stack .clear () # 任何新操作都会清空重做栈
505
+ self ._update_undo_redo_buttons ()
506
+ self .modified = True # 任何记录的操作都应标记为修改
507
+
508
+ def _restore_state (self , state ):
509
+ """根据给定状态恢复UI"""
510
+ current_tab_index = self .notebook .index (self .notebook .select ())
511
+ day_frame = self .day_frames [current_tab_index ]
512
+
513
+ # 清除现有UI
514
+ for widget in day_frame .winfo_children ():
515
+ widget .destroy ()
516
+
517
+ # 根据状态重建UI
518
+ for i , course in enumerate (state ):
519
+ self .add_course_row (day_frame , i , course , record_state = False )
520
+
521
+ # 重建“添加课程”按钮
522
+ style = ttk .Style ()
523
+ style .configure ("AddSchedule.TButton" , font = ("微软雅黑" , 8 ), padding = 5 )
524
+ btn_frame = tk .Frame (day_frame , bg = "white" )
525
+ btn_frame .pack (pady = 5 )
526
+ ttk .Button (btn_frame , text = "添加课程" ,
527
+ command = lambda : self .add_course_row (day_frame , len (state )),
528
+ style = "AddSchedule.TButton" ).pack (side = tk .LEFT , padx = 2 )
529
+
530
+ # 只有当恢复后的状态与初始状态不同时,才标记为已修改
531
+ if self .undo_stack and state != self .undo_stack [0 ]:
532
+ self .modified = True
533
+ else :
534
+ self .modified = False
535
+
536
+ def _undo (self ):
537
+ """执行撤销操作"""
538
+ if len (self .undo_stack ) > 1 :
539
+ current_state = self .undo_stack .pop ()
540
+ self .redo_stack .append (current_state )
541
+
542
+ last_state = self .undo_stack [- 1 ]
543
+ self ._restore_state (last_state )
544
+ self ._update_undo_redo_buttons ()
545
+
546
+ # 检查是否回到了初始状态
547
+ if self .undo_stack and last_state == self .undo_stack [0 ]:
548
+ self .modified = False
549
+ else :
550
+ self .modified = True
551
+
552
+ def _redo (self ):
553
+ """执行重做操作"""
554
+ if self .redo_stack :
555
+ state_to_restore = self .redo_stack .pop ()
556
+ self .undo_stack .append (state_to_restore )
557
+ self ._restore_state (state_to_restore )
558
+ self ._update_undo_redo_buttons ()
559
+ self .modified = True # 任何重做操作都意味着有修改
560
+
561
+ def _clear_history (self ):
562
+ """清空撤销和重做历史"""
563
+ self .undo_stack .clear ()
564
+ self .redo_stack .clear ()
565
+ # 捕获清空后的初始状态
566
+ self .window .after (50 , self ._initial_state_capture )
567
+ self ._update_undo_redo_buttons ()
568
+
569
+ def _update_undo_redo_buttons (self ):
570
+ """更新撤销和重做按钮的状态"""
571
+ if self .undo_button and self .redo_button :
572
+ # 撤销按钮:当栈中有多于一个状态时(初始状态之外还有其他状态)才可点击
573
+ self .undo_button .config (state = "normal" if len (self .undo_stack ) > 1 else "disabled" )
574
+ # 重做按钮:当重做栈不为空时可点击
575
+ self .redo_button .config (state = "normal" if self .redo_stack else "disabled" )
576
+
453
577
def _create_batch_operations_bar (self ) -> None :
454
578
"""创建批量操作按钮栏"""
455
579
style = ttk .Style ()
@@ -469,6 +593,13 @@ def _create_batch_operations_bar(self) -> None:
469
593
ttk .Button (batch_frame , text = "☑ 全选" ,
470
594
command = self ._select_all ,
471
595
style = "Small.TButton" ).pack (side = tk .LEFT , padx = 4 )
596
+
597
+ # 添加撤销和重做按钮
598
+ self .undo_button = ttk .Button (batch_frame , text = "↶" , command = self ._undo , style = "Small.TButton" , state = "disabled" , width = 3 )
599
+ self .undo_button .pack (side = tk .LEFT , padx = (10 , 2 ))
600
+
601
+ self .redo_button = ttk .Button (batch_frame , text = "↷" , command = self ._redo , style = "Small.TButton" , state = "disabled" , width = 3 )
602
+ self .redo_button .pack (side = tk .LEFT , padx = 2 )
472
603
473
604
# 批量操作按钮 (右侧)
474
605
ttk .Button (batch_frame , text = "导入课程" ,
@@ -556,7 +687,7 @@ def create_day_ui(self, frame, day):
556
687
557
688
# 绘制课程行
558
689
for i , course in enumerate (courses_to_display ):
559
- self .add_course_row (frame , i , course )
690
+ self .add_course_row (frame , i , course , record_state = False )
560
691
561
692
# 更新课程名称建议
562
693
self .all_courses = self ._get_all_courses ()
@@ -571,11 +702,13 @@ def create_day_ui(self, frame, day):
571
702
# "添加课程"按钮
572
703
# 使用 len(courses_to_display) 来确保索引正确
573
704
ttk .Button (btn_frame , text = "添加课程" ,
574
- command = lambda : self .add_course_row (frame , len (courses_to_display )),
705
+ command = lambda : self .add_course_row (frame , len (courses_to_display ), record_state = True ),
575
706
style = "AddSchedule.TButton" ).pack (side = tk .LEFT , padx = 2 )
576
707
577
708
578
- def add_course_row (self , parent_frame , index , course = None ):
709
+ def add_course_row (self , parent_frame , index , course = None , record_state = True ):
710
+ if record_state and course is None : # 仅在手动添加新行时记录
711
+ self ._capture_state ()
579
712
row_frame = tk .Frame (parent_frame , bg = "white" , bd = 0 , relief = tk .FLAT )
580
713
row_frame .pack (fill = tk .X , pady = 4 , padx = 2 )
581
714
# 生成唯一且稳定的行ID
@@ -611,6 +744,7 @@ def show_start_time_picker():
611
744
start_time_entry .delete (0 , tk .END )
612
745
start_time_entry .insert (0 , picker .selected_time )
613
746
calculate_end_time ()
747
+ self ._capture_state ()
614
748
615
749
ttk .Button (row_frame , text = "🕒" , command = show_start_time_picker ,
616
750
style = "Editor.TButton" ).pack (side = tk .LEFT , padx = 2 )
@@ -628,6 +762,7 @@ def show_end_time_picker():
628
762
if picker .selected_time :
629
763
end_time_entry .delete (0 , tk .END )
630
764
end_time_entry .insert (0 , picker .selected_time )
765
+ self ._capture_state ()
631
766
632
767
ttk .Button (row_frame , text = "🕒" , command = show_end_time_picker ,
633
768
style = "Editor.TButton" ).pack (side = tk .LEFT , padx = 2 )
@@ -637,6 +772,7 @@ def show_end_time_picker():
637
772
if course and course ["name" ]:
638
773
name_entry .insert (0 , course ["name" ])
639
774
name_entry .pack (side = tk .LEFT , padx = 2 , expand = True , fill = tk .X )
775
+ name_entry .bind ("<FocusOut>" , lambda e : self ._capture_state ())
640
776
641
777
# 历史课程选择框
642
778
history_var = tk .StringVar ()
@@ -650,6 +786,7 @@ def on_history_select(event):
650
786
if selected_course :
651
787
name_entry .delete (0 , tk .END )
652
788
name_entry .insert (0 , selected_course )
789
+ self ._capture_state ()
653
790
654
791
history_combobox .bind ("<<ComboboxSelected>>" , on_history_select )
655
792
@@ -776,13 +913,13 @@ def fix_time_format(time_str):
776
913
end_time_entry .config (fg = "red" )
777
914
return False
778
915
779
- start_time_entry .bind ("<FocusOut>" , lambda e : [calculate_end_time (), validate_time ()])
916
+ start_time_entry .bind ("<FocusOut>" , lambda e : [calculate_end_time (), validate_time (), self . _capture_state () ])
780
917
781
918
# 仅在用户添加新行时(即 course is None)自动计算下一个课程时间
782
919
if course is None :
783
920
calculate_next_course_time ()
784
921
785
- end_time_entry .bind ("<FocusOut>" , lambda e : validate_time ())
922
+ end_time_entry .bind ("<FocusOut>" , lambda e : [ validate_time (), self . _capture_state ()] )
786
923
787
924
# 删除按钮
788
925
ttk .Button (row_frame , text = "×" , command = lambda : self .delete_course_row (row_frame ),
@@ -801,11 +938,14 @@ def move_down():
801
938
style = "Editor.TButton" , width = 2 ).pack (side = tk .RIGHT , padx = 2 )
802
939
803
940
def delete_course_row (self , row_frame ):
941
+ self ._capture_state ()
804
942
row_frame .destroy ()
805
943
# 标记为已修改
806
944
self .modified = True
945
+ self ._capture_state ()
807
946
808
947
def move_course_row (self , row_frame , direction ):
948
+ self ._capture_state ()
809
949
"""移动课程行位置 - 更安全的实现"""
810
950
parent = row_frame .master
811
951
if not parent .winfo_exists ():
@@ -936,6 +1076,7 @@ def move_course_row(self, row_frame, direction):
936
1076
except Exception as e :
937
1077
logger .log_error (f"日志记录错误: { str (e )} " )
938
1078
self .modified = True
1079
+ self ._capture_state ()
939
1080
940
1081
except Exception as e :
941
1082
logger .log_error (f"移动行失败: { str (e )} " )
@@ -987,6 +1128,10 @@ def move_course_row(self, row_frame, direction):
987
1128
988
1129
989
1130
def _batch_delete (self ):
1131
+ if not self .selected_rows :
1132
+ messagebox .showwarning ("提示" , "请先选中要删除的课程" )
1133
+ return
1134
+ self ._capture_state ()
990
1135
"""批量删除选中课程"""
991
1136
if self .is_dialog_open :
992
1137
return
@@ -1006,6 +1151,7 @@ def _batch_delete(self):
1006
1151
1007
1152
self .selected_rows .clear ()
1008
1153
self .modified = True
1154
+ self ._capture_state ()
1009
1155
finally :
1010
1156
self .is_dialog_open = False
1011
1157
@@ -1046,6 +1192,7 @@ def _copy_selected(self):
1046
1192
self .is_dialog_open = False
1047
1193
1048
1194
def _import_from_clipboard (self ):
1195
+ self ._capture_state ()
1049
1196
"""从剪贴板导入课程"""
1050
1197
if self .is_dialog_open :
1051
1198
return
@@ -1075,6 +1222,7 @@ def _import_from_clipboard(self):
1075
1222
1076
1223
messagebox .showinfo ("成功" , f"已导入{ len (courses )} 个课程" )
1077
1224
self .modified = True
1225
+ self ._capture_state ()
1078
1226
1079
1227
except json .JSONDecodeError :
1080
1228
messagebox .showerror ("错误" , "剪贴板中没有有效的课程数据" )
@@ -1174,6 +1322,7 @@ def save(self, show_message=True):
1174
1322
selected_tab_index = self .notebook .index (self .notebook .select ())
1175
1323
self ._save_day (selected_tab_index )
1176
1324
self ._reset_modified_flag ()
1325
+ self ._clear_history ()
1177
1326
1178
1327
if show_message :
1179
1328
messagebox .showinfo ("成功" , f"课表'{ self .current_schedule } '已保存" )
0 commit comments