@@ -69,7 +69,7 @@ def _format_run_summary(raw_run: Dict[str, Any]) -> Dict[str, Any]:
6969
7070# _progress 的初始结构,每次 start_sync 前重置为此形态
7171_PROGRESS_INIT : Dict [str , Any ] = {
72- "status" : "idle" , # idle / syncing / done / error
72+ "status" : "idle" , # idle / syncing / confirm_needed / done / error
7373 "current_product" : "" , # 最近完成的产品名
7474 "completed" : 0 , # 已完成产品数
7575 "total" : 0 , # 本次同步产品总数
@@ -78,6 +78,7 @@ def _format_run_summary(raw_run: Dict[str, Any]) -> Dict[str, Any]:
7878 "run_summary" : None , # 同步完成后填充 run_summary dict
7979 "products" : [], # 已完成产品列表 [{name, status, elapsed_seconds, files_count}]
8080 "all_products" : [], # 全部产品名列表(由 progress_callback 初始化调用时传入)
81+ "estimate" : None , # EstimateResult 的 dict 表示(confirm_needed 时填充)
8182}
8283
8384
@@ -94,6 +95,11 @@ def __init__(self) -> None:
9495 self ._health_progress : Dict [str , Any ] = {
9596 "checking" : False , "current" : 0 , "total" : 0 , "product" : "" , "result" : None ,
9697 }
98+ # 同步确认事件(每次 _run_sync 重置,用于与前端双向通信)
99+ self ._confirm_event : threading .Event = threading .Event ()
100+ self ._confirm_result : bool = False
101+ # 明确标记用户是否主动取消(区别于凭证错误等其他 error 场景)
102+ self ._was_cancelled : bool = False
97103
98104 # ------------------------------------------------------------------
99105 # 内部辅助方法
@@ -293,7 +299,7 @@ def get_config(self) -> Dict[str, Any]:
293299 }
294300
295301 def run_setup (self , data_root : str , api_key : str , hid : str ,
296- create_dir : bool = False ) -> dict :
302+ create_dir : bool = False , course_type : str = "basic" ) -> dict :
297303 """GUI setup 向导调用。先保存配置,再验证连通性。
298304
299305 流程:
@@ -330,10 +336,15 @@ def run_setup(self, data_root: str, api_key: str, hid: str,
330336 secrets_file = DEFAULT_USER_SECRETS_FILE .resolve ()
331337
332338 try :
339+ # basic 固定 10 次/天,premium 固定 100 次/天
340+ _limit_map = {"basic" : 10 , "premium" : 100 }
341+ _api_call_limit = _limit_map .get (course_type , 50 )
333342 config = UserConfig (
334343 data_root = str (dr ),
335344 product_mode = "local_scan" ,
336345 default_products = [],
346+ course_type = course_type ,
347+ api_call_limit = _api_call_limit ,
337348 )
338349 save_setup_artifacts_atomic (
339350 config_path = config_file ,
@@ -386,6 +397,23 @@ def run_setup(self, data_root: str, api_key: str, hid: str,
386397 "warning" : "配置已保存,但连接验证未通过,请检查网络连接" ,
387398 }
388399
400+ def confirm_sync (self ) -> Dict [str , Any ]:
401+ """前端点击"继续同步"时调用,唤醒后台线程继续执行。"""
402+ self ._confirm_result = True
403+ self ._confirm_event .set ()
404+ return {"ok" : True }
405+
406+ def cancel_sync (self ) -> Dict [str , Any ]:
407+ """前端点击"取消"时调用,唤醒后台线程并标记取消。
408+
409+ 只设置取消标志和事件,最终状态由 _run_sync 的 exit_code==-1 分支统一写入,
410+ 避免与 _run_sync 写 status="idle" 产生竞争。
411+ """
412+ self ._was_cancelled = True
413+ self ._confirm_result = False
414+ self ._confirm_event .set ()
415+ return {"ok" : True }
416+
389417 def start_sync (self , retry_failed : bool = False ) -> Dict [str , Any ]:
390418 """启动同步线程。
391419
@@ -401,7 +429,11 @@ def start_sync(self, retry_failed: bool = False) -> Dict[str, Any]:
401429 retry_products = None
402430 # 读-判断-写合并在同一个锁块,防止双击连续启动
403431 with self ._lock :
404- if self ._progress .get ("status" ) == "syncing" :
432+ # 检查 worker 线程是否仍在运行(cancel 后 status 变 error,但线程可能未退出)
433+ if hasattr (self , "_sync_thread" ) and self ._sync_thread and self ._sync_thread .is_alive ():
434+ return {"started" : False , "message" : "同步正在进行中,请等待完成后再试。" }
435+ # confirm_needed 状态表示同步已在进行中(等待用户确认),也需拦截
436+ if self ._progress .get ("status" ) in ("syncing" , "confirm_needed" ):
405437 return {"started" : False , "message" : "同步正在进行中,请等待完成后再试。" }
406438
407439 # retry_failed 分支:从上次 run_summary 读取失败产品名
@@ -431,6 +463,8 @@ def start_sync(self, retry_failed: bool = False) -> Dict[str, Any]:
431463 daemon = True , # 主进程退出时自动结束
432464 name = "gui-sync-worker" ,
433465 )
466+ # 保存线程引用,供下次 start_sync 调用时检查是否仍在运行
467+ self ._sync_thread = thread
434468 thread .start ()
435469 log_info ("GUI 同步线程已启动。" , event = "GUI_SYNC" )
436470
@@ -516,9 +550,9 @@ def open_data_dir(self) -> Dict[str, Any]:
516550 return {"ok" : True }
517551
518552 def start_health_check (self ) -> Dict [str , Any ]:
519- """启动后台健康检查线程。同步中拒绝 ,重复启动拒绝。"""
553+ """启动后台健康检查线程。同步中(含等待确认)拒绝 ,重复启动拒绝。"""
520554 with self ._lock :
521- if self ._progress .get ("status" ) == "syncing" :
555+ if self ._progress .get ("status" ) in ( "syncing" , "confirm_needed" ) :
522556 return {"ok" : False , "error" : "同步进行中,请稍后再试" }
523557 if self ._health_progress ["checking" ]:
524558 return {"ok" : False , "error" : "检查已在进行中" }
@@ -572,9 +606,9 @@ def get_health_result(self) -> Dict[str, Any]:
572606 return self ._health_progress .get ("result" )
573607
574608 def repair_health_issues (self ) -> Dict [str , Any ]:
575- """修复可修复的数据问题。同步中拒绝 。"""
609+ """修复可修复的数据问题。同步中(含等待确认)拒绝 。"""
576610 with self ._lock :
577- if self ._progress .get ("status" ) == "syncing" :
611+ if self ._progress .get ("status" ) in ( "syncing" , "confirm_needed" ) :
578612 return {"ok" : False , "error" : "同步进行中,请稍后修复" }
579613 result = self ._health_progress .get ("result" )
580614 if not result or not result .get ("ok" ):
@@ -740,6 +774,29 @@ def _run_sync(self, user_config: object, data_root: Path,
740774 """
741775 t_start = time .time ()
742776
777+ # 每次同步重置确认事件和取消标志,防止上次状态残留
778+ self ._confirm_event = threading .Event ()
779+ self ._confirm_result = False
780+ self ._was_cancelled = False
781+
782+ def _gui_confirm (estimate ) -> bool :
783+ """GUI 确认回调:设状态 -> 等前端点击 -> 返回结果。
784+
785+ 在 gui-sync-worker 线程等待,不持 _lock,不阻塞 pywebview 主线程。
786+ 等待超时(300s)视为取消。
787+ """
788+ from dataclasses import asdict
789+ with self ._lock :
790+ self ._progress ["status" ] = "confirm_needed"
791+ self ._progress ["estimate" ] = asdict (estimate )
792+ # 等待前端调用 confirm_sync() 或 cancel_sync()
793+ self ._confirm_event .wait (timeout = 300 )
794+ # wait 返回后清除确认状态,防止前端轮询时 confirm_needed 卡片重复弹出
795+ with self ._lock :
796+ self ._progress ["status" ] = "syncing"
797+ self ._progress ["estimate" ] = None
798+ return self ._confirm_result
799+
743800 try :
744801 config_file = DEFAULT_USER_CONFIG_FILE .resolve ()
745802 secrets_file = DEFAULT_USER_SECRETS_FILE .resolve ()
@@ -843,6 +900,7 @@ def progress_callback(product_name: str, completed: int, total: int, *,
843900 fallback_products = user_config .default_products or []
844901
845902 # 执行同步,接收退出码判断业务结果
903+ # 传入 api_call_limit/course_type 供预估函数使用,confirm_callback 供 GUI 确认流程
846904 exit_code = run_update_with_settings (
847905 command_ctx = command_ctx ,
848906 mode = "local" , # 产品发现策略(按本地已有目录扫描)
@@ -852,6 +910,9 @@ def progress_callback(product_name: str, completed: int, total: int, *,
852910 fallback_products = fallback_products ,
853911 max_workers = DEFAULT_GUI_WORKERS ,
854912 progress_callback = progress_callback ,
913+ api_call_limit = getattr (user_config , "api_call_limit" , 50 ),
914+ course_type = getattr (user_config , "course_type" , "" ),
915+ confirm_callback = _gui_confirm ,
855916 )
856917
857918 # 读取 run_summary 并转换为前端友好格式
@@ -865,27 +926,41 @@ def progress_callback(product_name: str, completed: int, total: int, *,
865926 except Exception as summary_exc :
866927 log_error (f"同步完成但运行摘要读取失败:{ summary_exc } " , event = "GUI_SYNC" )
867928
929+ # orchestrator 返回 -1 表示用户主动取消,静默回到 idle(不显示成功也不显示错误)
930+ if exit_code == - 1 :
931+ self ._update_progress (status = "idle" )
932+ log_info ("GUI 同步已取消。" , event = "GUI_SYNC" , elapsed = round (elapsed , 1 ))
933+ return
934+
868935 # 退出码非零表示有产品失败或无可执行产品,标记为 error 并附带 run_summary
869- if exit_code != EXIT_CODE_SUCCESS :
870- error_msg = "部分产品同步失败" if run_summary and run_summary .get ("error" , 0 ) > 0 else "同步未成功完成"
871- self ._update_progress (
872- status = "error" ,
873- elapsed_seconds = round (elapsed , 1 ),
874- error_message = error_msg ,
875- run_summary = run_summary ,
876- )
877- log_info ("GUI 同步结束(有失败)。" , event = "GUI_SYNC" , exit_code = exit_code , elapsed = round (elapsed , 1 ))
878- else :
879- self ._update_progress (
880- status = "done" ,
881- elapsed_seconds = round (elapsed , 1 ),
882- run_summary = run_summary ,
883- )
884- log_info ("GUI 同步完成。" , event = "GUI_SYNC" , elapsed = round (elapsed , 1 ))
936+ # 注意:cancel_sync() 会直接设 status=error,用 _was_cancelled 精确判断,
937+ # 避免凭证错误等场景误判为"已取消"
938+ already_cancelled = self ._was_cancelled
939+ if not already_cancelled :
940+ if exit_code != EXIT_CODE_SUCCESS :
941+ error_msg = "部分产品同步失败" if run_summary and run_summary .get ("error" , 0 ) > 0 else "同步未成功完成"
942+ self ._update_progress (
943+ status = "error" ,
944+ elapsed_seconds = round (elapsed , 1 ),
945+ error_message = error_msg ,
946+ run_summary = run_summary ,
947+ )
948+ log_info ("GUI 同步结束(有失败)。" , event = "GUI_SYNC" , exit_code = exit_code , elapsed = round (elapsed , 1 ))
949+ else :
950+ self ._update_progress (
951+ status = "done" ,
952+ elapsed_seconds = round (elapsed , 1 ),
953+ run_summary = run_summary ,
954+ )
955+ log_info ("GUI 同步完成。" , event = "GUI_SYNC" , elapsed = round (elapsed , 1 ))
885956
886957 except Exception as exc :
887958 # 预检阶段异常(凭证缺失、配置错误等),尚未进入 run_update_with_settings,
888959 # 无 run_summary 可填(保持初始值 None),前端据此不展示摘要详情
960+ # 若用户已主动取消(_was_cancelled),静默回到 idle,不展示 error
961+ if self ._was_cancelled :
962+ self ._update_progress (status = "idle" )
963+ return
889964 elapsed = time .time () - t_start
890965 error_msg = str (exc )
891966 log_error (f"GUI 同步出错:{ error_msg } " , event = "GUI_SYNC" )
0 commit comments