diff --git a/app/common/IPC_URL/url_command_handler.py b/app/common/IPC_URL/url_command_handler.py index 5a171251..b81dfdb5 100644 --- a/app/common/IPC_URL/url_command_handler.py +++ b/app/common/IPC_URL/url_command_handler.py @@ -5,6 +5,17 @@ from loguru import logger from PySide6.QtCore import QObject, Signal from urllib.parse import urlparse +from app.common.page_registry import ( + get_settings_page_by_alias, + get_settings_page_by_interface, + iter_settings_pages, + resolve_main_page_alias, +) +from app.common.safety.verify_ops import ( + resolve_operation_for_command, + resolve_operation_switch, + should_require_password_for_command, +) # ================================================== @@ -41,6 +52,10 @@ def __init__(self, main_window=None): # 定义所有支持的命令映射 self.command_map = { + "settings": self._handle_settings, + "main_window": self._handle_main_window, + "roll_call": self._handle_roll_call, + "lottery": self._handle_lottery, # 点名控制命令 "roll_call/quick_draw": self._handle_roll_call_quick_draw, "roll_call/start": self._handle_roll_call_start, @@ -77,29 +92,6 @@ def __init__(self, main_window=None): "data/lottery_history": self._handle_get_lottery_history, } - # 设置页面映射 - self.settings_page_map = { - "basic": "basicSettingsInterface", - "list": "listManagementInterface", - "extraction": "extractionSettingsInterface", - "floating": "floatingWindowManagementInterface", - "notification": "notificationSettingsInterface", - "safety": "safetySettingsInterface", - "custom": "customSettingsInterface", - "voice": "voiceSettingsInterface", - "history": "historyInterface", - "more": "moreSettingsInterface", - "update": "updateInterface", - "about": "aboutInterface", - } - - # 主页面映射 - self.main_page_map = { - "roll": "roll_call_page", - "lottery": "lottery_page", - "history": "history_page", - } - logger.debug("URL命令处理器初始化完成") def set_security_verifier(self, verifier): @@ -250,74 +242,12 @@ def _parse_query_string(self, query: str) -> Dict[str, Any]: def _requires_verification(self, command: str) -> bool: """检查命令是否需要验证""" - from app.tools.settings_access import readme_settings_async - from app.common.safety.password import is_configured as password_is_configured - - if command.startswith("data/"): - logger.debug(f"命令无需验证(数据只读):{command}") - return False - - # 未配置密码则不需要验证 - if not password_is_configured(): - logger.debug(f"命令无需验证(未配置密码):{command}") - return False - - # 检查安全总开关 - if not readme_settings_async("basic_safety_settings", "safety_switch"): - logger.debug(f"命令无需验证(安全总开关关闭):{command}") - return False - if command in (self.secure_commands or []): logger.debug(f"命令需验证(自定义受控命令):{command}") return True - # 命令到操作类型的映射 - command_to_op = { - "tray/settings": "open_settings", - "tray/float": "show_hide_floating_window", - "window/main": None, - "window/settings": "open_settings", - "window/float": "show_hide_floating_window", - "window/timer": None, - "tray/restart": "restart", - "tray/exit": "exit", - "roll_call/quick_draw": None, - "roll_call/start": None, - "roll_call/reset": None, - "lottery/start": None, - "lottery/reset": None, - } - - # 操作类型到开关的映射 - op_to_switch = { - "open_settings": "open_settings_switch", - "show_hide_floating_window": "show_hide_floating_window_switch", - "restart": "restart_switch", - "exit": "exit_switch", - "roll_call_start": "safety_switch", - "roll_call_reset": "safety_switch", - "lottery_start": "safety_switch", - "lottery_reset": "safety_switch", - "quick_draw": "safety_switch", - } - - # 获取操作类型 - op = command_to_op.get(command, None) - if op is None: - logger.debug(f"命令无需验证(默认放行):{command}") - return False - - # 获取对应的开关 - switch = op_to_switch.get(op) - if not switch: - logger.debug(f"命令需验证(默认受控):{command}") - return True - - # 检查开关状态 - requires = bool(readme_settings_async("basic_safety_settings", switch)) - logger.debug( - f"检查命令是否需要验证 - 命令: {command}, 操作: {op}, 开关: {switch}, 结果: {requires}" - ) + requires = should_require_password_for_command(command) + logger.debug(f"检查命令是否需要验证 - 命令: {command}, 结果: {requires}") return requires def _resolve_settings_page( @@ -331,45 +261,54 @@ def _resolve_settings_page( if not args or (args and args[0] == ""): return "basicSettingsInterface" page_name = args[0] - return self.settings_page_map.get(page_name) + return self._resolve_settings_page_name(page_name) if command.startswith("settings/"): name = command.split("/", 1)[1] - return self.settings_page_map.get(name) + return self._resolve_settings_page_name(name) return None - def _get_op_and_switch(self, command: str) -> tuple[str, Optional[str]]: - command_to_op = { - "tray/settings": "open_settings", - "tray/float": "show_hide_floating_window", - "window/main": None, - "window/settings": "open_settings", - "window/float": "show_hide_floating_window", - "window/timer": None, - "tray/restart": "restart", - "tray/exit": "exit", - "roll_call/quick_draw": "quick_draw", - "roll_call/start": "roll_call_start", - "roll_call/reset": "roll_call_reset", - "lottery/start": "lottery_start", - "lottery/reset": "lottery_reset", - } + def _resolve_settings_page_name(self, page_name: str) -> Optional[str]: + if not page_name: + return None + + page = get_settings_page_by_alias(str(page_name).strip()) + if page is not None: + return page.interface_attr + + page = get_settings_page_by_interface(str(page_name).strip()) + if page is not None: + return page.interface_attr + + return None + + def _resolve_main_page_name(self, page_name: str) -> Optional[str]: + if not page_name: + return None + + raw = str(page_name).strip() + if not raw: + return None - op_to_switch = { - "open_settings": "open_settings_switch", - "show_hide_floating_window": "show_hide_floating_window_switch", - "restart": "restart_switch", - "exit": "exit_switch", - "roll_call_start": "safety_switch", - "roll_call_reset": "safety_switch", - "lottery_start": "safety_switch", - "lottery_reset": "safety_switch", - "quick_draw": "safety_switch", + return resolve_main_page_alias(raw) or raw + + def _open_settings_page( + self, interface_attr: str, message: str, extra: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + self.showSettingsRequested.emit(interface_attr) + payload = { + "status": "success", + "message": message, + "page": interface_attr, } + if extra: + payload.update(extra) + return payload - op = command_to_op.get(command, command) - return op, op_to_switch.get(op) + def _get_op_and_switch(self, command: str) -> tuple[str, Optional[str]]: + op = resolve_operation_for_command(command) or command + return op, resolve_operation_switch(op) def _get_linkage_verification_policy(self, kind: str) -> Dict[str, bool]: from app.tools.settings_access import readme_settings_async @@ -450,6 +389,11 @@ def _execute_command(self, command: str, params: Dict[str, Any]) -> Dict[str, An """执行命令""" logger.debug(f"执行命令: {command}, 参数: {params}") try: + if command.startswith("settings/"): + page_name = command.split("/", 1)[1] + merged_params = {**(params or {}), "args": [page_name]} + return self._handle_settings(merged_params) + # 查找命令处理器 handler = self.command_map.get(command) if handler: @@ -539,126 +483,6 @@ def _fuzzy_match_command(self, command: str) -> Optional[str]: # 具体命令处理器 # ================================================== - def _handle_basic_settings(self, params: Dict[str, Any]) -> Dict[str, Any]: - """处理基础设置""" - logger.debug("打开基础设置页面") - self.showSettingsRequested.emit("basicSettingsInterface") - return { - "status": "success", - "message": "基础设置页面已打开", - "page": "basicSettingsInterface", - } - - def _handle_list_settings(self, params: Dict[str, Any]) -> Dict[str, Any]: - """处理列表管理设置""" - logger.debug("打开列表管理设置页面") - self.showSettingsRequested.emit("listManagementInterface") - return { - "status": "success", - "message": "列表管理设置页面已打开", - "page": "listManagementInterface", - } - - def _handle_extraction_settings(self, params: Dict[str, Any]) -> Dict[str, Any]: - """处理抽取设置""" - logger.debug("打开抽取设置页面") - self.showSettingsRequested.emit("extractionSettingsInterface") - return { - "status": "success", - "message": "抽取设置页面已打开", - "page": "extractionSettingsInterface", - } - - def _handle_floating_settings(self, params: Dict[str, Any]) -> Dict[str, Any]: - """处理浮窗设置""" - logger.debug("打开浮窗设置页面") - self.showSettingsRequested.emit("floatingWindowManagementInterface") - return { - "status": "success", - "message": "浮窗设置页面已打开", - "page": "floatingWindowManagementInterface", - } - - def _handle_notification_settings(self, params: Dict[str, Any]) -> Dict[str, Any]: - """处理通知设置""" - logger.debug("打开通知设置页面") - self.showSettingsRequested.emit("notificationSettingsInterface") - return { - "status": "success", - "message": "通知设置页面已打开", - "page": "notificationSettingsInterface", - } - - def _handle_safety_settings(self, params: Dict[str, Any]) -> Dict[str, Any]: - """处理安全设置""" - logger.debug("打开安全设置页面") - self.showSettingsRequested.emit("safetySettingsInterface") - return { - "status": "success", - "message": "安全设置页面已打开", - "page": "safetySettingsInterface", - } - - def _handle_custom_settings(self, params: Dict[str, Any]) -> Dict[str, Any]: - """处理自定义设置""" - logger.debug("打开自定义设置页面") - self.showSettingsRequested.emit("customSettingsInterface") - return { - "status": "success", - "message": "自定义设置页面已打开", - "page": "customSettingsInterface", - } - - def _handle_voice_settings(self, params: Dict[str, Any]) -> Dict[str, Any]: - """处理语音设置""" - logger.debug("打开语音设置页面") - self.showSettingsRequested.emit("voiceSettingsInterface") - return { - "status": "success", - "message": "语音设置页面已打开", - "page": "voiceSettingsInterface", - } - - def _handle_history_settings(self, params: Dict[str, Any]) -> Dict[str, Any]: - """处理历史记录设置""" - logger.debug("打开历史记录设置页面") - self.showSettingsRequested.emit("historyInterface") - return { - "status": "success", - "message": "历史记录设置页面已打开", - "page": "historyInterface", - } - - def _handle_more_settings(self, params: Dict[str, Any]) -> Dict[str, Any]: - """处理更多设置""" - logger.debug("打开更多设置页面") - self.showSettingsRequested.emit("moreSettingsInterface") - return { - "status": "success", - "message": "更多设置页面已打开", - "page": "moreSettingsInterface", - } - - def _handle_update_settings(self, params: Dict[str, Any]) -> Dict[str, Any]: - """处理更新设置""" - logger.debug("打开更新设置页面") - self.showSettingsRequested.emit("updateInterface") - return { - "status": "success", - "message": "更新设置页面已打开", - "page": "updateInterface", - } - - def _handle_about_settings(self, params: Dict[str, Any]) -> Dict[str, Any]: - """处理关于设置""" - logger.debug("打开关于设置页面") - self.showSettingsRequested.emit("aboutInterface") - return { - "status": "success", - "message": "关于设置页面已打开", - "page": "aboutInterface", - } - def _handle_settings(self, params: Dict[str, Any]) -> Dict[str, Any]: """处理设置命令""" args = params.get("args", []) @@ -667,45 +491,28 @@ def _handle_settings(self, params: Dict[str, Any]) -> Dict[str, Any]: if not args or (args and args[0] == ""): # 默认打开基本设置页面 logger.debug("打开设置界面 - 基本设置") - self.showSettingsRequested.emit("basicSettingsInterface") - return {"status": "success", "message": "设置界面已打开"} + return self._open_settings_page("basicSettingsInterface", "设置界面已打开") # 处理特定的设置页面 page_name = args[0] logger.debug(f"打开设置页面: {page_name}") + mapped_page = self._resolve_settings_page_name(page_name) + if mapped_page: + logger.debug(f"切换到设置页面: {mapped_page}") + return self._open_settings_page( + mapped_page, + f"设置页面 '{page_name}' 已打开", + ) - # 映射设置页面名称 - page_mapping = { - "basic": "basicSettingsInterface", - "list": "listManagementInterface", - "extraction": "extractionSettingsInterface", - "floating": "floatingWindowManagementInterface", - "notification": "notificationSettingsInterface", - "safety": "safetySettingsInterface", - "voice": "voiceSettingsInterface", - "history": "historyInterface", - "more": "moreSettingsInterface", - "update": "updateInterface", - "about": "aboutInterface", + logger.warning(f"未知的设置页面: {page_name}") + return { + "status": "error", + "message": f"不支持的设置页面: {page_name}", + "available_pages": [ + page.url_alias for page in iter_settings_pages() if page.url_alias + ], } - if page_name in page_mapping: - mapped_page = page_mapping[page_name] - logger.debug(f"切换到设置页面: {mapped_page}") - self.showSettingsRequested.emit(mapped_page) - return { - "status": "success", - "message": f"设置页面 '{page_name}' 已打开", - "page": mapped_page, - } - else: - logger.warning(f"未知的设置页面: {page_name}") - return { - "status": "error", - "message": f"不支持的设置页面: {page_name}", - "available_pages": list(page_mapping.keys()), - } - def _handle_roll_call(self, params: Dict[str, Any]) -> Dict[str, Any]: """处理抽人功能""" logger.debug("切换到抽人页面") @@ -940,7 +747,7 @@ def _handle_window_main(self, params: Dict[str, Any]) -> Dict[str, Any]: page_name = None if isinstance(page, str) and page.strip(): raw = page.strip() - page_name = self.main_page_map.get(raw, raw) + page_name = self._resolve_main_page_name(raw) payload: Dict[str, Any] = {"action": action} if page_name: @@ -972,7 +779,7 @@ def _handle_window_settings(self, params: Dict[str, Any]) -> Dict[str, Any]: is_preview = v in ("1", "true", "yes", "on") payload: Dict[str, Any] = { "action": action, - "page": page, + "page": self._resolve_settings_page_name(page) if page else None, "is_preview": is_preview, } self.windowActionRequested.emit("settings", {**payload, **(params or {})}) @@ -999,8 +806,8 @@ def _handle_main_window(self, params: Dict[str, Any]) -> Dict[str, Any]: args = params.get("args", []) if args: page = args[0] - if page in self.main_page_map: - page_name = self.main_page_map[page] + page_name = self._resolve_main_page_name(page) + if page_name: self.showMainPageRequested.emit(page_name) return { "status": "success", diff --git a/app/common/lottery/lottery_manager.py b/app/common/lottery/lottery_manager.py index 1b191342..5efcbbd8 100644 --- a/app/common/lottery/lottery_manager.py +++ b/app/common/lottery/lottery_manager.py @@ -27,7 +27,6 @@ from app.common.music.music_player import music_player from app.common.voice.voice import TTSHandler from app.common.extraction.extract import _is_non_class_time -from app.common.safety.verify_ops import require_and_run from app.page_building.another_window import create_remaining_list_window from app.tools.path_utils import get_data_path from app.tools.personalised import load_custom_font @@ -48,6 +47,7 @@ ) from app.tools.variable import APP_INIT_DELAY from app.tools.config import track_event +from app.common.safety.verify_proxy import require_and_run_lazy system_random = SystemRandom() @@ -1225,7 +1225,9 @@ def start_draw(widget): if _is_non_class_time(): if readme_settings_async("linkage_settings", "verification_required"): logger.info("当前时间在非上课时间段内,需要密码验证") - require_and_run("lottery_start", widget, lambda: start_lottery_draw(widget)) + require_and_run_lazy( + "lottery_start", widget, lambda: start_lottery_draw(widget) + ) else: logger.info("当前时间在非上课时间段内,禁止抽取") return @@ -1241,7 +1243,7 @@ def reset_count(widget): if _is_non_class_time(): if readme_settings_async("linkage_settings", "verification_required"): logger.info("当前时间在非上课时间段内,需要密码验证") - require_and_run("lottery_reset", widget, widget._do_reset_count) + require_and_run_lazy("lottery_reset", widget, widget._do_reset_count) else: logger.info("当前时间在非上课时间段内,禁止重置") return diff --git a/app/common/page_registry.py b/app/common/page_registry.py new file mode 100644 index 00000000..e7841d9b --- /dev/null +++ b/app/common/page_registry.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class SettingsPageRegistration: + route_name: str + interface_attr: str + item_attr: str + page_method: str + is_pivot: bool + icon_name: str + language_module: str + sidebar_setting_key: str + title_key: str = "title" + url_alias: str | None = None + + +SETTINGS_PAGE_REGISTRY: tuple[SettingsPageRegistration, ...] = ( + SettingsPageRegistration( + route_name="settings_basic", + interface_attr="basicSettingsInterface", + item_attr="basic_settings_item", + page_method="basic_settings_page", + is_pivot=False, + icon_name="ic_fluent_wrench_settings_20_filled", + language_module="basic_settings", + sidebar_setting_key="base_settings", + url_alias="basic", + ), + SettingsPageRegistration( + route_name="settings_list", + interface_attr="listManagementInterface", + item_attr="list_management_item", + page_method="list_management_page", + is_pivot=True, + icon_name="ic_fluent_list_20_filled", + language_module="list_management", + sidebar_setting_key="name_management", + url_alias="list", + ), + SettingsPageRegistration( + route_name="settings_extraction", + interface_attr="extractionSettingsInterface", + item_attr="extraction_settings_item", + page_method="extraction_settings_page", + is_pivot=True, + icon_name="ic_fluent_archive_20_filled", + language_module="extraction_settings", + sidebar_setting_key="draw_settings", + url_alias="extraction", + ), + SettingsPageRegistration( + route_name="settings_floating", + interface_attr="floatingWindowManagementInterface", + item_attr="floating_window_management_item", + page_method="floating_window_management_page", + is_pivot=True, + icon_name="ic_fluent_window_apps_20_filled", + language_module="floating_window_management", + sidebar_setting_key="floating_window_management", + url_alias="floating", + ), + SettingsPageRegistration( + route_name="settings_notification", + interface_attr="notificationSettingsInterface", + item_attr="notification_settings_item", + page_method="notification_settings_page", + is_pivot=True, + icon_name="ic_fluent_comment_note_20_filled", + language_module="notification_settings", + sidebar_setting_key="notification_service", + url_alias="notification", + ), + SettingsPageRegistration( + route_name="settings_safety", + interface_attr="safetySettingsInterface", + item_attr="safety_settings_item", + page_method="safety_settings_page", + is_pivot=True, + icon_name="ic_fluent_shield_20_filled", + language_module="safety_settings", + sidebar_setting_key="security_settings", + url_alias="safety", + ), + SettingsPageRegistration( + route_name="settings_linkage", + interface_attr="courseSettingsInterface", + item_attr="course_settings_item", + page_method="linkage_settings_page", + is_pivot=False, + icon_name="ic_fluent_calendar_ltr_20_filled", + language_module="linkage_settings", + sidebar_setting_key="linkage_settings", + ), + SettingsPageRegistration( + route_name="settings_voice", + interface_attr="voiceSettingsInterface", + item_attr="voice_settings_item", + page_method="voice_settings_page", + is_pivot=True, + icon_name="ic_fluent_person_voice_20_filled", + language_module="voice_settings", + sidebar_setting_key="voice_settings", + url_alias="voice", + ), + SettingsPageRegistration( + route_name="settings_theme", + interface_attr="themeManagementInterface", + item_attr="theme_management_item", + page_method="theme_management_page", + is_pivot=False, + icon_name="ic_fluent_paint_brush_20_filled", + language_module="theme_management", + sidebar_setting_key="theme_management", + ), + SettingsPageRegistration( + route_name="settings_history", + interface_attr="historyInterface", + item_attr="history_item", + page_method="history_page", + is_pivot=True, + icon_name="ic_fluent_history_20_filled", + language_module="history", + sidebar_setting_key="settings_history", + url_alias="history", + ), + SettingsPageRegistration( + route_name="settings_more", + interface_attr="moreSettingsInterface", + item_attr="more_settings_item", + page_method="more_settings_page", + is_pivot=True, + icon_name="ic_fluent_more_horizontal_20_filled", + language_module="more_settings", + sidebar_setting_key="more_settings", + url_alias="more", + ), + SettingsPageRegistration( + route_name="settings_update", + interface_attr="updateInterface", + item_attr="update_item", + page_method="update_page", + is_pivot=False, + icon_name="ic_fluent_arrow_sync_20_filled", + language_module="update", + sidebar_setting_key="updateInterface", + url_alias="update", + ), + SettingsPageRegistration( + route_name="settings_about", + interface_attr="aboutInterface", + item_attr="about_item", + page_method="about_page", + is_pivot=False, + icon_name="ic_fluent_info_20_filled", + language_module="about", + sidebar_setting_key="aboutInterface", + url_alias="about", + ), +) + + +MAIN_PAGE_ALIAS_MAP: dict[str, str] = { + "roll": "roll_call_page", + "lottery": "lottery_page", + "history": "history_page", +} + + +SETTINGS_PAGE_BY_ROUTE: dict[str, SettingsPageRegistration] = { + page.route_name: page for page in SETTINGS_PAGE_REGISTRY +} +SETTINGS_PAGE_BY_INTERFACE: dict[str, SettingsPageRegistration] = { + page.interface_attr: page for page in SETTINGS_PAGE_REGISTRY +} +SETTINGS_PAGE_BY_ALIAS: dict[str, SettingsPageRegistration] = { + page.url_alias: page for page in SETTINGS_PAGE_REGISTRY if page.url_alias +} + + +def iter_settings_pages() -> tuple[SettingsPageRegistration, ...]: + return SETTINGS_PAGE_REGISTRY + + +def iter_navigable_settings_pages() -> tuple[SettingsPageRegistration, ...]: + return SETTINGS_PAGE_REGISTRY + + +def iter_settings_page_container_names() -> tuple[str, ...]: + return tuple(page.interface_attr for page in SETTINGS_PAGE_REGISTRY) + + +def get_settings_page_by_route(route_name: str) -> SettingsPageRegistration | None: + return SETTINGS_PAGE_BY_ROUTE.get(route_name) + + +def get_settings_page_by_interface( + interface_attr: str, +) -> SettingsPageRegistration | None: + return SETTINGS_PAGE_BY_INTERFACE.get(interface_attr) + + +def get_settings_page_by_alias(alias: str) -> SettingsPageRegistration | None: + return SETTINGS_PAGE_BY_ALIAS.get(alias) + + +def resolve_main_page_alias(alias: str) -> str | None: + return MAIN_PAGE_ALIAS_MAP.get(alias) diff --git a/app/common/roll_call/roll_call_manager.py b/app/common/roll_call/roll_call_manager.py index 93a5c09b..40f98913 100644 --- a/app/common/roll_call/roll_call_manager.py +++ b/app/common/roll_call/roll_call_manager.py @@ -28,7 +28,6 @@ from app.common.behind_scenes.behind_scenes_utils import BehindScenesUtils from app.common.voice.voice import TTSHandler from app.common.extraction.extract import _is_non_class_time -from app.common.safety.verify_ops import require_and_run from app.page_building.another_window import create_remaining_list_window from app.tools.config import remove_record from app.tools.interaction_perf import start_interaction @@ -42,6 +41,7 @@ from app.tools.path_utils import get_data_path from app.tools.variable import APP_INIT_DELAY from app.tools.config import track_event +from app.common.safety.verify_proxy import require_and_run_lazy system_random = SystemRandom() @@ -906,7 +906,7 @@ def start_draw(widget): if _is_non_class_time(): if readme_settings_async("linkage_settings", "verification_required"): logger.info("当前时间在非上课时间段内,需要密码验证") - require_and_run( + require_and_run_lazy( "roll_call_start", widget, lambda: start_roll_call_draw(widget) ) else: @@ -924,7 +924,7 @@ def reset_count(widget): if _is_non_class_time(): if readme_settings_async("linkage_settings", "verification_required"): logger.info("当前时间在非上课时间段内,需要密码验证") - require_and_run("roll_call_reset", widget, widget._do_reset_count) + require_and_run_lazy("roll_call_reset", widget, widget._do_reset_count) else: logger.info("当前时间在非上课时间段内,禁止重置") return diff --git a/app/common/safety/verify_ops.py b/app/common/safety/verify_ops.py index 4990deed..c0d4fc3a 100644 --- a/app/common/safety/verify_ops.py +++ b/app/common/safety/verify_ops.py @@ -1,10 +1,83 @@ from app.tools.settings_access import readme_settings_async -from app.common.safety.password import is_configured as password_is_configured -from app.page_building.security_window import create_verify_password_window from loguru import logger +READONLY_COMMAND_PREFIXES = ("data/",) + +OPERATION_SWITCH_MAP = { + "show_hide_floating_window": "show_hide_floating_window_switch", + "restart": "restart_switch", + "exit": "exit_switch", + "set_totp": "safety_switch", + "bind_usb": "safety_switch", + "unbind_usb": "safety_switch", + "toggle_totp": "safety_switch", + "toggle_usb": "safety_switch", + "change_verification_process": "safety_switch", + "toggle_show_hide_floating_window_switch": "safety_switch", + "toggle_restart_switch": "safety_switch", + "toggle_exit_switch": "safety_switch", + "open_settings": "open_settings_switch", + "toggle_open_settings_switch": "safety_switch", + "roll_call_start": "safety_switch", + "roll_call_reset": "safety_switch", + "lottery_start": "safety_switch", + "lottery_reset": "safety_switch", + "quick_draw": "safety_switch", +} + +COMMAND_OPERATION_MAP = { + "tray/settings": "open_settings", + "tray/float": "show_hide_floating_window", + "window/main": None, + "window/settings": "open_settings", + "window/float": "show_hide_floating_window", + "window/timer": None, + "tray/restart": "restart", + "tray/exit": "exit", + "roll_call/quick_draw": "quick_draw", + "roll_call/start": "roll_call_start", + "roll_call/reset": "roll_call_reset", + "lottery/start": "lottery_start", + "lottery/reset": "lottery_reset", +} + + +def is_readonly_command(command: str) -> bool: + return str(command or "").startswith(READONLY_COMMAND_PREFIXES) + + +def resolve_operation_switch(op: str | None) -> str | None: + if op is None: + return None + return OPERATION_SWITCH_MAP.get(op) + + +def resolve_operation_for_command(command: str) -> str | None: + return COMMAND_OPERATION_MAP.get(command) + + +def should_require_password_for_command(command: str) -> bool: + if is_readonly_command(command): + logger.debug(f"命令无需验证(数据只读):{command}") + return False + + op = resolve_operation_for_command(command) + if op is None: + logger.debug(f"命令无需验证(默认放行):{command}") + return False + + return should_require_password(op) + def should_require_password(op: str) -> bool: + if op != "toggle_safety" and not readme_settings_async( + "basic_safety_settings", "safety_switch" + ): + logger.debug(f"操作无需验证(安全总开关关闭):{op}") + return False + + from app.common.safety.password import is_configured as password_is_configured + if not password_is_configured(): logger.debug(f"操作无需验证(未配置密码):{op}") return False @@ -14,32 +87,7 @@ def should_require_password(op: str) -> bool: logger.debug(f"操作需验证(切换安全总开关):{op}") return True - if not readme_settings_async("basic_safety_settings", "safety_switch"): - logger.debug(f"操作无需验证(安全总开关关闭):{op}") - return False - - key_map = { - "show_hide_floating_window": "show_hide_floating_window_switch", - "restart": "restart_switch", - "exit": "exit_switch", - "set_totp": "safety_switch", - "bind_usb": "safety_switch", - "unbind_usb": "safety_switch", - "toggle_totp": "safety_switch", - "toggle_usb": "safety_switch", - "change_verification_process": "safety_switch", - "toggle_show_hide_floating_window_switch": "safety_switch", - "toggle_restart_switch": "safety_switch", - "toggle_exit_switch": "safety_switch", - "open_settings": "open_settings_switch", - "toggle_open_settings_switch": "safety_switch", - "roll_call_start": "safety_switch", - "roll_call_reset": "safety_switch", - "lottery_start": "safety_switch", - "lottery_reset": "safety_switch", - "quick_draw": "safety_switch", - } - k = key_map.get(op) + k = resolve_operation_switch(op) if not k: logger.debug(f"操作需验证(默认受控):{op}") return True @@ -53,6 +101,8 @@ def require_and_run(op: str, parent, func, on_preview=None): func() return logger.debug(f"触发验证窗口:{op}") + from app.page_building.security_window import create_verify_password_window + create_verify_password_window( on_verified=func, on_preview=on_preview, operation_type=op ) diff --git a/app/common/safety/verify_proxy.py b/app/common/safety/verify_proxy.py new file mode 100644 index 00000000..836d4f80 --- /dev/null +++ b/app/common/safety/verify_proxy.py @@ -0,0 +1,5 @@ +def require_and_run_lazy(*args, **kwargs): + """Lazily import the verification entrypoint on demand.""" + from app.common.safety.verify_ops import require_and_run + + return require_and_run(*args, **kwargs) diff --git a/app/common/search/settings_search_controller.py b/app/common/search/settings_search_controller.py index e1b8b0d7..c014ddd5 100644 --- a/app/common/search/settings_search_controller.py +++ b/app/common/search/settings_search_controller.py @@ -26,18 +26,14 @@ def __init__( window: QWidget, title_bar: QWidget, line_edit: QWidget, - handle_page_request: Callable[[str], None], - get_page_mapping: Callable[[], Dict[str, Any]], - get_created_page: Callable[[str], Optional[QWidget]], + ensure_page_ready: Callable[..., Optional[QWidget]], parent: Optional[QObject] = None, ) -> None: super().__init__(parent) self._window = window self._title_bar = title_bar self._line_edit = line_edit - self._handle_page_request = handle_page_request - self._get_page_mapping = get_page_mapping - self._get_created_page = get_created_page + self._ensure_page_ready = ensure_page_ready self._menu: Optional[SystemTrayMenu] = None self._index: Optional[List[Dict[str, Any]]] = None @@ -146,40 +142,30 @@ def _jump_to_entry(self, entry: Dict[str, Any]) -> None: if not page_route: return - self._handle_page_request(page_route) - - page_mapping = self._get_page_mapping() or {} - interface_attr = page_mapping.get(page_route, (None, None))[0] - if not interface_attr: - return - - def try_jump(attempt: int = 0) -> None: - page = self._get_created_page(interface_attr) - if page is None: - if attempt < 20: - QTimer.singleShot(50, lambda: try_jump(attempt + 1)) - return - - pivot = entry.get("pivot") - if pivot: - if hasattr(page, "switch_to_page"): + self._ensure_page_ready( + page_route, + on_ready=lambda page, current_entry=entry: self._focus_entry( + page, current_entry + ), + ) + + def _focus_entry(self, page: QWidget, entry: Dict[str, Any]) -> None: + pivot = entry.get("pivot") + if pivot: + if hasattr(page, "switch_to_page"): + try: + page.switch_to_page(pivot) + except Exception: + pass + else: + pivot_widget = getattr(page, "pivot", None) + if pivot_widget is not None and hasattr(pivot_widget, "setCurrentItem"): try: - page.switch_to_page(pivot) + pivot_widget.setCurrentItem(pivot) except Exception: pass - else: - pivot_widget = getattr(page, "pivot", None) - if pivot_widget is not None and hasattr( - pivot_widget, "setCurrentItem" - ): - try: - pivot_widget.setCurrentItem(pivot) - except Exception: - pass - - QTimer.singleShot(50, lambda: self._scroll_to_entry(page, entry)) - - QTimer.singleShot(0, lambda: try_jump(0)) + + self._scroll_to_entry(page, entry) def _scroll_to_entry(self, page: QWidget, entry: Dict[str, Any]) -> None: first = entry.get("first") diff --git a/app/core/app_init.py b/app/core/app_init.py index a5c09b18..2fbe84e2 100644 --- a/app/core/app_init.py +++ b/app/core/app_init.py @@ -1,10 +1,8 @@ from PySide6.QtCore import QTimer from loguru import logger -from app.tools.settings_default import manage_settings_file from app.tools.config import remove_record from app.tools.settings_access import readme_settings_async -from app.tools.update_utils import check_for_updates_on_startup from app.tools.variable import APP_INIT_DELAY from app.core.font_manager import ( apply_font_settings, @@ -27,14 +25,9 @@ def __init__(self, window_manager: WindowManager) -> None: def initialize(self) -> None: """初始化应用程序""" - self._manage_settings_file() self._schedule_initialization_tasks() logger.debug("应用初始化调度已启动,主窗口将在延迟后创建") - def _manage_settings_file(self) -> None: - """管理设置文件,确保其存在且完整""" - manage_settings_file() - def _schedule_initialization_tasks(self) -> None: """调度所有初始化任务""" guide_completed = readme_settings_async("basic_settings", "guide_completed") @@ -72,16 +65,9 @@ def _run_post_first_window_tasks(self) -> None: error_message="启动主窗口延后任务失败", ) safe_execute( - lambda: check_for_updates_on_startup(None), + self._check_for_updates, error_message="检查更新失败", ) - QTimer.singleShot( - 1500, - lambda: safe_execute( - self._do_warmup_face_detector_devices, - error_message="预热摄像头设备失败", - ), - ) def _run_main_window_post_show_tasks(self) -> None: main_window = getattr(self.window_manager, "main_window", None) @@ -91,6 +77,11 @@ def _run_main_window_post_show_tasks(self) -> None: if hasattr(main_window, "schedule_post_startup_tasks"): main_window.schedule_post_startup_tasks() + def _check_for_updates(self) -> None: + from app.tools.update_utils import check_for_updates_on_startup + + check_for_updates_on_startup(None) + def _apply_theme(self) -> None: """应用主题设置""" from qfluentwidgets import setTheme, Theme @@ -113,8 +104,3 @@ def _apply_theme_color(self) -> None: def _clear_restart_record_now(self) -> None: """清除重启记录""" remove_record("", "", "", "restart") - - def _do_warmup_face_detector_devices(self) -> None: - from app.common.camera_preview_backend import warmup_camera_devices_async - - warmup_camera_devices_async(force_refresh=True) diff --git a/app/core/window_manager.py b/app/core/window_manager.py index 182819c5..c5c42153 100644 --- a/app/core/window_manager.py +++ b/app/core/window_manager.py @@ -265,10 +265,10 @@ def _connect_url_handler_signals(self) -> None: return self.url_handler.showMainPageRequested.connect( - self.main_window._handle_main_page_requested + self.main_window.showMainPageRequested.emit ) self.url_handler.showTrayActionRequested.connect( - lambda action: self.main_window._handle_tray_action_requested(action) + self.main_window.showTrayActionRequested.emit ) self.url_handler.showSettingsRequested.connect(self.show_settings_window) if hasattr(self.url_handler, "showSettingsPreviewRequested"): @@ -584,8 +584,8 @@ def impl(): ) if self.main_window.isVisible() and not is_minimized: self.main_window.hide() - elif hasattr(self.main_window, "_handle_main_page_requested"): - self.main_window._handle_main_page_requested(page_name) + elif hasattr(self.main_window, "showMainPageRequested"): + self.main_window.showMainPageRequested.emit(page_name) elif hasattr(self.main_window, "toggle_window"): self.main_window.toggle_window() else: @@ -597,10 +597,8 @@ def impl(): self.main_window.hide() return if action == "show": - if page_name and hasattr( - self.main_window, "_handle_main_page_requested" - ): - self.main_window._handle_main_page_requested(page_name) + if page_name and hasattr(self.main_window, "showMainPageRequested"): + self.main_window.showMainPageRequested.emit(page_name) return if ( getattr(self.main_window, "isMinimized", None) diff --git a/app/page_building/main_window_page.py b/app/page_building/main_window_page.py index 5cc2d4a5..016eaf68 100644 --- a/app/page_building/main_window_page.py +++ b/app/page_building/main_window_page.py @@ -1,6 +1,6 @@ # 导入库 from PySide6.QtWidgets import QFrame -from PySide6.QtCore import QTimer, Qt +from PySide6.QtCore import Qt # 导入页面模板 from app.page_building.page_template import PageTemplate, PivotPageTemplate @@ -10,61 +10,59 @@ from app.Language.obtain_language import * from app.tools.settings_access import get_settings_signals -# 导入自定义页面内容组件 -from app.view.main.roll_call import roll_call -from app.view.main.lottery import Lottery from app.tools.theme_loader import ThemeLoader -class roll_call_page(PageTemplate): - """创建班级点名页面""" +class _ThemedMainPage(PageTemplate): + """Shared wrapper for theme-aware main pages.""" + + THEME_NAME = "" + CONTENT_ATTR_NAME = "" + THEME_SETTING_KEYS = () def __init__(self, parent: QFrame = None): - widget_class = ThemeLoader.load_theme_widget("roll_call", roll_call) + widget_class = ThemeLoader.load_theme_widget( + self.THEME_NAME, self._get_default_widget_class() + ) + setattr(self, self.CONTENT_ATTR_NAME, None) super().__init__(content_widget_class=widget_class, parent=parent) - self.roll_call_widget = None get_settings_signals().settingChanged.connect(self._on_global_setting_changed) + @staticmethod + def _get_default_widget_class(): + raise NotImplementedError + def _on_global_setting_changed(self, group, key, value): - if group == "theme_management" and key in ( - "roll_call_theme_id", - "roll_call_theme_type", - ): + if group == "theme_management" and key in self.THEME_SETTING_KEYS: self.content_widget_class = ThemeLoader.load_theme_widget( - "roll_call", roll_call + self.THEME_NAME, self._get_default_widget_class() ) self.handle_settings_change() def create_content(self): """后台创建内容组件,避免堵塞进程""" super().create_content() - # 获取点名组件实例并连接信号 - if hasattr(self, "contentWidget"): - self.roll_call_widget = self.contentWidget - if self.roll_call_widget and self.roll_call_widget.property( - "theme_html_wrapper" - ): - if hasattr(self, "_inner_layout_lazy") and self._inner_layout_lazy: - self._inner_layout_lazy.setAlignment(Qt.AlignmentFlag.AlignTop) - self._inner_layout_lazy.setContentsMargins(0, 0, 0, 0) - self._inner_layout_lazy.setSpacing(0) - if hasattr(self, "_main_layout_lazy") and self._main_layout_lazy: - self._main_layout_lazy.setContentsMargins(0, 0, 0, 0) - self._main_layout_lazy.setSpacing(0) - # 连接设置变化信号 - if hasattr(self.roll_call_widget, "settingsChanged"): - self.roll_call_widget.settingsChanged.connect( - self.handle_settings_change - ) + if not hasattr(self, "contentWidget"): + return + + content_widget = self.contentWidget + setattr(self, self.CONTENT_ATTR_NAME, content_widget) + + if content_widget and content_widget.property("theme_html_wrapper"): + if hasattr(self, "_inner_layout_lazy") and self._inner_layout_lazy: + self._inner_layout_lazy.setAlignment(Qt.AlignmentFlag.AlignTop) + self._inner_layout_lazy.setContentsMargins(0, 0, 0, 0) + self._inner_layout_lazy.setSpacing(0) + if hasattr(self, "_main_layout_lazy") and self._main_layout_lazy: + self._main_layout_lazy.setContentsMargins(0, 0, 0, 0) + self._main_layout_lazy.setSpacing(0) + + if hasattr(content_widget, "settingsChanged"): + content_widget.settingsChanged.connect(self.handle_settings_change) def handle_settings_change(self): """处理设置变化信号""" - # 清除页面缓存并重新创建 self.clear_content() - QTimer.singleShot(0, self._recreate_content) - - def _recreate_content(self): - """重新创建内容""" self.create_content() def clear_content(self): @@ -76,66 +74,41 @@ def clear_content(self): widget.deleteLater() self.content_created = False self.contentWidget = None + setattr(self, self.CONTENT_ATTR_NAME, None) -class lottery_page(PageTemplate): +class roll_call_page(_ThemedMainPage): """创建班级点名页面""" - def __init__(self, parent: QFrame = None): - widget_class = ThemeLoader.load_theme_widget("lottery", Lottery) - super().__init__(content_widget_class=widget_class, parent=parent) - self.lottery_widget = None - get_settings_signals().settingChanged.connect(self._on_global_setting_changed) + THEME_NAME = "roll_call" + CONTENT_ATTR_NAME = "roll_call_widget" + THEME_SETTING_KEYS = ( + "roll_call_theme_id", + "roll_call_theme_type", + ) - def _on_global_setting_changed(self, group, key, value): - if group == "theme_management" and key in ( - "lottery_theme_id", - "lottery_theme_type", - ): - self.content_widget_class = ThemeLoader.load_theme_widget( - "lottery", Lottery - ) - self.handle_settings_change() + @staticmethod + def _get_default_widget_class(): + from app.view.main.roll_call import roll_call - def create_content(self): - """后台创建内容组件,避免堵塞进程""" - super().create_content() - # 获取奖池组件实例并连接信号 - if hasattr(self, "contentWidget"): - self.lottery_widget = self.contentWidget - if self.lottery_widget and self.lottery_widget.property( - "theme_html_wrapper" - ): - if hasattr(self, "_inner_layout_lazy") and self._inner_layout_lazy: - self._inner_layout_lazy.setAlignment(Qt.AlignmentFlag.AlignTop) - self._inner_layout_lazy.setContentsMargins(0, 0, 0, 0) - self._inner_layout_lazy.setSpacing(0) - if hasattr(self, "_main_layout_lazy") and self._main_layout_lazy: - self._main_layout_lazy.setContentsMargins(0, 0, 0, 0) - self._main_layout_lazy.setSpacing(0) - # 连接设置变化信号 - if hasattr(self.lottery_widget, "settingsChanged"): - self.lottery_widget.settingsChanged.connect(self.handle_settings_change) + return roll_call - def handle_settings_change(self): - """处理设置变化信号""" - # 清除页面缓存并重新创建 - self.clear_content() - QTimer.singleShot(0, self._recreate_content) - def _recreate_content(self): - """重新创建内容""" - self.create_content() +class lottery_page(_ThemedMainPage): + """创建班级点名页面""" - def clear_content(self): - """清除内容""" - if hasattr(self, "_inner_layout_lazy") and self._inner_layout_lazy.count() > 0: - item = self._inner_layout_lazy.takeAt(0) - if item and item.widget(): - widget = item.widget() - widget.deleteLater() - self.content_created = False - self.contentWidget = None + THEME_NAME = "lottery" + CONTENT_ATTR_NAME = "lottery_widget" + THEME_SETTING_KEYS = ( + "lottery_theme_id", + "lottery_theme_type", + ) + + @staticmethod + def _get_default_widget_class(): + from app.view.main.lottery import Lottery + + return Lottery class history_page(PivotPageTemplate): @@ -150,5 +123,4 @@ def __init__(self, parent: QFrame = None): "lottery_history_table", "title" ), } - super().__init__(page_config, parent) - self.set_base_path("app.view.settings.history") + super().__init__(page_config, parent, base_path="app.view.settings.history") diff --git a/app/page_building/page_template.py b/app/page_building/page_template.py index 93ff50f8..b7766265 100644 --- a/app/page_building/page_template.py +++ b/app/page_building/page_template.py @@ -43,7 +43,7 @@ def __init__( self._content_kwargs = kwargs # 存储传递给内容组件的额外参数 self.__connectSignalToSlot() - QTimer.singleShot(0, self.create_ui_components) + self.create_ui_components() @property def is_preview_mode(self): @@ -314,7 +314,13 @@ class PivotPageTemplate(QFrame): _resolved_page_class_cache = {} - def __init__(self, page_config: dict, parent: QFrame = None, is_preview_mode=False): + def __init__( + self, + page_config: dict, + parent: QFrame = None, + is_preview_mode=False, + base_path: str | None = None, + ): """ 初始化 Pivot 页面模板 @@ -330,15 +336,14 @@ def __init__(self, page_config: dict, parent: QFrame = None, is_preview_mode=Fal self.pages = {} # 存储页面组件 (scroll areas) self.page_infos = {} # 存储页面附加信息: display, layout, loaded self.current_page = None # 当前页面 - self.base_path = "app.view.settings.list_management" # 默认基础路径 + self.base_path = base_path or "app.view.settings.list_management" self._page_load_order = [] # 页面加载顺序,用于LRU卸载 self._pending_page_loads = set() self.MAX_CACHED_PAGES = MAX_CACHED_PAGES # 最大同时保留在内存中的页面数量 self._is_preview_mode = is_preview_mode self.__connectSignalToSlot() - - QTimer.singleShot(0, self.create_ui_components) + self.create_ui_components() @property def is_preview_mode(self): @@ -412,11 +417,9 @@ def add_page(self, page_name: str, display_name: str): display_name: 在 Pivot 中显示的名称 """ if not self.ui_created: - # 如果UI尚未创建,延迟添加 - QTimer.singleShot( - APP_INIT_DELAY, lambda: self.add_page(page_name, display_name) - ) - return + self.create_ui_components() + if page_name in self.pages: + return # 创建滑动区域 scroll_area = SingleDirectionScrollArea(self) diff --git a/app/page_building/settings_window_page.py b/app/page_building/settings_window_page.py index 94418e99..bc52ff33 100644 --- a/app/page_building/settings_window_page.py +++ b/app/page_building/settings_window_page.py @@ -44,8 +44,12 @@ def __init__(self, parent: QFrame = None, is_preview=False): "roll_call_table": get_content_name_async("roll_call_table", "title"), "lottery_table": get_content_name_async("lottery_table", "title"), } - super().__init__(page_config, parent, is_preview_mode=is_preview) - self.set_base_path("app.view.settings.list_management") + super().__init__( + page_config, + parent, + is_preview_mode=is_preview, + base_path="app.view.settings.list_management", + ) class extraction_settings_page(PivotPageTemplate): @@ -62,8 +66,12 @@ def __init__(self, parent: QFrame = None, is_preview=False): "face_detector_settings", "title" ), } - super().__init__(page_config, parent, is_preview_mode=is_preview) - self.set_base_path("app.view.settings.extraction_settings") + super().__init__( + page_config, + parent, + is_preview_mode=is_preview, + base_path="app.view.settings.extraction_settings", + ) class floating_window_management_page(PageTemplate): @@ -92,8 +100,12 @@ def __init__(self, parent: QFrame = None, is_preview=False): "lottery_notification_settings", "title" ), } - super().__init__(page_config, parent, is_preview_mode=is_preview) - self.set_base_path("app.view.settings.notification_settings") + super().__init__( + page_config, + parent, + is_preview_mode=is_preview, + base_path="app.view.settings.notification_settings", + ) class safety_settings_page(PageTemplate): @@ -119,8 +131,12 @@ def __init__(self, parent: QFrame = None, is_preview=False): "specific_announcements", "title" ), } - super().__init__(page_config, parent, is_preview_mode=is_preview) - self.set_base_path("app.view.settings.voice_settings") + super().__init__( + page_config, + parent, + is_preview_mode=is_preview, + base_path="app.view.settings.voice_settings", + ) class history_page(PivotPageTemplate): @@ -136,8 +152,12 @@ def __init__(self, parent: QFrame = None, is_preview=False): "lottery_history_table", "title" ), } - super().__init__(page_config, parent, is_preview_mode=is_preview) - self.set_base_path("app.view.settings.history") + super().__init__( + page_config, + parent, + is_preview_mode=is_preview, + base_path="app.view.settings.history", + ) class linkage_settings_page(PageTemplate): @@ -192,8 +212,12 @@ def __init__(self, parent: QFrame = None, is_preview=False): page_config["behind_scenes_settings"] = get_content_name_async( "behind_scenes_settings", "title" ) - super().__init__(page_config, parent, is_preview_mode=is_preview) - self.set_base_path("app.view.settings.more_settings") + super().__init__( + page_config, + parent, + is_preview_mode=is_preview, + base_path="app.view.settings.more_settings", + ) class update_page(PageTemplate): diff --git a/app/view/floating_window/levitation.py b/app/view/floating_window/levitation.py index 71df11a8..c0693240 100644 --- a/app/view/floating_window/levitation.py +++ b/app/view/floating_window/levitation.py @@ -29,8 +29,8 @@ get_content_combo_name_async, ) from app.common.extraction.extract import _is_non_class_time -from app.common.safety.verify_ops import require_and_run from app.common.data.list import get_class_name_list, get_group_list, get_gender_list +from app.common.safety.verify_proxy import require_and_run_lazy class _QuickDrawPanelSignals(QObject): @@ -1383,7 +1383,7 @@ def _handle_button_click(self, signal): if verification_required: # 如果需要验证流程,弹出密码验证窗口 logger.info("当前时间在非上课时间段内,需要密码验证") - require_and_run( + require_and_run_lazy( "quick_draw", self, lambda: self._emit_signal(signal) ) return diff --git a/app/view/main/camera_preview.py b/app/view/main/camera_preview.py index 9b6cdb93..5994bf11 100644 --- a/app/view/main/camera_preview.py +++ b/app/view/main/camera_preview.py @@ -2,7 +2,9 @@ import os import random +import threading import time +from importlib import import_module from typing import Optional from PySide6.QtWidgets import * @@ -17,19 +19,61 @@ get_content_pushbutton_name_async, ) -from app.common.camera_preview_backend import ( - get_cached_camera_devices, - get_recommended_camera_resolution, - OpenCVCaptureWorker, - FaceDetectorWorker, - bgr_frame_to_qimage, - warmup_camera_devices_async, -) -from app.common.camera_preview_backend.audio_player import camera_preview_audio_player from app.tools.settings_access import get_settings_signals, readme_settings_async from app.common.roll_call import roll_call_manager +_CAMERA_BACKEND_IMPORT_LOCK = threading.Lock() +_CAMERA_DEVICES_MODULE = None +_CAMERA_WORKERS_MODULE = None +_CAMERA_IMAGE_MODULE = None +_CAMERA_AUDIO_MODULE = None + + +def _get_camera_devices_module(): + global _CAMERA_DEVICES_MODULE + if _CAMERA_DEVICES_MODULE is None: + with _CAMERA_BACKEND_IMPORT_LOCK: + if _CAMERA_DEVICES_MODULE is None: + _CAMERA_DEVICES_MODULE = import_module( + "app.common.camera_preview_backend.devices" + ) + return _CAMERA_DEVICES_MODULE + + +def _get_camera_workers_module(): + global _CAMERA_WORKERS_MODULE + if _CAMERA_WORKERS_MODULE is None: + with _CAMERA_BACKEND_IMPORT_LOCK: + if _CAMERA_WORKERS_MODULE is None: + _CAMERA_WORKERS_MODULE = import_module( + "app.common.camera_preview_backend.workers" + ) + return _CAMERA_WORKERS_MODULE + + +def _get_camera_image_module(): + global _CAMERA_IMAGE_MODULE + if _CAMERA_IMAGE_MODULE is None: + with _CAMERA_BACKEND_IMPORT_LOCK: + if _CAMERA_IMAGE_MODULE is None: + _CAMERA_IMAGE_MODULE = import_module( + "app.common.camera_preview_backend.image_utils" + ) + return _CAMERA_IMAGE_MODULE + + +def _get_camera_audio_player(): + global _CAMERA_AUDIO_MODULE + if _CAMERA_AUDIO_MODULE is None: + with _CAMERA_BACKEND_IMPORT_LOCK: + if _CAMERA_AUDIO_MODULE is None: + _CAMERA_AUDIO_MODULE = import_module( + "app.common.camera_preview_backend.audio_player" + ) + return _CAMERA_AUDIO_MODULE.camera_preview_audio_player + + class CameraPreview(QWidget): """带有 OpenCV 捕获和 ONNX 人脸检测的摄像头预览页面。""" @@ -86,9 +130,10 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: self._last_render_at: float = 0.0 self._capture_thread: Optional[QThread] = None - self._capture_worker: Optional[OpenCVCaptureWorker] = None + self._capture_worker: Optional[object] = None self._detector_thread: Optional[QThread] = None - self._detector_worker: Optional[FaceDetectorWorker] = None + self._detector_worker: Optional[object] = None + self._workers_state: str = "not_started" self._init_poll_left: int = 0 self._init_poll_timer = QTimer(self) self._init_poll_timer.setSingleShot(True) @@ -126,15 +171,18 @@ def hideEvent(self, event: QHideEvent) -> None: super().hideEvent(event) def _ensure_workers_nonblocking(self) -> None: - if self._workers_ready: + if self._workers_ready or self._workers_state == "initializing": return - devices = get_cached_camera_devices() + self._workers_state = "initializing" + devices = _get_camera_devices_module().get_cached_camera_devices() if devices: self._init_workers(devices=devices) return try: - warmup_camera_devices_async(force_refresh=False) + _get_camera_devices_module().warmup_camera_devices_async( + force_refresh=False + ) except Exception: pass @@ -146,7 +194,9 @@ def _ensure_workers_nonblocking(self) -> None: def _try_init_workers_nonblocking(self) -> None: if self._workers_ready: return - devices = get_cached_camera_devices() + if self._workers_state != "initializing": + return + devices = _get_camera_devices_module().get_cached_camera_devices() if devices: self._init_workers(devices=devices) try: @@ -161,6 +211,7 @@ def _try_init_workers_nonblocking(self) -> None: if self._init_poll_left > 0: self._init_poll_timer.start(200) return + self._workers_state = "not_started" try: self.start_button.setEnabled(False) except Exception: @@ -231,7 +282,7 @@ def _apply_preview_mode(self, mode_index: Optional[int] = None) -> None: try: if self._audio_loop_started: - camera_preview_audio_player.stop(wait=False) + _get_camera_audio_player().stop(wait=False) except Exception: pass self._audio_loop_started = False @@ -336,7 +387,7 @@ def _stop_preview_capture(self) -> None: self._disconnect_frame_pipeline() try: - camera_preview_audio_player.stop(wait=False) + _get_camera_audio_player().stop(wait=False) except Exception: pass self._audio_loop_started = False @@ -508,12 +559,13 @@ def _init_workers(self, devices: Optional[list] = None) -> None: os.environ.setdefault("OPENCV_VIDEOIO_PRIORITY_MSMF", "1") if devices is None: - devices = get_cached_camera_devices() + devices = _get_camera_devices_module().get_cached_camera_devices() try: self._devices = list(devices or []) except Exception: self._devices = [] if not self._devices: + self._workers_state = "not_started" self.start_button.setEnabled(False) self.preview_label.setText( get_content_description_async("camera_preview", "no_camera") @@ -557,9 +609,12 @@ def _init_workers(self, devices: Optional[list] = None) -> None: default_camera_id = default_source desired_resolution = self._resolve_camera_display_resolution(default_camera_id) + workers_module = _get_camera_workers_module() self._capture_thread = QThread(self) - self._capture_worker = OpenCVCaptureWorker(default_source, desired_resolution) + self._capture_worker = workers_module.OpenCVCaptureWorker( + default_source, desired_resolution + ) self._capture_worker.moveToThread(self._capture_thread) self._capture_thread.started.connect(self._capture_worker.start) self.capture_stop_requested.connect(self._capture_worker.stop) @@ -576,7 +631,7 @@ def _init_workers(self, devices: Optional[list] = None) -> None: self._detector_thread = QThread(self) detector_type = readme_settings_async("face_detector_settings", "detector_type") - self._detector_worker = FaceDetectorWorker() + self._detector_worker = workers_module.FaceDetectorWorker() self._detector_worker.set_model_filename(detector_type) try: w = int( @@ -606,12 +661,14 @@ def _init_workers(self, devices: Optional[list] = None) -> None: self._capture_thread.start() except Exception as exc: logger.exception("启动摄像头线程失败: {}", exc) + self._workers_state = "not_started" self._show_message("unavailable", details=str(exc)) self.start_button.setEnabled(False) return self._connect_frame_pipeline() self._workers_ready = True + self._workers_state = "ready" def _on_start_clicked(self) -> None: """启动人脸检测流程。""" @@ -867,7 +924,11 @@ def _resolve_camera_display_resolution( break except Exception: continue - recommended = get_recommended_camera_resolution(preferred_id) + recommended = ( + _get_camera_devices_module().get_recommended_camera_resolution( + preferred_id + ) + ) except Exception: recommended = None if recommended is not None: @@ -908,7 +969,7 @@ def _stop_detection_and_reset(self) -> None: pass if self._audio_loop_started: try: - camera_preview_audio_player.stop(wait=False) + _get_camera_audio_player().stop(wait=False) except Exception: pass self._audio_loop_started = False @@ -979,7 +1040,7 @@ def _render_latest_frame(self) -> None: self._render_inflight = True try: try: - qimage = bgr_frame_to_qimage(frame_bgr) + qimage = _get_camera_image_module().bgr_frame_to_qimage(frame_bgr) except Exception as exc: logger.exception("帧转换失败: {}", exc) return @@ -1091,7 +1152,7 @@ def _on_faces_detected(self, rects: list[tuple[int, int, int, int]]) -> None: self._load_picker_settings() if self._play_process_audio and not self._audio_loop_started: try: - started = camera_preview_audio_player.play( + started = _get_camera_audio_player().play( "face/process.wav", loop=True, volume=1.0 ) self._audio_loop_started = True if started else False @@ -1299,7 +1360,9 @@ def _commit_locked_face(self) -> None: qimage: Optional[QImage] = None try: if self._latest_frame is not None: - qimage = bgr_frame_to_qimage(self._latest_frame) + qimage = _get_camera_image_module().bgr_frame_to_qimage( + self._latest_frame + ) except Exception: qimage = None if ( @@ -1393,14 +1456,14 @@ def _show_final_result( if self._audio_loop_started: try: - camera_preview_audio_player.stop(wait=False) + _get_camera_audio_player().stop(wait=False) except Exception: pass self._audio_loop_started = False if self._play_result_audio: try: - camera_preview_audio_player.play( + _get_camera_audio_player().play( "face/result.wav", loop=False, volume=1.0 ) except Exception: @@ -1445,7 +1508,9 @@ def _set_final_selection(self, rect: tuple[int, int, int, int]) -> None: qimage: Optional[QImage] = None try: if self._latest_frame is not None: - qimage = bgr_frame_to_qimage(self._latest_frame) + qimage = _get_camera_image_module().bgr_frame_to_qimage( + self._latest_frame + ) except Exception: qimage = None @@ -1480,14 +1545,14 @@ def _set_final_selection(self, rect: tuple[int, int, int, int]) -> None: if self._audio_loop_started: try: - camera_preview_audio_player.stop(wait=False) + _get_camera_audio_player().stop(wait=False) except Exception: pass self._audio_loop_started = False if self._play_result_audio: try: - camera_preview_audio_player.play( + _get_camera_audio_player().play( "face/result.wav", loop=False, volume=1.0 ) except Exception: @@ -1551,4 +1616,11 @@ def closeEvent(self, event: QCloseEvent) -> None: except Exception as exc: logger.exception("线程关闭失败: {}", exc) + self._workers_ready = False + self._workers_state = "not_started" + self._capture_thread = None + self._capture_worker = None + self._detector_thread = None + self._detector_worker = None + super().closeEvent(event) diff --git a/app/view/main/window.py b/app/view/main/window.py index 85b046b6..4677115b 100644 --- a/app/view/main/window.py +++ b/app/view/main/window.py @@ -1,13 +1,14 @@ # ================================================== # 导入库 # ================================================== +from typing import TYPE_CHECKING + from loguru import logger from PySide6.QtWidgets import QApplication, QWidget, QVBoxLayout from PySide6.QtGui import QIcon from PySide6.QtCore import QTimer, QEvent, Signal, QThreadPool, QRunnable, Qt from qfluentwidgets import FluentWindow, NavigationItemPosition -from app.common.IPC_URL.csharp_ipc_handler import CSharpIPCHandler from app.common.shortcut import ShortcutManager from app.tools.variable import ( MINIMUM_WINDOW_SIZE, @@ -24,25 +25,22 @@ readme_settings_async, update_settings, ) -from app.common.safety.verify_ops import require_and_run -from app.view.main.quick_draw_animation import QuickDrawAnimation from app.tools.list_specific_settings_access import ( read_quick_draw_setting, get_safe_font_size_list_specific, ) -from app.view.main.camera_preview import CameraPreview from app.page_building.main_window_page import ( roll_call_page, lottery_page, history_page, ) -from app.view.tray.tray import Tray -from app.view.floating_window.levitation import LevitationWindow -from app.common.IPC_URL.url_command_handler import URLCommandHandler from app.page_building.window_template import BackgroundLayer -from app.page_building.another_window import create_countdown_timer_window +from app.common.safety.verify_proxy import require_and_run_lazy from app.tools.settings_access import get_settings_snapshot +if TYPE_CHECKING: + from app.view.floating_window.levitation import LevitationWindow + # ================================================== # 主窗口类 @@ -59,7 +57,7 @@ class MainWindow(FluentWindow): showTrayActionRequested = Signal(str) # 请求执行托盘操作 classIslandDataReceived = Signal(dict) # 接收ClassIsland数据信号 - def __init__(self, float_window: LevitationWindow, url_handler_instance=None): + def __init__(self, float_window: "LevitationWindow", url_handler_instance=None): self.resize_timer = None super().__init__() self.setObjectName("MainWindow") @@ -90,6 +88,7 @@ def _initialize_variables(self): self._has_been_shown = False self._post_startup_tasks_scheduled = False self.pre_class_reset_performed = False + self._sub_interface_create_count = 0 def _setup_timers(self): """设置定时器""" @@ -235,9 +234,11 @@ def _apply_topmost_mode(self, mode=None, snapshot=None): def _setup_url_handler(self): """设置URL处理器""" + from app.common.IPC_URL.url_command_handler import URLCommandHandler + self.url_command_handler = URLCommandHandler(self) self.url_command_handler.showMainPageRequested.connect( - self._handle_main_page_requested + self.showMainPageRequested.emit ) self.url_command_handler.showSettingsRequested.connect( self.showSettingsRequested.emit @@ -251,6 +252,8 @@ def _setup_url_handler(self): def _setup_tray(self): """设置系统托盘""" + from app.view.tray.tray import Tray + self.tray_icon = Tray(self) self.tray_icon.showSettingsRequested.connect(self.showSettingsRequested.emit) self.tray_icon.showSettingsRequestedAbout.connect( @@ -264,25 +267,28 @@ def _setup_tray(self): ) self.tray_icon.show_tray_icon() - def _setup_float_window(self, float_window: LevitationWindow): + def _setup_float_window(self, float_window: "LevitationWindow"): """设置悬浮窗""" self.float_window = float_window self.showFloatWindowRequested.connect(self._toggle_float_window) self.showMainPageRequested.connect(self._handle_main_page_requested) self.showTrayActionRequested.connect(self._handle_tray_action_requested) self.float_window.rollCallRequested.connect( - lambda: self._show_and_switch_to_page("roll_call_page") + lambda: self.showMainPageRequested.emit("roll_call_page") ) self.float_window.quickDrawRequested.connect(self._handle_quick_draw) self.float_window.lotteryRequested.connect( - lambda: self._show_and_switch_to_page("lottery_page") + lambda: self.showMainPageRequested.emit("lottery_page") ) self.float_window.faceDrawRequested.connect( - lambda: self._show_and_switch_to_page("camera_preview_page") - ) - self.float_window.timerRequested.connect( - lambda: create_countdown_timer_window() + lambda: self.showMainPageRequested.emit("camera_preview_page") ) + self.float_window.timerRequested.connect(self._open_countdown_timer_window) + + def _open_countdown_timer_window(self): + from app.page_building.another_window import create_countdown_timer_window + + create_countdown_timer_window() # ================================================== # IPC 服务器管理 @@ -526,7 +532,7 @@ def createSubInterface(self): self._page_factories = { "roll_call_page": lambda parent: roll_call_page(parent), "lottery_page": lambda parent: lottery_page(parent), - "camera_preview_page": lambda parent: CameraPreview(parent), + "camera_preview_page": self._create_camera_preview_page, "history_page": lambda parent: history_page(parent), } @@ -544,6 +550,10 @@ def createSubInterface(self): except Exception: pass self._sub_interface_created = True + self._sub_interface_create_count += 1 + logger.debug( + f"主窗口导航壳创建完成,累计有效创建次数: {self._sub_interface_create_count}" + ) def _register_main_page_shell(self, page_name: str): shell = QWidget(self) @@ -626,10 +636,13 @@ def _show_and_switch_to_page(self, page_name: str): return self._ensure_main_page_loaded(page_name) + self._activate_main_window() + self.switchTo(shell) + + def _activate_main_window(self): self._show_main_window() self.activateWindow() self.raise_() - self.switchTo(shell) def _on_main_stacked_widget_changed(self, index: int): try: @@ -644,6 +657,11 @@ def _on_main_stacked_widget_changed(self, index: int): if page_name in self._registered_pages: self._ensure_main_page_loaded(page_name) + def _create_camera_preview_page(self, parent): + from app.view.main.camera_preview import CameraPreview + + return CameraPreview(parent) + def initNavigation(self): """初始化导航系统 根据用户设置构建个性化菜单导航""" @@ -783,9 +801,7 @@ def _show_and_switch_to(self, page): self._show_and_switch_to_page(page_name) return - self._show_main_window() - self.activateWindow() - self.raise_() + self._activate_main_window() self.switchTo(page) def _handle_main_page_requested(self, page_name: str): @@ -799,9 +815,7 @@ def _handle_main_page_requested(self, page_name: str): ) if page_name == "main_window": logger.debug("MainWindow._handle_main_page_requested: 显示主窗口") - self._show_main_window() - self.raise_() - self.activateWindow() + self._activate_main_window() elif page_name in self._page_shells: logger.debug( f"MainWindow._handle_main_page_requested: 切换到页面: {page_name}" @@ -826,9 +840,9 @@ def _handle_tray_action_requested(self, action: str): elif action == "float": self._toggle_float_window() elif action == "restart": - require_and_run("restart", self, self.restart_app) + require_and_run_lazy("restart", self, self.restart_app) elif action == "exit": - require_and_run("exit", self, self.close_window_secrandom) + require_and_run_lazy("exit", self, self.close_window_secrandom) else: logger.warning(f"未知的托盘操作: {action}") @@ -841,7 +855,7 @@ def _connect_shortcut_signals(self): logger.debug("开始连接快捷键信号...") self.shortcut_manager.openRollCallPageRequested.connect( - lambda: self._show_and_switch_to_page("roll_call_page") + lambda: self.showMainPageRequested.emit("roll_call_page") ) logger.debug("快捷键信号已连接: openRollCallPageRequested") @@ -849,7 +863,7 @@ def _connect_shortcut_signals(self): logger.debug("快捷键信号已连接: useQuickDrawRequested") self.shortcut_manager.openLotteryPageRequested.connect( - lambda: self._show_and_switch_to_page("lottery_page") + lambda: self.showMainPageRequested.emit("lottery_page") ) logger.debug("快捷键信号已连接: openLotteryPageRequested") @@ -1117,6 +1131,8 @@ def _execute_quick_draw_animation(self, roll_call_widget): "default_class": list_name, } + from app.view.main.quick_draw_animation import QuickDrawAnimation + quick_draw_animation = QuickDrawAnimation(roll_call_widget) quick_draw_animation.execute_quick_draw(quick_draw_settings) @@ -1191,6 +1207,8 @@ def _get_on_class_left_time(self, data_source): int: 距离上课的秒数,如果不需要重置则返回None """ if data_source == 2: + from app.common.IPC_URL.csharp_ipc_handler import CSharpIPCHandler + return CSharpIPCHandler.instance().get_on_class_left_time() elif data_source == 1: from app.common.extraction.extract import _get_seconds_to_next_class @@ -1320,6 +1338,8 @@ def _cleanup_shortcuts(self): def _stop_ipc_client(self): """停止IPC客户端""" try: + from app.common.IPC_URL.csharp_ipc_handler import CSharpIPCHandler + CSharpIPCHandler.instance().stop_ipc_client() logger.debug("C# IPC 停止请求已发出") except Exception as e: diff --git a/app/view/settings/extraction_settings/face_detector_settings.py b/app/view/settings/extraction_settings/face_detector_settings.py index 874fb4e5..04f3b616 100644 --- a/app/view/settings/extraction_settings/face_detector_settings.py +++ b/app/view/settings/extraction_settings/face_detector_settings.py @@ -1,4 +1,6 @@ import os +import threading +from importlib import import_module from loguru import logger from PySide6.QtCore import QTimer, QUrl @@ -23,17 +25,27 @@ get_content_pushbutton_name_async, get_content_switchbutton_name_async, ) -from app.common.camera_preview_backend import ( - get_cached_camera_devices, - list_camera_resolutions, - warmup_camera_devices_async, -) from app.tools.path_utils import get_data_path from app.tools.personalised import get_theme_icon from app.tools.settings_access import readme_settings_async, update_settings from app.tools.variable import WIDTH_SPINBOX +_FACE_SETTINGS_BACKEND_LOCK = threading.Lock() +_FACE_SETTINGS_DEVICES_MODULE = None + + +def _get_camera_devices_module(): + global _FACE_SETTINGS_DEVICES_MODULE + if _FACE_SETTINGS_DEVICES_MODULE is None: + with _FACE_SETTINGS_BACKEND_LOCK: + if _FACE_SETTINGS_DEVICES_MODULE is None: + _FACE_SETTINGS_DEVICES_MODULE = import_module( + "app.common.camera_preview_backend.devices" + ) + return _FACE_SETTINGS_DEVICES_MODULE + + class face_detector_settings(QWidget): def __init__(self, parent=None): super().__init__(parent) @@ -262,16 +274,20 @@ def _init_camera_combo(self) -> None: self._refresh_camera_resolution_list() if self.camera_combo.count() <= 0: try: - warmup_camera_devices_async(force_refresh=False) + _get_camera_devices_module().warmup_camera_devices_async( + force_refresh=False + ) except Exception: pass self._schedule_camera_poll() def _list_cameras(self, force_refresh: bool = False): try: - devices = get_cached_camera_devices() + devices = _get_camera_devices_module().get_cached_camera_devices() if force_refresh or not devices: - warmup_camera_devices_async(force_refresh=force_refresh) + _get_camera_devices_module().warmup_camera_devices_async( + force_refresh=force_refresh + ) return devices except Exception: return [] @@ -454,7 +470,9 @@ def _refresh_camera_resolution_list(self) -> None: camera_id = None try: - resolutions = list_camera_resolutions(camera_id) + resolutions = _get_camera_devices_module().list_camera_resolutions( + camera_id + ) except Exception: resolutions = [] diff --git a/app/view/settings/safety_settings.py b/app/view/settings/safety_settings.py index ac8b1257..37c45dd3 100644 --- a/app/view/settings/safety_settings.py +++ b/app/view/settings/safety_settings.py @@ -15,17 +15,67 @@ from app.tools.settings_default import * from app.tools.settings_access import * from app.Language.obtain_language import * -from app.page_building.security_window import ( - create_set_password_window, - create_set_totp_window, - create_bind_usb_window, - create_unbind_usb_window, -) -from app.common.safety.verify_ops import require_and_run -from app.common.safety.usb import has_binding, is_bound_present -from app.common.safety.secure_store import read_secrets, write_secrets -from app.common.safety.password import is_configured as password_is_configured -from app.common.safety.totp import is_configured as totp_is_configured +from app.common.safety.verify_proxy import require_and_run_lazy + + +def _create_set_password_window(): + from app.page_building.security_window import create_set_password_window + + return create_set_password_window() + + +def _create_set_totp_window(): + from app.page_building.security_window import create_set_totp_window + + return create_set_totp_window() + + +def _create_bind_usb_window(): + from app.page_building.security_window import create_bind_usb_window + + return create_bind_usb_window() + + +def _create_unbind_usb_window(): + from app.page_building.security_window import create_unbind_usb_window + + return create_unbind_usb_window() + + +def _read_secrets(): + from app.common.safety.secure_store import read_secrets + + return read_secrets() + + +def _write_secrets(data): + from app.common.safety.secure_store import write_secrets + + return write_secrets(data) + + +def _is_password_configured() -> bool: + from app.common.safety.password import is_configured as password_is_configured + + return bool(password_is_configured()) + + +def _is_totp_configured() -> bool: + from app.common.safety.totp import is_configured as totp_is_configured + + return bool(totp_is_configured()) + + +def _has_binding() -> bool: + from app.common.safety.usb import has_binding + + return bool(has_binding()) + + +def _is_bound_present() -> bool: + from app.common.safety.usb import is_bound_present + + return bool(is_bound_present()) # ================================================== @@ -107,14 +157,15 @@ def __init__(self, parent=None): self.safety_switch.setChecked( readme_settings_async("basic_safety_settings", "safety_switch") ) - try: - sec = read_secrets() - bs = sec.get("basic_safety_settings") or {} - if "safety_switch" in bs: - self.safety_switch.setChecked(bool(bs.get("safety_switch"))) - except Exception: - pass - if self.safety_switch.isChecked() and not password_is_configured(): + if self.safety_switch.isChecked(): + try: + sec = _read_secrets() + bs = sec.get("basic_safety_settings") or {} + if "safety_switch" in bs: + self.safety_switch.setChecked(bool(bs.get("safety_switch"))) + except Exception: + pass + if self.safety_switch.isChecked() and not _is_password_configured(): self.safety_switch.setChecked(False) update_settings("basic_safety_settings", "safety_switch", False) self.safety_switch.checkedChanged.connect(self.__on_safety_switch_changed) @@ -140,14 +191,19 @@ def __init__(self, parent=None): self.totp_switch.setChecked( readme_settings_async("basic_safety_settings", "totp_switch") ) - try: - sec = read_secrets() - bs = sec.get("basic_safety_settings") or {} - if "totp_switch" in bs: - self.totp_switch.setChecked(bool(bs.get("totp_switch"))) - except Exception: - pass - if self.totp_switch.isChecked() and not totp_is_configured(): + if self.safety_switch.isChecked(): + try: + sec = _read_secrets() + bs = sec.get("basic_safety_settings") or {} + if "totp_switch" in bs: + self.totp_switch.setChecked(bool(bs.get("totp_switch"))) + except Exception: + pass + if ( + self.safety_switch.isChecked() + and self.totp_switch.isChecked() + and not _is_totp_configured() + ): self.totp_switch.setChecked(False) update_settings("basic_safety_settings", "totp_switch", False) self.totp_switch.checkedChanged.connect(self.__on_totp_switch_changed) @@ -173,14 +229,19 @@ def __init__(self, parent=None): self.usb_switch.setChecked( readme_settings_async("basic_safety_settings", "usb_switch") ) - try: - sec = read_secrets() - bs = sec.get("basic_safety_settings") or {} - if "usb_switch" in bs: - self.usb_switch.setChecked(bool(bs.get("usb_switch"))) - except Exception: - pass - if self.usb_switch.isChecked() and not has_binding(): + if self.safety_switch.isChecked(): + try: + sec = _read_secrets() + bs = sec.get("basic_safety_settings") or {} + if "usb_switch" in bs: + self.usb_switch.setChecked(bool(bs.get("usb_switch"))) + except Exception: + pass + if ( + self.safety_switch.isChecked() + and self.usb_switch.isChecked() + and not _has_binding() + ): self.usb_switch.setChecked(False) update_settings("basic_safety_settings", "usb_switch", False) self.usb_switch.checkedChanged.connect(self.__on_usb_switch_changed) @@ -241,7 +302,7 @@ def __init__(self, parent=None): self.unbind_usb_button, ) - if not password_is_configured(): + if self.safety_switch.isChecked() and not _is_password_configured(): self.totp_switch.setEnabled(False) self.set_totp_button.setEnabled(False) self.usb_switch.setEnabled(False) @@ -288,53 +349,53 @@ def _set_switch(self, switch: SwitchButton, key: str, desired: bool): def _persist_basic_safety(self, name: str, value: bool): try: - sec = read_secrets() + sec = _read_secrets() bs = sec.get("basic_safety_settings") or {} bs[name] = bool(value) sec["basic_safety_settings"] = bs - write_secrets(sec) + _write_secrets(sec) except Exception: pass def set_password(self): - create_set_password_window() + _create_set_password_window() def set_totp(self): - if not password_is_configured(): + if not _is_password_configured(): self._notify_error( get_content_name_async( "basic_safety_settings", "error_set_password_first" ) ) return - require_and_run("set_totp", self, create_set_totp_window) + require_and_run_lazy("set_totp", self, _create_set_totp_window) def bind_usb(self): - if not password_is_configured(): + if not _is_password_configured(): self._notify_error( get_content_name_async( "basic_safety_settings", "error_set_password_first" ) ) return - require_and_run("bind_usb", self, create_bind_usb_window) + require_and_run_lazy("bind_usb", self, _create_bind_usb_window) def unbind_usb(self): - if not password_is_configured(): + if not _is_password_configured(): self._notify_error( get_content_name_async( "basic_safety_settings", "error_set_password_first" ) ) return - require_and_run("unbind_usb", self, create_unbind_usb_window) + require_and_run_lazy("unbind_usb", self, _create_unbind_usb_window) def __on_safety_switch_changed(self): if self._busy: return self._busy = True try: - if self.safety_switch.isChecked() and not password_is_configured(): + if self.safety_switch.isChecked() and not _is_password_configured(): self.safety_switch.setChecked(False) update_settings("basic_safety_settings", "safety_switch", False) self._notify_error( @@ -361,7 +422,7 @@ def apply(): self._update_components_enabled_state() logger.debug(f"安全总开关状态:{bool(desired)}") - require_and_run("toggle_safety", self, apply) + require_and_run_lazy("toggle_safety", self, apply) finally: self._busy = False @@ -371,7 +432,7 @@ def __on_totp_switch_changed(self): self._busy = True try: desired = bool(self.totp_switch.isChecked()) - if desired and not totp_is_configured(): + if desired and not _is_totp_configured(): self._set_switch(self.totp_switch, "totp_switch", False) self._notify_error( get_content_name_async( @@ -406,7 +467,7 @@ def apply(): ) logger.debug(f"TOTP开关状态:{bool(desired)}") - require_and_run("toggle_totp", self, apply) + require_and_run_lazy("toggle_totp", self, apply) finally: self._busy = False @@ -417,9 +478,11 @@ def __on_usb_switch_changed(self): try: desired = bool(self.usb_switch.isChecked()) try: - need_present = desired and (not has_binding() or not is_bound_present()) + need_present = desired and ( + not _has_binding() or not _is_bound_present() + ) except Exception: - need_present = desired and (not has_binding()) + need_present = desired and (not _has_binding()) if need_present: self._set_switch(self.usb_switch, "usb_switch", False) self._notify_error( @@ -455,14 +518,14 @@ def apply(): ) logger.debug(f"U盘验证开关状态:{bool(desired)}") - require_and_run("toggle_usb", self, apply) + require_and_run_lazy("toggle_usb", self, apply) finally: self._busy = False def _update_components_enabled_state(self): """根据安全总开关状态更新其他组件的启用状态(内部方法)""" safety_enabled = self.safety_switch.isChecked() - password_configured = password_is_configured() + password_configured = _is_password_configured() if safety_enabled else False # 总开关关闭时,禁用除总开关外的所有安全相关组件 if not safety_enabled: @@ -481,7 +544,9 @@ def _update_components_enabled_state(self): def _update_components_enabled_state_based_on_global(self, global_safety_enabled): """根据全局安全总开关状态更新组件的启用状态""" - password_configured = password_is_configured() + password_configured = ( + _is_password_configured() if global_safety_enabled else False + ) # 如果全局安全总开关关闭,禁用除总开关外的所有安全相关组件 if not global_safety_enabled: @@ -530,7 +595,7 @@ def __on_setting_changed(self, first_level_key, second_level_key, value): self.usb_switch.setChecked(bool(value)) self.usb_switch.blockSignals(False) - enabled = password_is_configured() + enabled = _is_password_configured() if self.safety_switch.isChecked() else False self.totp_switch.setEnabled(enabled and self.safety_switch.isChecked()) self.set_totp_button.setEnabled(self.safety_switch.isChecked()) self.usb_switch.setEnabled(enabled and self.safety_switch.isChecked()) @@ -589,7 +654,7 @@ def apply(): self.verification_process_combo.setCurrentIndex(desired) self.verification_process_combo.blockSignals(False) - require_and_run("change_verification_process", self, apply) + require_and_run_lazy("change_verification_process", self, apply) def _update_enabled_state(self, enabled): """根据安全总开关状态更新组件的启用状态""" @@ -743,12 +808,24 @@ def __init__(self, parent=None): self.open_settings_switch, ) - self._ensure_ops_disabled_if_no_password() + safety_enabled = bool( + readme_settings_async("basic_safety_settings", "safety_switch") + ) + if safety_enabled: + self._ensure_ops_disabled_if_no_password() + else: + self._update_enabled_state(False) def __on_ops_setting_changed(self, first_level_key, second_level_key, value): if first_level_key != "basic_safety_settings": return - self._ensure_ops_disabled_if_no_password() + if second_level_key == "safety_switch": + self._update_enabled_state(bool(value)) + if not bool(value): + return + + if readme_settings_async("basic_safety_settings", "safety_switch"): + self._ensure_ops_disabled_if_no_password() # 只在状态不同时才更新,避免不必要的信号触发 if ( @@ -787,7 +864,11 @@ def __on_ops_setting_changed(self, first_level_key, second_level_key, value): self.preview_settings_switch.blockSignals(False) def _ensure_ops_disabled_if_no_password(self): - enabled = password_is_configured() + if not readme_settings_async("basic_safety_settings", "safety_switch"): + self._update_enabled_state(False) + return + + enabled = _is_password_configured() self.show_hide_floating_window_switch.setEnabled(enabled) self.restart_switch.setEnabled(enabled) self.exit_switch.setEnabled(enabled) @@ -819,7 +900,7 @@ def __on_ops_show_hide_changed(self): return self._busy = True try: - if not password_is_configured(): + if not _is_password_configured(): try: self.show_hide_floating_window_switch.blockSignals(True) self.show_hide_floating_window_switch.setChecked(False) @@ -849,7 +930,7 @@ def apply(): desired, ) - require_and_run("toggle_show_hide_floating_window_switch", self, apply) + require_and_run_lazy("toggle_show_hide_floating_window_switch", self, apply) finally: self._busy = False @@ -858,7 +939,7 @@ def __on_ops_restart_changed(self): return self._busy = True try: - if not password_is_configured(): + if not _is_password_configured(): try: self.restart_switch.blockSignals(True) self.restart_switch.setChecked(False) @@ -882,7 +963,7 @@ def apply(): desired, ) - require_and_run("toggle_restart_switch", self, apply) + require_and_run_lazy("toggle_restart_switch", self, apply) finally: self._busy = False @@ -891,7 +972,7 @@ def __on_ops_exit_changed(self): return self._busy = True try: - if not password_is_configured(): + if not _is_password_configured(): try: self.exit_switch.blockSignals(True) self.exit_switch.setChecked(False) @@ -913,7 +994,7 @@ def apply(): desired, ) - require_and_run("toggle_exit_switch", self, apply) + require_and_run_lazy("toggle_exit_switch", self, apply) finally: self._busy = False @@ -922,7 +1003,7 @@ def __on_ops_open_settings_changed(self): return self._busy = True try: - if not password_is_configured(): + if not _is_password_configured(): try: self.open_settings_switch.blockSignals(True) self.open_settings_switch.setChecked(False) @@ -948,7 +1029,7 @@ def apply(): desired, ) - require_and_run("toggle_open_settings_switch", self, apply) + require_and_run_lazy("toggle_open_settings_switch", self, apply) finally: self._busy = False @@ -957,7 +1038,7 @@ def __on_ops_preview_settings_changed(self): return self._busy = True try: - if not password_is_configured(): + if not _is_password_configured(): try: self.preview_settings_switch.blockSignals(True) self.preview_settings_switch.setChecked(False) @@ -992,7 +1073,7 @@ def apply(): desired, ) - require_and_run("toggle_preview_settings_switch", self, apply) + require_and_run_lazy("toggle_preview_settings_switch", self, apply) finally: self._busy = False @@ -1007,7 +1088,7 @@ def _update_enabled_state(self, global_safety_enabled): self.preview_settings_switch.setEnabled(False) else: # 全局安全总开关开启时,根据密码配置状态决定其他组件是否启用 - password_configured = password_is_configured() + password_configured = _is_password_configured() self.show_hide_floating_window_switch.setEnabled(password_configured) self.restart_switch.setEnabled(password_configured) self.exit_switch.setEnabled(password_configured) diff --git a/app/view/settings/settings.py b/app/view/settings/settings.py index 0a2112d6..9d9cd207 100644 --- a/app/view/settings/settings.py +++ b/app/view/settings/settings.py @@ -5,7 +5,7 @@ from loguru import logger from PySide6.QtWidgets import QApplication, QLabel, QWidget, QScroller, QSizePolicy from PySide6.QtGui import QIcon -from PySide6.QtCore import QTimer, QEvent, Signal, QSize, Qt +from PySide6.QtCore import QTimer, QEvent, Signal, QSize, Qt, QThread, QObject from PySide6.QtWidgets import QVBoxLayout from qfluentwidgets import ( FluentWindow, @@ -32,6 +32,12 @@ from app.page_building.window_template import BackgroundLayer from app.Language.obtain_language import get_content_name_async from app.common.IPC_URL.url_command_handler import URLCommandHandler +from app.common.page_registry import ( + get_settings_page_by_interface, + iter_navigable_settings_pages, + iter_settings_page_container_names, + iter_settings_pages, +) from app.common.search.settings_search_controller import SettingsSearchController @@ -68,20 +74,8 @@ def __init__(self, parent=None, is_preview=False): def _initialize_variables(self): """初始化实例变量""" - interface_names = [ - "basicSettingsInterface", - "listManagementInterface", - "extractionSettingsInterface", - "floatingWindowManagementInterface", - "notificationSettingsInterface", - "safetySettingsInterface", - "customSettingsInterface", - "voiceSettingsInterface", - "themeManagementInterface", - "historyInterface", - "moreSettingsInterface", - "updateInterface", - "aboutInterface", + interface_names = list(iter_settings_page_container_names()) + [ + "customSettingsInterface" ] for name in interface_names: @@ -92,6 +86,11 @@ def _initialize_variables(self): self._created_pages = {} self._page_access_order = [] self._pending_page_loads = set() + self._geometry_sync_scheduled = False + self._geometry_sync_callbacks = [] + if __debug__: + self._geometry_sync_request_count = 0 + self._geometry_sync_run_count = 0 def _setup_timers(self): """设置定时器""" @@ -134,11 +133,7 @@ def _setup_titlebar_search(self): window=self, title_bar=title_bar, line_edit=self._settings_search_line_edit, - handle_page_request=self._handle_settings_page_request, - get_page_mapping=self._get_page_mapping, - get_created_page=lambda interface_attr: getattr( - self, "_created_pages", {} - ).get(interface_attr, None), + ensure_page_ready=self._ensure_settings_page_ready, parent=self, ) try: @@ -148,7 +143,7 @@ def _setup_titlebar_search(self): except Exception: pass - QTimer.singleShot(0, self._position_titlebar_search) + self._request_geometry_sync() def _position_titlebar_search(self): title_bar = getattr(self, "titleBar", None) @@ -229,7 +224,7 @@ def _setup_sidebar_scroll(self): navigation.installEventFilter(self) scroll_area.installEventFilter(self) - QTimer.singleShot(0, self._sync_sidebar_scroll_geometry) + self._request_geometry_sync() def _sync_sidebar_scroll_geometry(self): scroll_area = getattr(self, "_sidebar_scroll_area", None) @@ -247,6 +242,45 @@ def _sync_sidebar_scroll_geometry(self): if viewport_h > 0 and navigation.minimumHeight() != viewport_h: navigation.setMinimumHeight(viewport_h) + def _request_geometry_sync(self, callback=None): + if callback is not None: + self._geometry_sync_callbacks.append(callback) + + if __debug__: + self._geometry_sync_request_count += 1 + + if self._geometry_sync_scheduled: + return + + self._geometry_sync_scheduled = True + # Wait one event-loop turn so title bar and sidebar layout can settle. + QTimer.singleShot(0, self._run_deferred_geometry_sync) + + def _run_deferred_geometry_sync(self): + self._geometry_sync_scheduled = False + + if __debug__: + self._geometry_sync_run_count += 1 + assert self._geometry_sync_run_count <= self._geometry_sync_request_count + + try: + self._sync_sidebar_scroll_geometry() + except Exception: + pass + + try: + self._position_titlebar_search() + except Exception: + pass + + callbacks = self._geometry_sync_callbacks + self._geometry_sync_callbacks = [] + for callback in callbacks: + try: + callback() + except Exception as e: + logger.exception(f"执行几何同步回调失败: {e}") + def _setup_settings_listener(self): try: from app.tools.settings_access import get_settings_signals @@ -441,14 +475,7 @@ def resizeEvent(self, event): except Exception: pass super().resizeEvent(event) - try: - self._sync_sidebar_scroll_geometry() - except Exception: - pass - try: - self._position_titlebar_search() - except Exception: - pass + self._request_geometry_sync() def changeEvent(self, event): """窗口状态变化事件处理 @@ -484,7 +511,7 @@ def changeEvent(self, event): super().changeEvent(event) if event.type() == QEvent.Type.WindowStateChange: - QTimer.singleShot(0, self._sync_sidebar_scroll_geometry) + self._request_geometry_sync() def eventFilter(self, obj, event): navigation = getattr(self, "_sidebar_navigation_widget", None) @@ -494,18 +521,12 @@ def eventFilter(self, obj, event): QEvent.Type.LayoutRequest, QEvent.Type.Show, ): - try: - self._sync_sidebar_scroll_geometry() - except Exception: - pass + self._request_geometry_sync() elif obj is scroll_area and event.type() in ( QEvent.Type.Resize, QEvent.Type.Show, ): - try: - self._sync_sidebar_scroll_geometry() - except Exception: - pass + self._request_geometry_sync() return super().eventFilter(obj, event) # ================================================== @@ -536,27 +557,12 @@ def _handle_settings_page_request(self, page_name: str): trace = start_interaction(f"settings.{page_name}") logger.debug(f"处理设置页面请求: {page_name}") - self._ensure_sub_interface_created() - - page_mapping = self._get_page_mapping() - - if page_name in page_mapping: - interface_attr, item_attr = page_mapping[page_name] - interface = getattr(self, interface_attr, None) - nav_item = getattr(self, item_attr, None) - - if interface and nav_item: - self._ensure_deferred_page_loaded(interface_attr) - logger.debug(f"切换到设置页面: {page_name}") - self.switchTo(interface) - trace.log("shell_visible") - self.show() - self.activateWindow() - self.raise_() - else: - logger.warning(f"设置页面不存在或尚未初始化: {page_name}") + page = self._ensure_settings_page_ready(page_name) + if page is not None: + trace.log("shell_visible") else: logger.warning(f"未知的设置页面: {page_name}") + return page def _ensure_sub_interface_created(self): """确保子界面已创建""" @@ -577,64 +583,52 @@ def _get_page_mapping(self): Returns: dict: 页面名称到界面属性的映射 """ - return { - "settings_basic": ("basicSettingsInterface", "basic_settings_item"), - "settings_list": ("listManagementInterface", "list_management_item"), - "settings_extraction": ( - "extractionSettingsInterface", - "extraction_settings_item", - ), - "settings_floating": ( - "floatingWindowManagementInterface", - "floating_window_management_item", - ), - "settings_notification": ( - "notificationSettingsInterface", - "notification_settings_item", - ), - "settings_safety": ("safetySettingsInterface", "safety_settings_item"), - "settings_linkage": ("courseSettingsInterface", "course_settings_item"), - "settings_voice": ("voiceSettingsInterface", "voice_settings_item"), - "settings_theme": ("themeManagementInterface", "theme_management_item"), - "settings_history": ("historyInterface", "history_item"), - "settings_more": ("moreSettingsInterface", "more_settings_item"), - "settings_update": ("updateInterface", "update_item"), - "settings_about": ("aboutInterface", "about_item"), - "basicSettingsInterface": ("basicSettingsInterface", "basic_settings_item"), - "listManagementInterface": ( - "listManagementInterface", - "list_management_item", - ), - "extractionSettingsInterface": ( - "extractionSettingsInterface", - "extraction_settings_item", - ), - "floatingWindowManagementInterface": ( - "floatingWindowManagementInterface", - "floating_window_management_item", - ), - "notificationSettingsInterface": ( - "notificationSettingsInterface", - "notification_settings_item", - ), - "safetySettingsInterface": ( - "safetySettingsInterface", - "safety_settings_item", - ), - "courseSettingsInterface": ( - "courseSettingsInterface", - "course_settings_item", - ), - "voiceSettingsInterface": ("voiceSettingsInterface", "voice_settings_item"), - "themeManagementInterface": ( - "themeManagementInterface", - "theme_management_item", - ), - "historyInterface": ("historyInterface", "history_item"), - "moreSettingsInterface": ("moreSettingsInterface", "more_settings_item"), - "updateInterface": ("updateInterface", "update_item"), - "aboutInterface": ("aboutInterface", "about_item"), - } + mapping = {} + for page in iter_settings_pages(): + value = (page.interface_attr, page.item_attr) + mapping[page.route_name] = value + mapping[page.interface_attr] = value + return mapping + + def _resolve_settings_page_target(self, page_name: str): + page_mapping = self._get_page_mapping() + if page_name not in page_mapping: + return None + + interface_attr, item_attr = page_mapping[page_name] + interface = getattr(self, interface_attr, None) + nav_item = getattr(self, item_attr, None) + if interface is None or nav_item is None: + return None + + return interface_attr, interface + + def _ensure_settings_page_ready(self, page_name: str, on_ready=None): + self._ensure_sub_interface_created() + + target = self._resolve_settings_page_target(page_name) + if target is None: + return None + + interface_attr, interface = target + self.switchTo(interface) + logger.debug(f"切换到设置页面: {page_name}") + self.show() + self.activateWindow() + self.raise_() + + self._ensure_deferred_page_loaded(interface_attr) + page = getattr(self, "_created_pages", {}).get(interface_attr) + if page is None: + logger.warning(f"设置页面不存在或尚未初始化: {page_name}") + return None + + if on_ready is not None: + self._request_geometry_sync( + lambda current_page=page: on_ready(current_page) + ) + + return page # ================================================== # 界面创建与导航 @@ -650,16 +644,16 @@ def createSubInterface(self): from app.page_building import settings_window_page settings = self._get_sidebar_settings() - page_configs = self._get_page_configs() - for setting_key, interface_attr, page_method, is_pivot in page_configs: - setting_value = settings.get(setting_key) + for page in iter_settings_pages(): + setting_value = settings.get(page.sidebar_setting_key) if setting_value is None or setting_value != 2: self._create_page_placeholder( - interface_attr, page_method, is_pivot, settings_window_page + page.interface_attr, + page.page_method, + page.is_pivot, + settings_window_page, ) - - self._create_special_pages(settings_window_page) self.initNavigation() self._setup_background_warmup() self._sub_interface_created = True @@ -670,21 +664,7 @@ def _get_sidebar_settings(self): Returns: dict: 侧边栏设置字典 """ - return { - "base_settings": 0, - "name_management": 0, - "draw_settings": 0, - "floating_window_management": 0, - "notification_service": 0, - "security_settings": 0, - "linkage_settings": 0, - "voice_settings": 0, - "theme_management": 0, - "settings_history": 0, - "more_settings": 0, - "updateInterface": 0, - "aboutInterface": 0, - } + return {page.sidebar_setting_key: 0 for page in iter_navigable_settings_pages()} def _get_page_configs(self): """获取页面配置列表 @@ -693,54 +673,13 @@ def _get_page_configs(self): list: 页面配置列表 """ return [ - ("base_settings", "basicSettingsInterface", "basic_settings_page", False), - ( - "name_management", - "listManagementInterface", - "list_management_page", - True, - ), ( - "draw_settings", - "extractionSettingsInterface", - "extraction_settings_page", - True, - ), - ( - "floating_window_management", - "floatingWindowManagementInterface", - "floating_window_management_page", - True, - ), - ( - "notification_service", - "notificationSettingsInterface", - "notification_settings_page", - True, - ), - ( - "security_settings", - "safetySettingsInterface", - "safety_settings_page", - True, - ), - ( - "linkage_settings", - "courseSettingsInterface", - "linkage_settings_page", - False, - ), - ("voice_settings", "voiceSettingsInterface", "voice_settings_page", True), - ( - "theme_management", - "themeManagementInterface", - "theme_management_page", - False, - ), - ("settings_history", "historyInterface", "history_page", True), - ("more_settings", "moreSettingsInterface", "more_settings_page", True), - ("updateInterface", "updateInterface", "update_page", False), - ("aboutInterface", "aboutInterface", "about_page", False), + page.sidebar_setting_key, + page.interface_attr, + page.page_method, + page.is_pivot, + ) + for page in iter_settings_pages() ] def _create_page_placeholder( @@ -793,22 +732,6 @@ def _set_placeholder_loading( label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(label) - def _create_special_pages(self, settings_window_page): - """创建特殊页面(更新和关于页面) - - Args: - settings_window_page: 设置窗口页面模块 - """ - self.updateInterface = self._make_placeholder("updateInterface") - self._register_deferred_factory( - "updateInterface", "update_page", False, settings_window_page - ) - - self.aboutInterface = self._make_placeholder("aboutInterface") - self._register_deferred_factory( - "aboutInterface", "about_page", False, settings_window_page - ) - def _make_page_factory(self, page_method, interface, settings_window_page): """创建页面工厂函数 @@ -845,15 +768,10 @@ def _register_deferred_factory( } def _get_page_factory_definition(self, page_name: str): - for _, interface_attr, page_method, is_pivot in self._get_page_configs(): - if interface_attr == page_name: - return page_method, is_pivot - - special_pages = { - "updateInterface": ("update_page", False), - "aboutInterface": ("about_page", False), - } - return special_pages.get(page_name) + page = get_settings_page_by_interface(page_name) + if page is None: + return None + return page.page_method, page.is_pivot def _setup_background_warmup(self): """设置后台预热""" @@ -866,20 +784,16 @@ def initNavigation(self): """初始化导航系统 根据用户设置构建个性化菜单导航""" settings = self._get_sidebar_settings() - nav_configs = self._get_nav_configs() - - for ( - setting_key, - interface_attr, - item_attr, - icon_name, - module, - name_key, - ) in nav_configs: - setting_value = settings.get(setting_key) + for page in iter_navigable_settings_pages(): + setting_value = settings.get(page.sidebar_setting_key) if setting_value is None or setting_value != 2: self._add_navigation_item( - setting_key, interface_attr, item_attr, icon_name, module, name_key + page.sidebar_setting_key, + page.interface_attr, + page.item_attr, + page.icon_name, + page.language_module, + page.title_key, ) self.splashScreen.finish() @@ -893,109 +807,14 @@ def _get_nav_configs(self): """ return [ ( - "base_settings", - "basicSettingsInterface", - "basic_settings_item", - "ic_fluent_wrench_settings_20_filled", - "basic_settings", - "title", - ), - ( - "name_management", - "listManagementInterface", - "list_management_item", - "ic_fluent_list_20_filled", - "list_management", - "title", - ), - ( - "draw_settings", - "extractionSettingsInterface", - "extraction_settings_item", - "ic_fluent_archive_20_filled", - "extraction_settings", - "title", - ), - ( - "floating_window_management", - "floatingWindowManagementInterface", - "floating_window_management_item", - "ic_fluent_window_apps_20_filled", - "floating_window_management", - "title", - ), - ( - "notification_service", - "notificationSettingsInterface", - "notification_settings_item", - "ic_fluent_comment_note_20_filled", - "notification_settings", - "title", - ), - ( - "security_settings", - "safetySettingsInterface", - "safety_settings_item", - "ic_fluent_shield_20_filled", - "safety_settings", - "title", - ), - ( - "linkage_settings", - "courseSettingsInterface", - "course_settings_item", - "ic_fluent_calendar_ltr_20_filled", - "linkage_settings", - "title", - ), - ( - "voice_settings", - "voiceSettingsInterface", - "voice_settings_item", - "ic_fluent_person_voice_20_filled", - "voice_settings", - "title", - ), - ( - "theme_management", - "themeManagementInterface", - "theme_management_item", - "ic_fluent_paint_brush_20_filled", - "theme_management", - "title", - ), - ( - "settings_history", - "historyInterface", - "history_item", - "ic_fluent_history_20_filled", - "history", - "title", - ), - ( - "more_settings", - "moreSettingsInterface", - "more_settings_item", - "ic_fluent_more_horizontal_20_filled", - "more_settings", - "title", - ), - ( - "updateInterface", - "updateInterface", - "update_item", - "ic_fluent_arrow_sync_20_filled", - "update", - "title", - ), - ( - "aboutInterface", - "aboutInterface", - "about_item", - "ic_fluent_info_20_filled", - "about", - "title", - ), + page.sidebar_setting_key, + page.interface_attr, + page.item_attr, + page.icon_name, + page.language_module, + page.title_key, + ) + for page in iter_navigable_settings_pages() ] def _add_navigation_item( @@ -1032,10 +851,8 @@ def _add_navigation_item( def _load_default_page(self): """加载默认页面(基础设置页面)""" try: - self._ensure_deferred_page_loaded("basicSettingsInterface") - - if hasattr(self, "basicSettingsInterface") and self.basicSettingsInterface: - self.switchTo(self.basicSettingsInterface) + page = self._ensure_settings_page_ready("basicSettingsInterface") + if page is not None: logger.debug("已自动导航到基础设置页面") except Exception as e: logger.exception(f"加载默认页面失败: {e}") @@ -1065,33 +882,13 @@ def _on_stacked_widget_changed(self, index: int): ): if name in self._pending_page_loads: return - self._pending_page_loads.add(name) self._set_placeholder_loading(widget, "正在加载页面...") - factory = self._deferred_factories.pop(name) - - def create_page(): - try: - logger.debug( - f"正在创建页面 {name},预览模式: {self.is_preview}" - ) - real_page = factory(is_preview=self.is_preview) - self._clear_placeholder_layout(widget) - widget.layout().addWidget(real_page) - - if not hasattr(self, "_created_pages"): - self._created_pages = {} - self._created_pages[name] = real_page - - logger.debug( - f"设置页面已按需创建: {name}, 预览模式: {self.is_preview}" - ) - except Exception as e: - self._set_placeholder_loading(widget, f"页面加载失败: {name}") - logger.exception(f"延迟创建设置页面 {name} 失败: {e}") - finally: - self._pending_page_loads.discard(name) - - QTimer.singleShot(0, create_page) + QTimer.singleShot( + 0, + lambda current_name=name: self._materialize_deferred_page( + current_name + ), + ) except Exception as e: logger.exception(f"处理堆叠窗口改变失败: {e}") @@ -1153,6 +950,7 @@ def _unload_settings_page(self, page_name: str): try: real_page = self._created_pages.pop(page_name) + self._cleanup_page_threads(real_page) container = getattr(self, page_name, None) if container and container.layout(): container.layout().removeWidget(real_page) @@ -1167,6 +965,96 @@ def _unload_settings_page(self, page_name: str): except Exception as e: logger.exception(f"卸载设置页面 {page_name} 失败: {e}") + def _cleanup_page_threads(self, widget: QWidget) -> None: + visited: set[int] = set() + self._cleanup_threads_in_object(widget, visited) + + def _cleanup_threads_in_object(self, obj, visited: set[int]) -> None: + if obj is None: + return + + obj_id = id(obj) + if obj_id in visited: + return + visited.add(obj_id) + + if isinstance(obj, QThread): + self._stop_qthread(obj) + return + + try: + obj_dict = vars(obj) + except Exception: + obj_dict = {} + + for value in obj_dict.values(): + self._cleanup_threads_in_value(value, visited) + + if isinstance(obj, QObject): + try: + for child in obj.children(): + self._cleanup_threads_in_object(child, visited) + except Exception: + pass + + def _cleanup_threads_in_value(self, value, visited: set[int]) -> None: + if value is None: + return + + if isinstance(value, (str, bytes, int, float, bool)): + return + + if isinstance(value, QThread): + self._stop_qthread(value) + return + + if isinstance(value, dict): + for item in value.values(): + self._cleanup_threads_in_value(item, visited) + return + + if isinstance(value, (list, tuple, set)): + for item in value: + self._cleanup_threads_in_value(item, visited) + return + + self._cleanup_threads_in_object(value, visited) + + def _stop_qthread(self, thread: QThread) -> None: + try: + if not thread.isRunning(): + return + except RuntimeError: + return + + try: + thread.requestInterruption() + except Exception: + pass + + try: + thread.quit() + except Exception: + pass + + try: + finished = thread.wait(500) + except Exception: + finished = False + + if finished: + return + + try: + thread.terminate() + except Exception: + return + + try: + thread.wait(500) + except Exception: + pass + def _restore_page_factory(self, page_name: str, container): """恢复页面工厂函数 @@ -1188,6 +1076,46 @@ def _restore_page_factory(self, page_name: str, container): page_name, page_method, is_pivot, settings_window_page ) + def _materialize_deferred_page(self, name: str) -> bool: + if name in getattr(self, "_created_pages", {}): + return True + if name in self._pending_page_loads: + return False + + factory = getattr(self, "_deferred_factories", {}).get(name) + if factory is None: + return False + + container = self._find_container_by_name(name) + if container is None or not hasattr(container, "layout"): + return False + + layout = container.layout() + if layout is None: + self._set_placeholder_loading(container, f"页面加载失败: {name}") + return False + + self._pending_page_loads.add(name) + try: + logger.debug(f"正在创建页面 {name},预览模式: {self.is_preview}") + real_page = factory(is_preview=self.is_preview) + if real_page is None: + raise RuntimeError(f"延迟页面工厂返回空页面: {name}") + + self._clear_placeholder_layout(container) + layout.addWidget(real_page) + + self._created_pages[name] = real_page + self._deferred_factories.pop(name, None) + logger.debug(f"设置页面已按需创建: {name}, 预览模式: {self.is_preview}") + return True + except Exception as e: + self._set_placeholder_loading(container, f"页面加载失败: {name}") + logger.exception(f"延迟创建设置页面 {name} 失败: {e}") + return False + finally: + self._pending_page_loads.discard(name) + def _create_deferred_page(self, name: str): """根据名字创建对应延迟工厂并把结果加入占位容器 @@ -1195,49 +1123,15 @@ def _create_deferred_page(self, name: str): name: 页面名称 """ try: - if name not in getattr(self, "_deferred_factories", {}): - return - factory = self._deferred_factories.pop(name) - container = self._find_container_by_name(name) - if container is None: - return - - if not container or not hasattr(container, "layout"): - return - layout = container.layout() - if layout is None: - return - - try: - real_page = factory(is_preview=self.is_preview) - except RuntimeError as e: - logger.exception(f"创建延迟页面 {name} 失败(父容器可能已销毁): {e}") - return - except Exception as e: - logger.exception(f"创建延迟页面 {name} 失败: {e}") - return - - try: - self._clear_placeholder_layout(container) - layout.addWidget(real_page) - if not hasattr(self, "_created_pages"): - self._created_pages = {} - self._created_pages[name] = real_page - logger.debug(f"后台预热创建设置页面: {name}") - except RuntimeError as e: - logger.exception( - f"将延迟页面 {name} 插入容器失败(容器可能已销毁): {e}" - ) - return + if container is not None: + self._set_placeholder_loading(container, "正在加载页面...") + self._materialize_deferred_page(name) except Exception as e: logger.exception(f"_create_deferred_page 失败: {e}") def _ensure_deferred_page_loaded(self, name: str) -> None: - if name in getattr(self, "_created_pages", {}): - return - if name in getattr(self, "_deferred_factories", {}): - self._create_deferred_page(name) + self._materialize_deferred_page(name) def _find_container_by_name(self, name: str): """根据名称查找容器 @@ -1248,20 +1142,8 @@ def _find_container_by_name(self, name: str): Returns: QWidget: 容器对象或None """ - container_attrs = [ - "basicSettingsInterface", - "listManagementInterface", - "extractionSettingsInterface", - "floatingWindowManagementInterface", - "notificationSettingsInterface", - "safetySettingsInterface", - "customSettingsInterface", - "voiceSettingsInterface", - "historyInterface", - "moreSettingsInterface", - "courseSettingsInterface", - "updateInterface", - "aboutInterface", + container_attrs = list(iter_settings_page_container_names()) + [ + "customSettingsInterface" ] for attr in container_attrs: diff --git a/app/view/tray/tray.py b/app/view/tray/tray.py index fcba0b1b..9c2884a9 100644 --- a/app/view/tray/tray.py +++ b/app/view/tray/tray.py @@ -14,10 +14,10 @@ TRAY_ICON_FILENAME, TRAY_MENU_POSITION_ADJUSTMENT, ) -from app.common.safety.verify_ops import require_and_run from app.tools.path_utils import get_data_path from app.Language.obtain_language import readme_settings_async, get_content_name_async from app.common.IPC_URL.url_command_handler import URLCommandHandler +from app.common.safety.verify_proxy import require_and_run_lazy # ================================================== @@ -238,7 +238,7 @@ def _create_toggle_float_window_action(self): """ return Action( get_content_name_async("tray_management", "show_hide_float_window"), - triggered=lambda: require_and_run( + triggered=lambda: require_and_run_lazy( "show_hide_floating_window", self.main_window, self.main_window._toggle_float_window, @@ -253,7 +253,7 @@ def _create_restart_action(self): """ return Action( get_content_name_async("tray_management", "restart"), - triggered=lambda: require_and_run( + triggered=lambda: require_and_run_lazy( "restart", self.main_window, self.main_window.restart_app ), ) @@ -266,7 +266,7 @@ def _create_exit_action(self): """ return Action( get_content_name_async("tray_management", "exit"), - triggered=lambda: require_and_run( + triggered=lambda: require_and_run_lazy( "exit", self.main_window, self.main_window.close_window_secrandom ), ) @@ -396,10 +396,10 @@ def _handle_toggle_float_window(self): def _handle_restart_app(self): """处理重启应用程序""" - require_and_run("restart", self.main_window, self.main_window.restart_app) + require_and_run_lazy("restart", self.main_window, self.main_window.restart_app) def _handle_exit_app(self): """处理退出应用程序""" - require_and_run( + require_and_run_lazy( "exit", self.main_window, self.main_window.close_window_secrandom ) diff --git a/build_nuitka.py b/build_nuitka.py index 99fecb1f..12bf2e07 100644 --- a/build_nuitka.py +++ b/build_nuitka.py @@ -3,6 +3,8 @@ 用于构建 SecRandom 的独立可执行文件 """ +import argparse +import os import subprocess import sys import re @@ -40,6 +42,23 @@ } +def parse_args() -> argparse.Namespace: + """解析命令行参数""" + parser = argparse.ArgumentParser(description="Nuitka 打包脚本") + parser.add_argument( + "--quick", + action="store_true", + help="快速构建模式:生成 standalone 目录(不启用 onefile)", + ) + parser.add_argument( + "--jobs", + type=int, + default=max(1, (os.cpu_count() or 1) - 1), + help="并行编译任务数,默认使用 CPU 核心数 - 1", + ) + return parser.parse_args() + + def _print_packaging_summary() -> None: """Log a quick overview of the data and modules that will be bundled.""" @@ -111,7 +130,7 @@ def _sanitize_version(ver_str: str) -> str: return "0.0.0.0" -def get_nuitka_command() -> list[str]: +def get_nuitka_command(*, quick: bool, jobs: int) -> list[str]: """获取Nuitka命令列表""" raw_version = VERSION if VERSION else "0.0.0" clean_version = _sanitize_version(raw_version) @@ -125,7 +144,7 @@ def get_nuitka_command() -> list[str]: "-m", "nuitka", "--standalone", - "--onefile", + f"--jobs={max(1, jobs)}", "--enable-plugin=pyside6", "--assume-yes-for-downloads", "--output-dir=dist", @@ -136,6 +155,8 @@ def get_nuitka_command() -> list[str]: "--copyright=Copyright (c) 2025", "--no-deployment-flag=self-execution", ] + if not quick: + cmd.append("--onefile") # 编译器选择逻辑 if sys.platform == "win32": @@ -231,15 +252,20 @@ def build_deb() -> None: def main(): """执行 Nuitka 打包""" + args = parse_args() + print("=" * 60) print("开始使用 Nuitka + uv 打包 SecRandom") print("=" * 60) + print( + f"构建模式: {'quick-standalone' if args.quick else 'onefile'} | jobs={max(1, args.jobs)}" + ) if sys.platform == "win32" and not check_compiler_env(): sys.exit(1) _print_packaging_summary() - cmd = get_nuitka_command() + cmd = get_nuitka_command(quick=args.quick, jobs=args.jobs) # 打印命令 print("\n执行命令:") diff --git a/main.py b/main.py index 422db7e2..7ef49443 100644 --- a/main.py +++ b/main.py @@ -5,9 +5,6 @@ import subprocess import platform -import sentry_sdk -from sentry_sdk.integrations.loguru import LoguruIntegration, LoggingLevels -from posthog import Posthog from PySide6.QtCore import Qt, QThreadPool, QRunnable, QTimer, qInstallMessageHandler from PySide6.QtWidgets import QApplication from loguru import logger @@ -21,10 +18,6 @@ ) from app.tools.settings_default import manage_settings_file from app.tools.settings_access import readme_settings_async, get_or_create_user_id -from app.core.usage_counters import ( - get_stored_draw_counts, - recompute_and_persist_draw_counts, -) from app.tools.variable import ( APP_QUIT_ON_LAST_WINDOW_CLOSED, VERSION, @@ -51,7 +44,6 @@ from app.core.url_handler_setup import create_url_handler from app.core.cs_ipc_handler_setup import create_cs_ipc_handler from app.core.app_init import AppInitializer -from app.tools.update_utils import update_check_thread import app.core.window_manager as wm @@ -62,6 +54,9 @@ def initialize_sentry(): """初始化 Sentry 错误监控系统""" + import sentry_sdk + from sentry_sdk.integrations.loguru import LoguruIntegration, LoggingLevels + sentry_sdk.init( dsn=SENTRY_DSN, integrations=[ @@ -87,6 +82,8 @@ def initialize_posthog( lottery_total: int | None = None, ): """初始化 PostHog 产品分析系统""" + from posthog import Posthog + posthog = Posthog( project_api_key=POSTHOG_API_KEY, host=POSTHOG_HOST, @@ -95,6 +92,8 @@ def initialize_posthog( user_id = get_or_create_user_id() geoip_properties = get_geoip_properties_zh_cn() if total_draw_count is None: + from app.core.usage_counters import get_stored_draw_counts + stored_counts = get_stored_draw_counts() if stored_counts is not None: total_draw_count, roll_call_total, lottery_total = stored_counts @@ -124,6 +123,8 @@ def schedule_deferred_startup_tasks(window_manager: WindowManager): def task(): start = time.perf_counter() try: + from app.core.usage_counters import recompute_and_persist_draw_counts + totals = recompute_and_persist_draw_counts() except Exception as e: logger.exception(f"补算抽取统计失败,将使用已存储计数发送事件: {e}") @@ -332,9 +333,7 @@ def initialize_app_components(window_manager): # ================================================== -def cleanup_resources( - shared_memory, local_server, url_handler, cs_ipc_handler, update_check_thread -): +def cleanup_resources(shared_memory, local_server, url_handler, cs_ipc_handler): """清理应用程序资源 Args: @@ -342,7 +341,6 @@ def cleanup_resources( local_server: 本地服务器对象 url_handler: URL 处理器对象 cs_ipc_handler: CS IPC 处理器对象 - update_check_thread: 更新检查线程对象 """ if cs_ipc_handler: cs_ipc_handler.stop_ipc_client() @@ -357,6 +355,7 @@ def cleanup_resources( local_server.close() logger.debug("本地服务器已关闭") + update_check_thread = _get_update_check_thread() if update_check_thread and update_check_thread.isRunning(): logger.debug("正在等待更新检查线程完成...") update_check_thread.wait(UPDATE_CHECK_THREAD_TIMEOUT_MS) @@ -369,6 +368,15 @@ def cleanup_resources( logger.debug("垃圾回收已完成") +def _get_update_check_thread(): + try: + from app.tools import update_utils + + return getattr(update_utils, "update_check_thread", None) + except Exception: + return None + + def restart_application(program_dir): """重启应用程序 @@ -469,7 +477,6 @@ def handle_exit( local_server, url_handler, cs_ipc_handler, - update_check_thread, ): """处理应用程序退出 @@ -480,13 +487,10 @@ def handle_exit( local_server: 本地服务器对象 url_handler: URL 处理器对象 cs_ipc_handler: CS IPC 处理器对象 - update_check_thread: 更新检查线程对象 """ logger.debug("Qt 事件循环已结束") - cleanup_resources( - shared_memory, local_server, url_handler, cs_ipc_handler, update_check_thread - ) + cleanup_resources(shared_memory, local_server, url_handler, cs_ipc_handler) logger.info("程序退出流程已完成,正在结束进程") if sys.stdout: @@ -560,7 +564,6 @@ def main(): local_server, url_handler, cs_ipc_handler, - update_check_thread, ) except Exception as e: logger.exception(f"程序退出过程中发生异常: {e}") diff --git a/packaging_utils.py b/packaging_utils.py index 45cd04a0..11e45cf6 100644 --- a/packaging_utils.py +++ b/packaging_utils.py @@ -37,9 +37,9 @@ class DataInclude: "app.tools.settings_default_storage", "app.tools.personalised", "app.common.data.list", - "app.common.history.history", + "app.common.history.history_reader", "app.common.display.result_display", - "app.common.extract.extract", + "app.common.extraction.extract", "app.tools.config", "app.page_building.main_window_page", "app.page_building.settings_window_page", @@ -60,8 +60,6 @@ class DataInclude: "numpy.ma", "numpy.polynomial", "numpy.testing", - "numpy.distutils", - "numpy.compat", "pandas", "PySide6", "app.view.another_window.contributor", @@ -95,10 +93,6 @@ def collect_data_includes() -> List[DataInclude]: includes: List[DataInclude] = [] if DATA_DIR.exists(): includes.append(DataInclude(DATA_DIR, "data", is_dir=True)) - if LANGUAGE_MODULES_DIR.exists(): - includes.append( - DataInclude(LANGUAGE_MODULES_DIR, "app/Language/modules", is_dir=True) - ) if LICENSE_FILE.exists(): includes.append(DataInclude(LICENSE_FILE, ".", is_dir=False)) return includes