From 5be96d2b73113085326960bc88248009c56b88f8 Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Sun, 16 Nov 2025 17:57:17 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E5=88=9D=E6=AD=A5=E6=8F=90?= =?UTF-8?q?=E5=8D=87=E2=80=9C=E5=89=A9=E4=BD=99=E5=90=8D=E5=8D=95=E2=80=9D?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E7=9A=84=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Language/modules/remaining_list.py | 5 +- app/page_building/another_window.py | 12 +- app/page_building/window_template.py | 29 +- app/tools/config.py | 6 +- app/tools/list.py | 6 +- app/tools/result_display.py | 32 +- app/view/another_window/remaining_list.py | 558 ++++++++++++++++++---- app/view/main/roll_call.py | 118 +++-- app/view/settings/settings.py | 4 +- main.py | 18 +- pyproject.toml | 7 + pyrightconfig.json | 8 + 12 files changed, 621 insertions(+), 182 deletions(-) create mode 100644 pyrightconfig.json diff --git a/app/Language/modules/remaining_list.py b/app/Language/modules/remaining_list.py index ed37198e..209fbd11 100644 --- a/app/Language/modules/remaining_list.py +++ b/app/Language/modules/remaining_list.py @@ -7,7 +7,10 @@ "description": "带班级名称的标题", }, "count_label": {"name": "剩余人数:{count}", "description": "剩余人数标签文本"}, - "group_count_label": {"name": "剩余组数:{count}", "description": "剩余组数标签文本"}, + "group_count_label": { + "name": "剩余组数:{count}", + "description": "剩余组数标签文本", + }, "no_students": { "name": "暂无未抽取的学生", "description": "没有剩余学生时的提示文本", diff --git a/app/page_building/another_window.py b/app/page_building/another_window.py index da6d2c1d..40621d08 100644 --- a/app/page_building/another_window.py +++ b/app/page_building/another_window.py @@ -227,10 +227,10 @@ def create_remaining_list_window( # 激活窗口并置于前台 window.raise_() window.activateWindow() - + # 获取页面实例并更新数据 page = None - + def setup_page(): nonlocal page page_template = window.get_page("remaining_list") @@ -245,10 +245,10 @@ def setup_page(): group_index, gender_index, ) - + # 使用延迟调用确保内容控件已创建 QTimer.singleShot(100, setup_page) - + # 创建一个回调函数,用于在页面设置完成后获取页面实例 def get_page_callback(callback): def check_page(): @@ -258,13 +258,13 @@ def check_page(): QTimer.singleShot(50, check_page) check_page() - + return window, get_page_callback except Exception as e: # 如果窗口已损坏,从字典中移除并创建新窗口 logger.error(f"激活剩余名单窗口失败: {e}") _window_instances.pop("remaining_list", None) - + # 创建新窗口 title = "剩余名单" window = SimpleWindowTemplate(title, width=800, height=600) diff --git a/app/page_building/window_template.py b/app/page_building/window_template.py index 8f22c0df..2b799676 100644 --- a/app/page_building/window_template.py +++ b/app/page_building/window_template.py @@ -119,10 +119,10 @@ def initWindow(self, title: str, width: int = 700, height: int = 500) -> None: custom_font = load_custom_font() title_font = QFont(custom_font, 9) self.titleBar.setFont(title_font) - + # 确保在设置标题栏后应用当前主题和自定义字体 self._apply_current_theme() - + if self.parent_window is None: screen = QApplication.primaryScreen().availableGeometry() w, h = screen.width(), screen.height() @@ -148,7 +148,7 @@ def _apply_current_theme(self) -> None: try: # 获取当前主题设置 current_theme = readme_settings("basic_settings", "theme") - + # 根据主题设置窗口背景色 if current_theme == "DARK": # 深色主题 - 设置深色背景 @@ -158,12 +158,17 @@ def _apply_current_theme(self) -> None: # 自动主题 - 根据系统设置 try: from darkdetect import isDark + if isDark(): self.setStyleSheet("background-color: #202020;") - self.default_page.setStyleSheet("background-color: transparent;") + self.default_page.setStyleSheet( + "background-color: transparent;" + ) else: self.setStyleSheet("background-color: #ffffff;") - self.default_page.setStyleSheet("background-color: transparent;") + self.default_page.setStyleSheet( + "background-color: transparent;" + ) except: # 如果检测失败,使用浅色主题 self.setStyleSheet("background-color: #ffffff;") @@ -172,10 +177,10 @@ def _apply_current_theme(self) -> None: # 浅色主题 self.setStyleSheet("background-color: #ffffff;") self.default_page.setStyleSheet("background-color: transparent;") - + # 应用标题栏自定义字体和颜色 self._set_titlebar_colors() - + logger.debug(f"窗口主题已更新为: {current_theme}") except Exception as e: logger.error(f"应用主题时出错: {e}") @@ -188,7 +193,7 @@ def _set_titlebar_colors(self) -> None: try: # 判断是否为深色主题 is_dark = is_dark_theme(qconfig) - + if is_dark: # 深色主题 title_color = "#ffffff" # 白色文字 @@ -197,7 +202,7 @@ def _set_titlebar_colors(self) -> None: # 浅色主题 title_color = "#000000" # 黑色文字 background_color = "#ffffff" - + # 设置标题栏颜色样式 titlebar_style = f""" QWidget {{ @@ -228,8 +233,10 @@ def _set_titlebar_colors(self) -> None: }} """ self.titleBar.setStyleSheet(titlebar_style) - - logger.debug(f"标题栏颜色已设置: 文字色={title_color}, 背景色={background_color}") + + logger.debug( + f"标题栏颜色已设置: 文字色={title_color}, 背景色={background_color}" + ) except Exception as e: logger.error(f"设置标题栏颜色失败: {e}") diff --git a/app/tools/config.py b/app/tools/config.py index 6be4aea7..d20dd2f3 100644 --- a/app/tools/config.py +++ b/app/tools/config.py @@ -646,7 +646,7 @@ def calculate_remaining_count( if group_index == 1: # 全部小组 # 获取所有小组列表 group_list = get_group_list(class_name) - + # 计算已被排除的小组数量 excluded_count = 0 for group_name in group_list: @@ -656,7 +656,7 @@ def calculate_remaining_count( and drawn_counts[group_name] >= half_repeat ): excluded_count += 1 - + # 计算实际剩余组数 return max(0, len(group_list) - excluded_count) else: @@ -672,7 +672,7 @@ def calculate_remaining_count( if isinstance(student, dict) and "name" in student else student ) - + # 如果学生已被抽取次数达到或超过设置值,则计入排除数量 if ( student_name in drawn_counts diff --git a/app/tools/list.py b/app/tools/list.py index 27aeac76..38da56d1 100644 --- a/app/tools/list.py +++ b/app/tools/list.py @@ -158,11 +158,11 @@ def get_group_members(class_name: str, group_name: str) -> List[Dict[str, Any]]: """ student_list = get_student_list(class_name) group_members = [] - + for student in student_list: if student["group"] == group_name: group_members.append(student) - + # 按ID排序 group_members.sort(key=lambda x: x["id"]) return group_members @@ -366,7 +366,7 @@ def filter_students_data( gender_index == 0 or gender == gender_filter ): # 根据性别条件过滤 groups_set.add(group) - + # 对小组进行排序,按小组名称排序 # 返回格式为 (id, name, gender, group, exist),但小组模式下只需要小组名称 students_data = [] diff --git a/app/tools/result_display.py b/app/tools/result_display.py index 646ef0c9..296fb7d6 100644 --- a/app/tools/result_display.py +++ b/app/tools/result_display.py @@ -19,8 +19,10 @@ from app.tools.list import * from random import SystemRandom + system_random = SystemRandom() + # ================================================== # 结果显示工具类 # ================================================== @@ -63,7 +65,15 @@ def _create_avatar_widget(image_path, name, font_size): return avatar @staticmethod - def _format_student_text(class_name, display_format, student_id_str, name, draw_count, is_group_mode=False, show_random=0): + def _format_student_text( + class_name, + display_format, + student_id_str, + name, + draw_count, + is_group_mode=False, + show_random=0, + ): """ 格式化学生显示文本 @@ -82,7 +92,7 @@ def _format_student_text(class_name, display_format, student_id_str, name, draw_ if is_group_mode: # 获取小组成员列表 group_members = get_group_members(class_name, name) - + if show_random == 1: # 组名[换行]随机选择的成员 if group_members: # 随机选择一个成员 @@ -101,7 +111,7 @@ def _format_student_text(class_name, display_format, student_id_str, name, draw_ return name else: # 不显示特殊格式 - 只显示组名 return f"{name}" - + if display_format == 1: # 仅显示姓名 return f"{name}" elif display_format == 2: # 仅显示学号 @@ -248,17 +258,23 @@ def create_student_label( student_id_str = STUDENT_ID_FORMAT.format(num=num) else: student_id_str = "" - + # 处理不同模式下的名称显示 if len(str(selected)) == 2 and group_index == 0: name = f"{str(selected)[0]}{NAME_SPACING}{str(selected)[1]}" else: name = str(selected) - + text = ResultDisplayUtils._format_student_text( - class_name, display_format, student_id_str, name, draw_count, is_group_mode=(group_index == 1), show_random=show_random + class_name, + display_format, + student_id_str, + name, + draw_count, + is_group_mode=(group_index == 1), + show_random=show_random, ) - + # 在小组模式下,只在没有成员显示时才显示头像 if show_student_image: label = ResultDisplayUtils._create_student_label_with_avatar( @@ -266,7 +282,7 @@ def create_student_label( ) else: label = BodyLabel(text) - + ResultDisplayUtils._apply_label_style(label, font_size, animation_color) student_labels.append(label) diff --git a/app/view/another_window/remaining_list.py b/app/view/another_window/remaining_list.py index a558d9ef..45cae42a 100644 --- a/app/view/another_window/remaining_list.py +++ b/app/view/another_window/remaining_list.py @@ -6,27 +6,182 @@ import json from typing import Dict, Any -from PySide6.QtWidgets import * -from PySide6.QtGui import * -from PySide6.QtCore import * -from qfluentwidgets import * +from PySide6.QtWidgets import QWidget, QVBoxLayout, QGridLayout, QHBoxLayout +from PySide6.QtGui import QFont +from PySide6.QtCore import Signal, Qt, QTimer, QThread +from qfluentwidgets import SubtitleLabel, BodyLabel, CardWidget, PushButton +from loguru import logger + +from app.tools.variable import ( + STUDENT_CARD_SPACING, + STUDENT_CARD_MIN_WIDTH, + STUDENT_CARD_FIXED_WIDTH, + STUDENT_CARD_FIXED_HEIGHT, + STUDENT_MAX_COLUMNS, + STUDENT_CARD_MARGIN, +) +from app.tools.path_utils import get_path +from app.tools.personalised import load_custom_font +from app.Language.obtain_language import ( + get_content_name_async, + get_any_position_value_async, +) +from app.tools.config import read_drawn_record + + +# 后台加载学生数据的线程 +class StudentLoader(QThread): + """在后台读取并过滤学生数据,避免阻塞 UI 线程""" + + finished = Signal(list) + + def __init__( + self, + students_file, + class_name, + group_index, + gender_index, + group_filter, + gender_filter, + half_repeat, + ): + super().__init__() + self.students_file = students_file + self.class_name = class_name + self.group_index = group_index + self.gender_index = gender_index + self.group_filter = group_filter + self.gender_filter = gender_filter + self.half_repeat = half_repeat + + def run(self): + try: + # 读取文件 + with open(self.students_file, "r", encoding="utf-8") as f: + data = json.load(f) + + # 构建学生列表 + students_dict_list = [] + for name, student_data in data.items(): + student_dict = { + "id": student_data.get("id", ""), + "name": name, + "gender": student_data.get("gender", ""), + "group": student_data.get("group", ""), + "exist": student_data.get("exist", True), + } + students_dict_list.append(student_dict) + + filtered_students = students_dict_list + + # 小组筛选 + if self.group_index > 0: + groups = set() + for student in students_dict_list: + if "group" in student and student["group"]: + groups.add(student["group"]) + sorted_groups = sorted(list(groups)) + + if self.group_index == 1: + group_data = {} + for student in students_dict_list: + group_name = student.get("group", "") + if group_name: + group_data.setdefault(group_name, []).append(student) + for group_name in group_data: + group_data[group_name] = sorted( + group_data[group_name], key=lambda x: x.get("name", "") + ) + filtered_students = [] + for group_name in sorted(group_data.keys()): + group_info = { + "id": f"GROUP_{group_name}", + "name": f"小组 {group_name}", + "gender": "", + "group": group_name, + "exist": True, + "is_group": True, + "members": group_data[group_name], + } + filtered_students.append(group_info) + elif self.group_index > 1 and sorted_groups: + group_index_adjusted = self.group_index - 2 + if 0 <= group_index_adjusted < len(sorted_groups): + selected_group = sorted_groups[group_index_adjusted] + filtered_students = [ + student + for student in students_dict_list + if "group" in student and student["group"] == selected_group + ] + + # 性别筛选 + if self.gender_index > 0: + genders = set() + for student in filtered_students: + if student["gender"]: + genders.add(student["gender"]) + sorted_genders = sorted(list(genders)) + if self.gender_index <= len(sorted_genders): + selected_gender = sorted_genders[self.gender_index - 1] + filtered_students = [ + s for s in filtered_students if s["gender"] == selected_gender + ] + + # half_repeat 过滤 + if self.half_repeat > 0: + drawn_records = read_drawn_record( + self.class_name, self.gender_filter, self.group_filter + ) + drawn_counts = {name: count for name, count in drawn_records} + remaining_students = [] + if self.group_index == 1: + for student in filtered_students: + if student.get("is_group", False): + members = student.get("members", []) + all_members_drawn = True + for member in members: + member_name = member["name"] + if ( + member_name not in drawn_counts + or drawn_counts[member_name] < self.half_repeat + ): + all_members_drawn = False + break + if not all_members_drawn: + remaining_students.append(student) + else: + student_name = student["name"] + if ( + student_name not in drawn_counts + or drawn_counts[student_name] < self.half_repeat + ): + remaining_students.append(student) + else: + for student in filtered_students: + student_name = student["name"] + if ( + student_name not in drawn_counts + or drawn_counts[student_name] < self.half_repeat + ): + remaining_students.append(student) + filtered_students = remaining_students -from app.tools.variable import * -from app.tools.path_utils import * -from app.tools.personalised import * -from app.tools.settings_default import * -from app.tools.settings_access import * -from app.Language.obtain_language import * -from app.tools.config import * -from app.tools.list import * + # 发送结果回主线程 + self.finished.emit(filtered_students) + except Exception: + # 出错时返回空列表 + try: + self.finished.emit([]) + except Exception: + pass class RemainingListPage(QWidget): """剩余名单页面类""" - + # 定义信号,当剩余人数变化时发出 count_changed = Signal(int) - + def __init__(self, parent=None): super().__init__(parent) self.class_name = "" @@ -36,13 +191,23 @@ def __init__(self, parent=None): self.group_index = 0 self.gender_index = 0 self.remaining_students = [] - + # 布局更新状态跟踪 self._last_layout_width = 0 self._last_card_count = 0 self._layout_update_in_progress = False self._resize_timer = None - + self._is_resizing = False + + # 缓存一些在创建大量卡片时会频繁使用的资源 + # 减少每次创建卡片时的重复开销 + try: + self._font_family = load_custom_font() + except Exception: + self._font_family = None + # 预先设置为空;init_ui 中会尝试异步预取模板文本 + self._student_info_text = None + self.init_ui() # 延迟加载学生数据 @@ -64,13 +229,19 @@ def init_ui(self): # 标题 self.title_label = SubtitleLabel(title_text) self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.title_label.setFont(QFont(load_custom_font(), 18)) + if self._font_family: + self.title_label.setFont(QFont(self._font_family, 18)) + else: + self.title_label.setFont(QFont("", 18)) self.main_layout.addWidget(self.title_label) # 剩余人数标签 self.count_label = BodyLabel(count_text.format(count=0)) self.count_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.count_label.setFont(QFont(load_custom_font(), 12)) + if self._font_family: + self.count_label.setFont(QFont(self._font_family, 12)) + else: + self.count_label.setFont(QFont("", 12)) self.main_layout.addWidget(self.count_label) # 创建网格布局 @@ -80,6 +251,41 @@ def init_ui(self): # 初始化卡片列表 self.cards = [] + # 缓存所有创建过的卡片,避免在布局切换时频繁创建/销毁 + self._card_cache = {} + + # 分页状态(每页卡片数根据列数动态计算,但默认每页20) + self.current_page = 0 + self.cards_per_page = 20 + + # 分页控制 + pager_widget = QWidget() + pager_layout = QHBoxLayout(pager_widget) + pager_layout.setContentsMargins(0, 0, 0, 0) + pager_layout.setSpacing(10) + + self.prev_page_btn = PushButton("上一页") + self.next_page_btn = PushButton("下一页") + self.page_label = BodyLabel("Page 1") + if self._font_family: + self.page_label.setFont(QFont(self._font_family, 10)) + + self.prev_page_btn.clicked.connect(self._go_prev_page) + self.next_page_btn.clicked.connect(self._go_next_page) + + pager_layout.addWidget(self.prev_page_btn) + pager_layout.addWidget(self.page_label, alignment=Qt.AlignmentFlag.AlignCenter) + pager_layout.addWidget(self.next_page_btn) + + self.main_layout.addWidget(pager_widget) + + # 预取学生信息文本,避免在创建每个卡片时重复请求 + try: + self._student_info_text = get_any_position_value_async( + "remaining_list", "student_info", "name" + ) + except Exception: + self._student_info_text = "{id} {gender} {group}" def get_students_file(self): """获取学生数据文件路径""" @@ -89,8 +295,48 @@ def get_students_file(self): return students_file def load_student_data(self): - """加载学生数据""" - self._load_and_update_students() + """开始后台加载学生数据(非阻塞)""" + # 如果已经有加载线程在运行,则不再重复启动 + try: + if ( + hasattr(self, "_loading_thread") + and self._loading_thread is not None + and self._loading_thread.isRunning() + ): + return + except Exception: + pass + + students_file = self.get_students_file() + # 使用 StudentLoader 在后台处理 I/O 和筛选 + loader = StudentLoader( + str(students_file), + getattr(self, "class_name", ""), + getattr(self, "group_index", 0), + getattr(self, "gender_index", 0), + getattr(self, "group_filter", ""), + getattr(self, "gender_filter", ""), + getattr(self, "half_repeat", 0), + ) + + loader.finished.connect(self._on_students_loaded) + # 将线程引用保留在实例上,避免过早回收 + self._loading_thread = loader + loader.start() + + def _on_students_loaded(self, students_list): + """收到后台加载完成的学生列表并更新 UI(在主线程中执行)""" + try: + self.students = students_list + # 使用QTimer将更新调度到事件循环中,保持与原有逻辑一致 + QTimer.singleShot(0, self.update_ui) + finally: + try: + # 清理线程引用 + if hasattr(self, "_loading_thread"): + self._loading_thread = None + except Exception: + pass def update_ui(self): """更新UI显示""" @@ -107,10 +353,14 @@ def update_ui(self): # 更新标题和人数/组数 self.title_label.setText(title_text.format(class_name=self.class_name)) - + # 检查是否显示小组 - is_showing_groups = any(student.get("is_group", False) for student in self.students) if self.students else False - + is_showing_groups = ( + any(student.get("is_group", False) for student in self.students) + if self.students + else False + ) + if is_showing_groups: # 显示组数 group_count = len(self.students) @@ -123,40 +373,103 @@ def update_ui(self): self.cards = [] self._clear_grid_layout() - # 创建学生卡片 - for student in self.students: - card = self.create_student_card(student) + # 根据分页只创建当前页卡片 + total_students = len(self.students) if self.students else 0 + # 计算每页数量(尝试根据当前列数估算) + try: + cols = max( + 1, + min( + STUDENT_MAX_COLUMNS, + max( + 1, + self.width() // (STUDENT_CARD_MIN_WIDTH + STUDENT_CARD_SPACING), + ), + ), + ) + rows = 4 + estimated_per_page = cols * rows + self.cards_per_page = max(10, estimated_per_page) + except Exception: + self.cards_per_page = 20 + + start = self.current_page * self.cards_per_page + end = start + self.cards_per_page + page_students = self.students[start:end] + + for student in page_students: + # 使用缓存的卡片以减少创建销毁开销 + key = student.get("name") + card = self._card_cache.get(key) + if card is None: + card = self.create_student_card(student) + if card is not None: + self._card_cache[key] = card if card is not None: self.cards.append(card) + # 更新页码标签 + total_pages = max( + 1, (total_students + self.cards_per_page - 1) // self.cards_per_page + ) + self.page_label.setText(f"第 {self.current_page + 1} / {total_pages} 页") + self.prev_page_btn.setEnabled(self.current_page > 0) + self.next_page_btn.setEnabled(self.current_page < total_pages - 1) + # 直接更新布局,不使用延迟 self.update_layout() + def _go_prev_page(self): + if self.current_page > 0: + self.current_page -= 1 + self.update_ui() + + def _go_next_page(self): + total_students = len(self.students) if self.students else 0 + total_pages = max( + 1, (total_students + self.cards_per_page - 1) // self.cards_per_page + ) + if self.current_page < total_pages - 1: + self.current_page += 1 + self.update_ui() + def update_layout(self): """更新布局""" if not self.grid_layout or not self.cards: return - + # 检查是否需要更新布局 current_width = self.width() current_card_count = len(self.cards) - + # 如果布局正在更新中,或者宽度和卡片数量都没有变化,则跳过更新 - if (self._layout_update_in_progress or - (current_width == self._last_layout_width and - current_card_count == self._last_card_count)): - logger.debug(f"跳过布局更新: 宽度={current_width}, 卡片数={current_card_count}") + if self._layout_update_in_progress or ( + current_width == self._last_layout_width + and current_card_count == self._last_card_count + ): + logger.debug( + f"跳过布局更新: 宽度={current_width}, 卡片数={current_card_count}" + ) return - + # 设置布局更新标志 self._layout_update_in_progress = True self._last_layout_width = current_width self._last_card_count = current_card_count - + try: + # 在进行大量布局变更时禁用更新,减少中间重绘导致的卡顿 + try: + top_win = self.window() + if top_win is not None: + top_win.setUpdatesEnabled(False) + except Exception: + top_win = None + self.setUpdatesEnabled(False) + # 清空现有布局 self._clear_grid_layout() - + # 计算列数 def calculate_columns(width): """根据窗口宽度和卡片尺寸动态计算列数""" @@ -175,27 +488,45 @@ def calculate_columns(width): # 至少显示1列 return max(cols, 1) - + # 获取当前窗口宽度 window_width = max(self.width(), self.sizeHint().width()) columns = calculate_columns(window_width) - + # 添加卡片到网格布局 for i, card in enumerate(self.cards): row = i // columns col = i % columns self.grid_layout.addWidget(card, row, col) - # 确保卡片可见 - card.show() - + # 仅在控件当前不可见时显示,避免重复触发绘制 + if not card.isVisible(): + card.show() + # 设置列的伸缩因子,使卡片均匀分布 for col in range(columns): self.grid_layout.setColumnStretch(col, 1) - - logger.debug(f"布局更新完成: 宽度={window_width}, 列数={columns}, 卡片数={len(self.cards)}") + + logger.debug( + f"布局更新完成: 宽度={window_width}, 列数={columns}, 卡片数={len(self.cards)}" + ) finally: # 清除布局更新标志 self._layout_update_in_progress = False + # 恢复更新 + try: + self.setUpdatesEnabled(True) + except Exception: + pass + try: + if top_win is not None: + top_win.setUpdatesEnabled(True) + except Exception: + pass + try: + # 触发一次完整刷新 + self.update() + except Exception: + pass def _clear_grid_layout(self): """清空网格布局""" @@ -203,12 +534,17 @@ def _clear_grid_layout(self): for col in range(self.grid_layout.columnCount()): self.grid_layout.setColumnStretch(col, 0) + # 移除布局中的所有项,但不要销毁控件,保留在内存中以便复用 + # 这样可以避免频繁的 setParent()/delete 操作导致的卡顿 while self.grid_layout.count(): item = self.grid_layout.takeAt(0) widget = item.widget() if widget: + try: + self.grid_layout.removeWidget(widget) + except Exception: + pass widget.hide() - widget.setParent(None) def create_student_card(self, student: Dict[str, Any]) -> CardWidget: """创建学生卡片 @@ -221,19 +557,23 @@ def create_student_card(self, student: Dict[str, Any]) -> CardWidget: """ # 检查是否是小组卡片 is_group = student.get("is_group", False) - + card = CardWidget() - + # 设置卡片属性,标记是否是小组卡片 card.setProperty("is_group", is_group) - + if is_group: # 小组卡片使用与学生卡片相同的宽度,但高度自适应 card.setMinimumSize(STUDENT_CARD_FIXED_WIDTH, 0) card.setMaximumSize(STUDENT_CARD_FIXED_WIDTH, 500) layout = QVBoxLayout(card) - layout.setContentsMargins(STUDENT_CARD_MARGIN, STUDENT_CARD_MARGIN, - STUDENT_CARD_MARGIN, STUDENT_CARD_MARGIN) + layout.setContentsMargins( + STUDENT_CARD_MARGIN, + STUDENT_CARD_MARGIN, + STUDENT_CARD_MARGIN, + STUDENT_CARD_MARGIN, + ) layout.setSpacing(8) # 小组名称 @@ -251,11 +591,13 @@ def create_student_card(self, student: Dict[str, Any]) -> CardWidget: layout.addWidget(count_label) # 小组成员列表 - members_names = [member['name'] for member in members[:5]] # 最多显示5个成员 + members_names = [ + member["name"] for member in members[:5] + ] # 最多显示5个成员 members_text = "、".join(members_names) if len(members) > 5: - members_text += f" 等{len(members)-5}名成员" - + members_text += f" 等{len(members) - 5}名成员" + members_label = BodyLabel(members_text) members_label.setFont(QFont(load_custom_font(), 9)) members_label.setAlignment(Qt.AlignmentFlag.AlignLeft) @@ -266,18 +608,30 @@ def create_student_card(self, student: Dict[str, Any]) -> CardWidget: card.setFixedSize(STUDENT_CARD_FIXED_WIDTH, STUDENT_CARD_FIXED_HEIGHT) layout = QVBoxLayout(card) - layout.setContentsMargins(STUDENT_CARD_MARGIN, STUDENT_CARD_MARGIN, - STUDENT_CARD_MARGIN, STUDENT_CARD_MARGIN) - layout.setSpacing(5) - - # 使用异步函数获取学生信息格式文本 - student_info_text = get_any_position_value_async( - "remaining_list", "student_info", "name" + layout.setContentsMargins( + STUDENT_CARD_MARGIN, + STUDENT_CARD_MARGIN, + STUDENT_CARD_MARGIN, + STUDENT_CARD_MARGIN, ) + layout.setSpacing(5) + # 使用缓存的学生信息文本,若缓存不存在则回退到调用 + if self._student_info_text is None: + try: + student_info_text = get_any_position_value_async( + "remaining_list", "student_info", "name" + ) + except Exception: + student_info_text = "{id} {gender} {group}" + else: + student_info_text = self._student_info_text # 学生姓名 name_label = BodyLabel(student["name"]) - name_label.setFont(QFont(load_custom_font(), 14)) + if self._font_family: + name_label.setFont(QFont(self._font_family, 14)) + else: + name_label.setFont(QFont("", 14)) name_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(name_label) @@ -286,7 +640,10 @@ def create_student_card(self, student: Dict[str, Any]) -> CardWidget: id=student["id"], gender=student["gender"], group=student["group"] ) info_label = BodyLabel(info_text) - info_label.setFont(QFont(load_custom_font(), 9)) + if self._font_family: + info_label.setFont(QFont(self._font_family, 9)) + else: + info_label.setFont(QFont("", 9)) info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(info_label) @@ -325,7 +682,7 @@ def _load_and_update_students(self, count=None): # 根据小组和性别筛选 filtered_students = students_dict_list - + # 小组筛选 if group_index > 0: # 获取所有可用小组 @@ -333,10 +690,10 @@ def _load_and_update_students(self, count=None): for student in students_dict_list: if "group" in student and student["group"]: groups.add(student["group"]) - + # 排序小组列表 sorted_groups = sorted(list(groups)) - + # 处理"抽取全部小组"的情况 (group_index == 1) if group_index == 1: # 创建小组数据结构,每个小组包含组名和成员列表 @@ -347,11 +704,13 @@ def _load_and_update_students(self, count=None): if group_name not in group_data: group_data[group_name] = [] group_data[group_name].append(student) - + # 对每个小组内的成员按姓名排序 for group_name in group_data: - group_data[group_name] = sorted(group_data[group_name], key=lambda x: x.get("name", "")) - + group_data[group_name] = sorted( + group_data[group_name], key=lambda x: x.get("name", "") + ) + # 创建一个特殊的学生列表,用于显示小组信息 filtered_students = [] for group_name in sorted(group_data.keys()): @@ -363,7 +722,7 @@ def _load_and_update_students(self, count=None): "group": group_name, "exist": True, "is_group": True, # 标记这是一个小组 - "members": group_data[group_name] # 保存小组成员列表 + "members": group_data[group_name], # 保存小组成员列表 } filtered_students.append(group_info) elif group_index > 1 and sorted_groups: @@ -372,10 +731,11 @@ def _load_and_update_students(self, count=None): if 0 <= group_index_adjusted < len(sorted_groups): selected_group = sorted_groups[group_index_adjusted] filtered_students = [ - student for student in students_dict_list + student + for student in students_dict_list if "group" in student and student["group"] == selected_group ] - + # 根据性别筛选 if gender_index > 0: # 0表示全部性别 # 获取所有可用的性别 @@ -383,27 +743,30 @@ def _load_and_update_students(self, count=None): for student in filtered_students: if student["gender"]: genders.add(student["gender"]) - + # 将性别转换为排序后的列表 sorted_genders = sorted(list(genders)) - + # 根据索引获取选择的性别 if gender_index <= len(sorted_genders): selected_gender = sorted_genders[gender_index - 1] filtered_students = [ - student for student in filtered_students + student + for student in filtered_students if student["gender"] == selected_gender ] # 根据half_repeat设置获取未抽取的学生 if self.half_repeat > 0: # 读取已抽取记录 - drawn_records = read_drawn_record(self.class_name, self.gender_filter, self.group_filter) + drawn_records = read_drawn_record( + self.class_name, self.gender_filter, self.group_filter + ) drawn_counts = {name: count for name, count in drawn_records} # 过滤掉已抽取次数达到或超过设置值的学生 remaining_students = [] - + # 特殊处理小组模式 (group_index == 1) if group_index == 1: # 对于小组模式,需要检查每个小组是否还有未被完全抽取的成员 @@ -412,7 +775,7 @@ def _load_and_update_students(self, count=None): if student.get("is_group", False): group_name = student["group"] members = student.get("members", []) - + # 检查小组成员是否都已被抽取 all_members_drawn = True for member in members: @@ -424,7 +787,7 @@ def _load_and_update_students(self, count=None): ): all_members_drawn = False break - + # 只有当小组不是所有成员都被抽取时才保留 if not all_members_drawn: remaining_students.append(student) @@ -501,11 +864,11 @@ def update_remaining_list( # 重新加载学生数据 self.load_student_data() - + # 如果需要发出信号,则发出count_changed信号 if emit_signal: # 计算剩余人数 - remaining_count = len(self.students) if hasattr(self, 'students') else 0 + remaining_count = len(self.students) if hasattr(self, "students") else 0 self.count_changed.emit(remaining_count) def refresh(self): @@ -515,56 +878,67 @@ def refresh(self): self._last_layout_width = 0 self._last_card_count = 0 self.load_student_data() - + def on_count_changed(self, count): """处理剩余人数变化的槽函数 - + Args: count: 剩余人数 """ - # 重新加载学生数据 - self._load_and_update_students(count=count) + # 重新加载学生数据(使用后台加载以避免阻塞) + # 保持 count 参数以兼容旧逻辑,如需特殊处理可扩展 + self.load_student_data() def resizeEvent(self, event): """窗口大小变化事件""" # 检查窗口大小是否真的改变了 new_size = event.size() old_size = event.oldSize() - + # 如果窗口大小没有改变,不触发布局更新 if new_size == old_size: return - + # 检查宽度是否发生了显著变化(至少变化5像素才触发布局更新) width_change = abs(new_size.width() - self._last_layout_width) if width_change < 5: return - + # 使用QTimer延迟布局更新,避免递归调用 if self._resize_timer is not None: self._resize_timer.stop() self._resize_timer = QTimer() self._resize_timer.setSingleShot(True) self._resize_timer.timeout.connect(self._delayed_update_layout) - self._resize_timer.start(100) # 增加延迟时间,减少频繁更新 + # 增加防抖延迟,避免用户缩放窗口时频繁触发布局重排导致卡顿 + self._is_resizing = True + self._resize_timer.start(300) super().resizeEvent(event) def _delayed_update_layout(self): """延迟更新布局""" try: + # 调整大小已结束,清除标志 + self._is_resizing = False if hasattr(self, "grid_layout") and self.grid_layout is not None: if self.isVisible(): # 检查是否需要更新布局 current_width = self.width() current_card_count = len(self.cards) - + # 只有当宽度或卡片数量发生变化时才更新布局 - if (current_width != self._last_layout_width or - current_card_count != self._last_card_count): + if ( + current_width != self._last_layout_width + or current_card_count != self._last_card_count + ): self.update_layout() - logger.debug(f"延迟布局更新完成,当前卡片数量: {len(self.cards)}") + logger.debug( + f"延迟布局更新完成,当前卡片数量: {len(self.cards)}" + ) else: - logger.debug(f"跳过布局更新: 宽度={current_width}, 卡片数={current_card_count}") + logger.debug( + f"跳过布局更新: 宽度={current_width}, 卡片数={current_card_count}" + ) except RuntimeError as e: logger.error(f"延迟布局更新错误: {e}") @@ -575,4 +949,4 @@ def closeEvent(self, event): self._resize_timer.stop() self._resize_timer = None - super().closeEvent(event) \ No newline at end of file + super().closeEvent(event) diff --git a/app/view/main/roll_call.py b/app/view/main/roll_call.py index 64d29d34..53a68cdb 100644 --- a/app/view/main/roll_call.py +++ b/app/view/main/roll_call.py @@ -24,8 +24,10 @@ from app.page_building.another_window import * from random import SystemRandom + system_random = SystemRandom() + # ================================================== # 班级点名类 # ================================================== @@ -34,15 +36,15 @@ def __init__(self, parent=None): super().__init__(parent) self.file_watcher = QFileSystemWatcher() self.setup_file_watcher() - + # 长按功能相关变量 self.press_timer = QTimer() self.press_timer.timeout.connect(self.handle_long_press) self.long_press_interval = 100 # 长按时连续触发的间隔时间(毫秒) - self.long_press_delay = 500 # 开始长按前的延迟时间(毫秒) - self.is_long_pressing = False # 是否正在长按 - self.long_press_direction = 0 # 长按方向:1为增加,-1为减少 - + self.long_press_delay = 500 # 开始长按前的延迟时间(毫秒) + self.is_long_pressing = False # 是否正在长按 + self.long_press_direction = 0 # 长按方向:1为增加,-1为减少 + self.initUI() def handle_long_press(self): @@ -52,10 +54,10 @@ def handle_long_press(self): self.press_timer.setInterval(self.long_press_interval) # 执行更新计数 self.update_count(self.long_press_direction) - + def start_long_press(self, direction): """开始长按 - + Args: direction (int): 长按方向,1为增加,-1为减少 """ @@ -64,7 +66,7 @@ def start_long_press(self, direction): # 设置初始延迟 self.press_timer.setInterval(self.long_press_delay) self.press_timer.start() - + def stop_long_press(self): """停止长按""" self.is_long_pressing = False @@ -108,7 +110,7 @@ def initUI(self): self.minus_button.setFont(QFont(load_custom_font(), 20)) self.minus_button.setFixedSize(45, 45) self.minus_button.clicked.connect(lambda: self.update_count(-1)) - + # 添加长按连续减功能 self.minus_button.pressed.connect(lambda: self.start_long_press(-1)) self.minus_button.released.connect(self.stop_long_press) @@ -123,7 +125,7 @@ def initUI(self): self.plus_button.setFont(QFont(load_custom_font(), 20)) self.plus_button.setFixedSize(45, 45) self.plus_button.clicked.connect(lambda: self.update_count(1)) - + # 添加长按连续加功能 self.plus_button.pressed.connect(lambda: self.start_long_press(1)) self.plus_button.released.connect(self.stop_long_press) @@ -244,7 +246,9 @@ def on_class_changed(self): # 性别 self.gender_combobox.clear() - gender_options = get_content_combo_name_async("roll_call", "gender_combobox") + gender_options = get_content_combo_name_async( + "roll_call", "gender_combobox" + ) gender_list = get_gender_list(self.list_combobox.currentText()) # 如果有性别,才添加"抽取全部性别"选项 if gender_list and gender_list != [""]: @@ -252,18 +256,20 @@ def on_class_changed(self): self.gender_combobox.addItems(gender_options + gender_list) else: # 只添加基础选项,跳过"抽取全部性别" - self.gender_combobox.addItems(gender_options[:1]) # 只添加"抽取全部性别" + self.gender_combobox.addItems( + gender_options[:1] + ) # 只添加"抽取全部性别" # 根据当前选择的范围计算实际的总人数 group_index = self.range_combobox.currentIndex() group_filter = self.range_combobox.currentText() - + # 使用统一的方法更新剩余人数显示 self.update_many_count_label() - + # 获取当前选择的小组/性别 group_index = self.range_combobox.currentIndex() - + # 根据范围计算实际人数 if group_index == 0: # 全班 total_count = len(get_student_list(self.list_combobox.currentText())) @@ -273,19 +279,19 @@ def on_class_changed(self): group_filter = self.range_combobox.currentText() students = get_student_list(self.list_combobox.currentText()) total_count = len([s for s in students if s["group"] == group_filter]) - + # 根据总人数是否为0,启用或禁用开始按钮 if total_count == 0: self.start_button.setEnabled(False) else: self.start_button.setEnabled(True) - + # 根据总人数是否为0,启用或禁用开始按钮 if total_count == 0: self.start_button.setEnabled(False) else: self.start_button.setEnabled(True) - + # 更新剩余名单窗口 if ( hasattr(self, "remaining_list_page") @@ -303,10 +309,10 @@ def on_filter_changed(self): try: # 使用统一的方法更新剩余人数显示 self.update_many_count_label() - + # 获取当前选择的小组/性别 group_index = self.range_combobox.currentIndex() - + # 根据范围计算实际人数 if group_index == 0: # 全班 total_count = len(get_student_list(self.list_combobox.currentText())) @@ -316,7 +322,7 @@ def on_filter_changed(self): group_filter = self.range_combobox.currentText() students = get_student_list(self.list_combobox.currentText()) total_count = len([s for s in students if s["group"] == group_filter]) - + # 根据总人数是否为0,启用或禁用开始按钮 if total_count == 0: self.start_button.setEnabled(False) @@ -460,7 +466,7 @@ def stop_animation(self): and hasattr(self.remaining_list_page, "count_changed") ): self.remaining_list_page.count_changed.emit(self.remaining_count) - + # 更新剩余名单窗口 QTimer.singleShot(100, self._update_remaining_list_delayed) @@ -511,28 +517,30 @@ def draw_random(self): "exist": student_tuple[4], } students_dict_list.append(student_dict) - + # 获取抽取类型 draw_type = readme_settings_async("roll_call_settings", "draw_type") - + # 处理小组模式下的特殊逻辑 if group_index == 1: # 小组模式下,students_data已经只包含小组信息 # 直接使用小组数据进行抽取 draw_count = min(self.current_count, len(students_dict_list)) - + selected_groups = [] if draw_type == 1: # 权重抽取模式下,所有小组权重相同 weights = [1.0] * len(students_dict_list) - + # 根据权重抽取小组 for _ in range(draw_count): if not students_dict_list: break total_weight = sum(weights) if total_weight <= 0: - random_index = system_random.randint(0, len(students_dict_list) - 1) + random_index = system_random.randint( + 0, len(students_dict_list) - 1 + ) else: rand_value = system_random.uniform(0, total_weight) cumulative_weight = 0 @@ -542,10 +550,12 @@ def draw_random(self): if rand_value <= cumulative_weight: random_index = i break - + selected_group = students_dict_list[random_index] - selected_groups.append((None, selected_group["name"], True)) # (id, name, exist) - + selected_groups.append( + (None, selected_group["name"], True) + ) # (id, name, exist) + students_dict_list.pop(random_index) weights.pop(random_index) else: @@ -555,16 +565,18 @@ def draw_random(self): break random_index = system_random.randint(0, len(students_dict_list) - 1) selected_group = students_dict_list[random_index] - selected_groups.append((None, selected_group["name"], True)) # (id, name, exist) - + selected_groups.append( + (None, selected_group["name"], True) + ) # (id, name, exist) + students_dict_list.pop(random_index) - + self.final_selected_students = selected_groups self.final_class_name = class_name self.final_selected_students_dict = [] # 小组模式下不存储学生字典 self.final_group_filter = group_filter self.final_gender_filter = gender_filter - + self.display_result(selected_groups, class_name) return @@ -654,9 +666,7 @@ def display_result(self, selected_students, class_name): "roll_call_settings", "student_image" ), group_index=group_index, - show_random=readme_settings_async( - "roll_call_settings", "show_random" - ), + show_random=readme_settings_async("roll_call_settings", "show_random"), ) ResultDisplayUtils.display_results_in_grid(self.result_grid, student_labels) @@ -713,7 +723,7 @@ def get_total_count(self): # 获取当前选择的范围和性别 group_index = self.range_combobox.currentIndex() group_filter = self.range_combobox.currentText() - + # 根据范围计算实际人数 if group_index == 0: # 全班 total_count = len(get_student_list(self.list_combobox.currentText())) @@ -730,7 +740,7 @@ def update_many_count_label(self): group_index = self.range_combobox.currentIndex() group_filter = self.range_combobox.currentText() gender_filter = self.gender_combobox.currentText() - + # 根据范围计算实际人数 if group_index == 0: # 全班 total_count = len(get_student_list(self.list_combobox.currentText())) @@ -750,7 +760,7 @@ def update_many_count_label(self): ) if self.remaining_count == 0: self.remaining_count = total_count - + # 根据是否为小组模式选择不同的文本模板 if group_index == 1: # 小组模式 text_template = get_any_position_value( @@ -764,7 +774,7 @@ def update_many_count_label(self): total_count=total_count, remaining_count=self.remaining_count ) self.many_count_label.setText(formatted_text) - + # 根据总人数是否为0,启用或禁用开始按钮 if total_count == 0: self.start_button.setEnabled(False) @@ -773,7 +783,10 @@ def update_many_count_label(self): def update_remaining_list_window(self): """更新剩余名单窗口的内容""" - if hasattr(self, "remaining_list_page") and self.remaining_list_page is not None: + if ( + hasattr(self, "remaining_list_page") + and self.remaining_list_page is not None + ): try: class_name = self.list_combobox.currentText() group_filter = self.range_combobox.currentText() @@ -781,7 +794,7 @@ def update_remaining_list_window(self): group_index = self.range_combobox.currentIndex() gender_index = self.gender_combobox.currentIndex() half_repeat = readme_settings_async("roll_call_settings", "half_repeat") - + # 更新剩余名单页面内容 if hasattr(self.remaining_list_page, "update_remaining_list"): self.remaining_list_page.update_remaining_list( @@ -799,7 +812,10 @@ def update_remaining_list_window(self): def show_remaining_list(self): """显示剩余名单窗口""" # 如果窗口已存在,则激活该窗口并更新内容 - if hasattr(self, "remaining_list_page") and self.remaining_list_page is not None: + if ( + hasattr(self, "remaining_list_page") + and self.remaining_list_page is not None + ): try: # 获取窗口实例 window = self.remaining_list_page.window() @@ -813,7 +829,7 @@ def show_remaining_list(self): except Exception as e: logger.error(f"激活剩余名单窗口失败: {e}") # 如果激活失败,继续创建新窗口 - + # 创建新窗口 class_name = self.list_combobox.currentText() group_filter = self.range_combobox.currentText() @@ -915,13 +931,13 @@ def populate_lists(self): # 填充范围和性别选项 self.range_combobox.blockSignals(True) self.range_combobox.clear() - + # 获取基础选项 base_options = get_content_combo_name_async("roll_call", "range_combobox") - + # 获取小组列表 group_list = get_group_list(self.list_combobox.currentText()) - + # 如果有小组,才添加"抽取全部小组"选项 if group_list: # 添加基础选项和小组列表 @@ -929,7 +945,7 @@ def populate_lists(self): else: # 只添加基础选项,跳过"抽取全部小组" self.range_combobox.addItems(base_options[:1]) # 只添加"抽取全部学生" - + self.range_combobox.blockSignals(False) self.gender_combobox.blockSignals(True) @@ -944,7 +960,7 @@ def populate_lists(self): group_index = self.range_combobox.currentIndex() group_filter = self.range_combobox.currentText() gender_filter = self.gender_combobox.currentText() - + # 根据范围计算实际人数 if group_index == 0: # 全班 total_count = len(get_student_list(self.list_combobox.currentText())) @@ -976,7 +992,7 @@ def populate_lists(self): total_count=total_count, remaining_count=self.remaining_count ) self.many_count_label.setText(formatted_text) - + # 根据总人数是否为0,启用或禁用开始按钮 if total_count == 0: self.start_button.setEnabled(False) diff --git a/app/view/settings/settings.py b/app/view/settings/settings.py index e82c5f13..d8dbb6dc 100644 --- a/app/view/settings/settings.py +++ b/app/view/settings/settings.py @@ -274,7 +274,9 @@ def _background_warmup_pages( return # 仅预热有限数量的页面,避免一次性占用主线程 names_to_preload = names[:max_preload] - logger.debug(f"后台预热将创建 {len(names_to_preload)} / {len(names)} 个页面") + logger.debug( + f"后台预热将创建 {len(names_to_preload)} / {len(names)} 个页面" + ) # 仅为要预热的页面调度创建,避免一次性调度所有页面 for i, name in enumerate(names_to_preload): # 延迟创建,避免短时间内占用主线程 diff --git a/main.py b/main.py index c92155a2..ac9bd4b3 100644 --- a/main.py +++ b/main.py @@ -32,6 +32,7 @@ if project_root not in sys.path: sys.path.insert(0, project_root) + # ================================================== # 日志配置相关函数 # ================================================== @@ -57,6 +58,8 @@ def configure_logging(): # 显示调节 # ================================================== """根据设置自动调整DPI缩放模式""" + + def configure_dpi_scale(): """配置DPI缩放模式""" dpiScale = readme_settings("basic_settings", "dpiScale") @@ -111,7 +114,7 @@ def check_single_instance(): def setup_local_server(): """设置本地服务器,用于接收激活窗口的信号 - + Returns: QLocalServer: 本地服务器对象 """ @@ -119,7 +122,7 @@ def setup_local_server(): if not server.listen(SHARED_MEMORY_KEY): logger.error(f"无法启动本地服务器: {server.errorString()}") return None - + def handle_new_connection(): """处理新的连接请求""" socket = server.nextPendingConnection() @@ -134,11 +137,12 @@ def handle_new_connection(): main_window.activateWindow() logger.info("已激活主窗口") socket.disconnectFromServer() - + server.newConnection.connect(handle_new_connection) logger.info("本地服务器已启动,等待激活信号") return server + # ================================================== # 字体设置相关函数 # ================================================== @@ -149,6 +153,7 @@ def apply_font_settings(): setFontFamilies([font_family]) QTimer.singleShot(FONT_APPLY_DELAY, lambda: apply_font_to_application(font_family)) + def apply_font_to_application(font_family): """应用字体设置到整个应用程序,优化版本使用字体管理器 @@ -240,6 +245,7 @@ def create_settings_window(): global settings_window try: from app.view.settings.settings import SettingsWindow + settings_window = SettingsWindow() except Exception as e: logger.error(f"创建设置窗口失败: {e}", exc_info=True) @@ -340,12 +346,12 @@ def main_async(): # 首先进行单实例检查 shared_memory, is_first_instance = check_single_instance() - + if not is_first_instance: # 不是第一个实例,退出程序 logger.info("程序将退出,已有实例已激活") sys.exit(0) - + # 设置本地服务器,用于接收激活窗口的信号 local_server = setup_local_server() if not local_server: @@ -375,7 +381,7 @@ def main_async(): # 程序退出时释放共享内存 shared_memory.detach() - + # 关闭本地服务器 if local_server: local_server.close() diff --git a/pyproject.toml b/pyproject.toml index fd2f34d3..01a98168 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,3 +78,10 @@ dev-dependencies = [ "pre-commit>=3.5.0", "ruff>=0.14.3", ] + +[tool.ruff] +# 忽略特定错误/警告(例如 F405: "name may be undefined, or defined from star imports") +ignore = ["F405","E722","E501","B012","F403","C901","B007","F841","C416","C414","E402"] +# 可选:关闭某些规则或配置 +extend-ignore = [] +select = ["E", "F", "W", "C", "B", "B9"] diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 00000000..9dbdb98a --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,8 @@ +{ + "typeCheckingMode": "basic", + "reportOptionalMemberAccess": "none", + "reportArgumentType": "none", + "reportCallIssue": "none", + "reportPossiblyUnboundVariable": "none", + "exclude": ["build", "dist", "__pycache__"] +} From 722e137fbb7efe1146fef673eafb032199f9c614 Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Sun, 16 Nov 2025 18:32:31 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=E5=8E=BB=E9=99=A4=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E4=B8=AD=E7=9A=84=E5=88=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/view/another_window/remaining_list.py | 78 ++--------------------- 1 file changed, 7 insertions(+), 71 deletions(-) diff --git a/app/view/another_window/remaining_list.py b/app/view/another_window/remaining_list.py index 45cae42a..b5ffbb7b 100644 --- a/app/view/another_window/remaining_list.py +++ b/app/view/another_window/remaining_list.py @@ -6,10 +6,10 @@ import json from typing import Dict, Any -from PySide6.QtWidgets import QWidget, QVBoxLayout, QGridLayout, QHBoxLayout +from PySide6.QtWidgets import QWidget, QVBoxLayout, QGridLayout from PySide6.QtGui import QFont from PySide6.QtCore import Signal, Qt, QTimer, QThread -from qfluentwidgets import SubtitleLabel, BodyLabel, CardWidget, PushButton +from qfluentwidgets import SubtitleLabel, BodyLabel, CardWidget from loguru import logger from app.tools.variable import ( @@ -254,30 +254,7 @@ def init_ui(self): # 缓存所有创建过的卡片,避免在布局切换时频繁创建/销毁 self._card_cache = {} - # 分页状态(每页卡片数根据列数动态计算,但默认每页20) - self.current_page = 0 - self.cards_per_page = 20 - - # 分页控制 - pager_widget = QWidget() - pager_layout = QHBoxLayout(pager_widget) - pager_layout.setContentsMargins(0, 0, 0, 0) - pager_layout.setSpacing(10) - - self.prev_page_btn = PushButton("上一页") - self.next_page_btn = PushButton("下一页") - self.page_label = BodyLabel("Page 1") - if self._font_family: - self.page_label.setFont(QFont(self._font_family, 10)) - - self.prev_page_btn.clicked.connect(self._go_prev_page) - self.next_page_btn.clicked.connect(self._go_next_page) - - pager_layout.addWidget(self.prev_page_btn) - pager_layout.addWidget(self.page_label, alignment=Qt.AlignmentFlag.AlignCenter) - pager_layout.addWidget(self.next_page_btn) - - self.main_layout.addWidget(pager_widget) + # 不再使用分页,所有卡片一次性展示 # 预取学生信息文本,避免在创建每个卡片时重复请求 try: @@ -373,29 +350,8 @@ def update_ui(self): self.cards = [] self._clear_grid_layout() - # 根据分页只创建当前页卡片 - total_students = len(self.students) if self.students else 0 - # 计算每页数量(尝试根据当前列数估算) - try: - cols = max( - 1, - min( - STUDENT_MAX_COLUMNS, - max( - 1, - self.width() // (STUDENT_CARD_MIN_WIDTH + STUDENT_CARD_SPACING), - ), - ), - ) - rows = 4 - estimated_per_page = cols * rows - self.cards_per_page = max(10, estimated_per_page) - except Exception: - self.cards_per_page = 20 - - start = self.current_page * self.cards_per_page - end = start + self.cards_per_page - page_students = self.students[start:end] + # 一次性创建所有卡片(不分页) + page_students = self.students if self.students else [] for student in page_students: # 使用缓存的卡片以减少创建销毁开销 @@ -408,30 +364,10 @@ def update_ui(self): if card is not None: self.cards.append(card) - # 更新页码标签 - total_pages = max( - 1, (total_students + self.cards_per_page - 1) // self.cards_per_page - ) - self.page_label.setText(f"第 {self.current_page + 1} / {total_pages} 页") - self.prev_page_btn.setEnabled(self.current_page > 0) - self.next_page_btn.setEnabled(self.current_page < total_pages - 1) - - # 直接更新布局,不使用延迟 + # 直接更新布局 self.update_layout() - def _go_prev_page(self): - if self.current_page > 0: - self.current_page -= 1 - self.update_ui() - - def _go_next_page(self): - total_students = len(self.students) if self.students else 0 - total_pages = max( - 1, (total_students + self.cards_per_page - 1) // self.cards_per_page - ) - if self.current_page < total_pages - 1: - self.current_page += 1 - self.update_ui() + # 已移除分页功能,相关方法已删除 def update_layout(self): """更新布局""" From 46792139c43eeac53fb143559cd309b8159ac50f Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Sun, 16 Nov 2025 18:52:50 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=A4=9A?= =?UTF-8?q?=E7=BA=BF=E7=A8=8B=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/view/another_window/remaining_list.py | 262 +++++++++++++++++----- 1 file changed, 204 insertions(+), 58 deletions(-) diff --git a/app/view/another_window/remaining_list.py b/app/view/another_window/remaining_list.py index b5ffbb7b..ed753697 100644 --- a/app/view/another_window/remaining_list.py +++ b/app/view/another_window/remaining_list.py @@ -8,16 +8,22 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QGridLayout from PySide6.QtGui import QFont -from PySide6.QtCore import Signal, Qt, QTimer, QThread +from PySide6.QtCore import ( + Signal, + Qt, + QTimer, + QThread, + QRunnable, + QThreadPool, + QObject, +) from qfluentwidgets import SubtitleLabel, BodyLabel, CardWidget from loguru import logger from app.tools.variable import ( STUDENT_CARD_SPACING, - STUDENT_CARD_MIN_WIDTH, STUDENT_CARD_FIXED_WIDTH, STUDENT_CARD_FIXED_HEIGHT, - STUDENT_MAX_COLUMNS, STUDENT_CARD_MARGIN, ) from app.tools.path_utils import get_path @@ -208,6 +214,13 @@ def __init__(self, parent=None): # 预先设置为空;init_ui 中会尝试异步预取模板文本 self._student_info_text = None + # 异步渲染相关状态(使用 QThreadPool) + self._pending_students = [] + self._batch_size = 20 # 每批创建的卡片数量 + self._rendering = False + self._thread_pool = QThreadPool.globalInstance() + self._render_reporter = None + self.init_ui() # 延迟加载学生数据 @@ -346,26 +359,13 @@ def update_ui(self): # 显示人数 self.count_label.setText(count_text.format(count=len(self.students))) - # 清空现有卡片 + # 清空现有卡片并准备异步渲染 self.cards = [] self._clear_grid_layout() - # 一次性创建所有卡片(不分页) - page_students = self.students if self.students else [] - - for student in page_students: - # 使用缓存的卡片以减少创建销毁开销 - key = student.get("name") - card = self._card_cache.get(key) - if card is None: - card = self.create_student_card(student) - if card is not None: - self._card_cache[key] = card - if card is not None: - self.cards.append(card) - - # 直接更新布局 - self.update_layout() + # 将待渲染学生放入队列,启动增量渲染 + self._pending_students = list(self.students) if self.students else [] + self._start_incremental_render() # 已移除分页功能,相关方法已删除 @@ -407,27 +407,8 @@ def update_layout(self): self._clear_grid_layout() # 计算列数 - def calculate_columns(width): - """根据窗口宽度和卡片尺寸动态计算列数""" - if width <= 0: - return 1 - - # 计算可用宽度(减去左右边距) - available_width = width - 40 # 左右各20px边距 - - # 所有卡片使用相同的尺寸 - card_actual_width = STUDENT_CARD_MIN_WIDTH + STUDENT_CARD_SPACING - max_cols = STUDENT_MAX_COLUMNS - - # 计算最大可能列数(不超过最大列数限制) - cols = min(int(available_width // card_actual_width), max_cols) - - # 至少显示1列 - return max(cols, 1) - - # 获取当前窗口宽度 window_width = max(self.width(), self.sizeHint().width()) - columns = calculate_columns(window_width) + columns = self._calculate_columns(window_width) # 添加卡片到网格布局 for i, card in enumerate(self.cards): @@ -464,6 +445,162 @@ def calculate_columns(width): except Exception: pass + def _calculate_columns(self, width: int) -> int: + """根据窗口宽度和卡片尺寸动态计算列数""" + try: + if width <= 0: + return 1 + + # 计算可用宽度(减去左右边距) + available_width = width - 40 # 左右各20px边距 + + # 所有卡片使用相同的尺寸 + card_actual_width = STUDENT_CARD_FIXED_WIDTH + STUDENT_CARD_SPACING + max_cols = max(1, available_width // card_actual_width) + + # 至少显示1列,且不超过一个合理上限 + return max(1, min(int(max_cols), 6)) + except Exception: + return 1 + + def _start_incremental_render(self): + """使用 QThreadPool 启动后台任务,按批准备数据并通过信号通知主线程创建控件""" + if self._rendering: + return + + # 准备 reporter(QObject,携带信号) + class _BatchReporter(QObject): + batch_ready = Signal(list) + finished = Signal() + + reporter = _BatchReporter() + reporter.batch_ready.connect(self._on_batch_ready) + reporter.finished.connect(self._on_render_finished) + + # 启动后台任务 + task_students = list(self._pending_students) + + class StudentRenderTask(QRunnable): + def __init__(self, students, batch_size, reporter, info_template): + super().__init__() + self.students = students + self.batch_size = batch_size + self.reporter = reporter + self.info_template = info_template or "{id} {gender} {group}" + self.setAutoDelete(True) + + def run(self): + try: + while self.students: + batch = [] + for _ in range(self.batch_size): + if not self.students: + break + student = self.students.pop(0) + # 在后台预格式化显示文本,减少主线程工作 + s = dict(student) + try: + s["info_text_pre"] = self.info_template.format( + id=s.get("id", ""), + gender=s.get("gender", ""), + group=s.get("group", ""), + ) + except Exception: + s["info_text_pre"] = ( + f"{s.get('id', '')} {s.get('gender', '')} {s.get('group', '')}" + ) + + if s.get("is_group", False): + members = s.get("members", []) + members_names = [m.get("name", "") for m in members[:5]] + members_text = "、".join(members_names) + if len(members) > 5: + members_text += f" 等{len(members) - 5}名成员" + s["members_text_pre"] = members_text + + batch.append(s) + # 发射信号到主线程,主线程负责创建 QWidget + try: + self.reporter.batch_ready.emit(batch) + except Exception: + pass + try: + self.reporter.finished.emit() + except Exception: + pass + except Exception: + try: + self.reporter.finished.emit() + except Exception: + pass + + self._render_reporter = reporter + task = StudentRenderTask( + task_students, self._batch_size, reporter, self._student_info_text + ) + self._rendering = True + self._thread_pool.start(task) + + def _render_next_batch(self): + # 该方法现在由后台任务通过 reporter 信号触发,已废弃 + return + + def _on_batch_ready(self, batch: list): + """主线程槽:接收一批学生数据并创建卡片加入布局""" + if not batch: + return + + # 创建卡片并直接加入布局缓存 + for student in batch: + key = student.get("name") + card = self._card_cache.get(key) + if card is None: + card = self.create_student_card(student) + if card is not None: + self._card_cache[key] = card + if card is not None: + self.cards.append(card) + + # 将新卡片添加到布局(不清空已有布局) + try: + columns = self._calculate_columns( + max(self.width(), self.sizeHint().width()) + ) + start_index = len(self.cards) - len(batch) + for i, card in enumerate(self.cards[start_index:], start=start_index): + row = i // columns + col = i % columns + self.grid_layout.addWidget(card, row, col) + if not card.isVisible(): + card.show() + + for col in range(columns): + self.grid_layout.setColumnStretch(col, 1) + except Exception: + logger.exception("增量渲染时布局更新失败") + + def _on_render_finished(self): + """后台渲染完成后的槽""" + self._rendering = False + self._pending_students = [] + # 最后触发完整布局更新以修正位置 + QTimer.singleShot(0, self.update_layout) + + def _finalize_render(self): + """渲染完成后的收尾工作""" + # 停止定时器 + try: + if self._render_timer is not None: + self._render_timer.stop() + self._render_timer = None + except Exception: + pass + + self._rendering = False + + # 最后触发完整布局更新以修正位置 + QTimer.singleShot(0, self.update_layout) + def _clear_grid_layout(self): """清空网格布局""" # 重置列伸缩因子 @@ -527,12 +664,13 @@ def create_student_card(self, student: Dict[str, Any]) -> CardWidget: layout.addWidget(count_label) # 小组成员列表 - members_names = [ - member["name"] for member in members[:5] - ] # 最多显示5个成员 - members_text = "、".join(members_names) - if len(members) > 5: - members_text += f" 等{len(members) - 5}名成员" + # 使用后台预计算的 members 文本(若存在) + members_text = student.get("members_text_pre") + if members_text is None: + members_names = [member.get("name", "") for member in members[:5]] + members_text = "、".join(members_names) + if len(members) > 5: + members_text += f" 等{len(members) - 5}名成员" members_label = BodyLabel(members_text) members_label.setFont(QFont(load_custom_font(), 9)) @@ -552,16 +690,19 @@ def create_student_card(self, student: Dict[str, Any]) -> CardWidget: ) layout.setSpacing(5) - # 使用缓存的学生信息文本,若缓存不存在则回退到调用 - if self._student_info_text is None: - try: - student_info_text = get_any_position_value_async( - "remaining_list", "student_info", "name" - ) - except Exception: - student_info_text = "{id} {gender} {group}" + # 使用后台预计算的 info 文本(若存在),否则从模板生成 + if student.get("info_text_pre") is not None: + student_info_text = student.get("info_text_pre") else: - student_info_text = self._student_info_text + if self._student_info_text is None: + try: + student_info_text = get_any_position_value_async( + "remaining_list", "student_info", "name" + ) + except Exception: + student_info_text = "{id} {gender} {group}" + else: + student_info_text = self._student_info_text # 学生姓名 name_label = BodyLabel(student["name"]) if self._font_family: @@ -572,9 +713,14 @@ def create_student_card(self, student: Dict[str, Any]) -> CardWidget: layout.addWidget(name_label) # 学生信息 - info_text = student_info_text.format( - id=student["id"], gender=student["gender"], group=student["group"] - ) + if isinstance(student_info_text, str) and "{" in student_info_text: + info_text = student_info_text.format( + id=student.get("id", ""), + gender=student.get("gender", ""), + group=student.get("group", ""), + ) + else: + info_text = student_info_text info_label = BodyLabel(info_text) if self._font_family: info_label.setFont(QFont(self._font_family, 9)) From 992cc29332c7c0b21573413fe4bbe0fb9e6e4a61 Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Sun, 16 Nov 2025 19:07:19 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=E8=A7=A3=E5=86=B3=E5=A4=9A=E7=BA=BF?= =?UTF-8?q?=E7=A8=8B=E7=AB=9E=E4=BA=89=E5=B8=A6=E6=9D=A5=E7=9A=84=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E9=87=8D=E5=8F=A0=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/view/another_window/remaining_list.py | 121 +++++++++++++++++++--- 1 file changed, 108 insertions(+), 13 deletions(-) diff --git a/app/view/another_window/remaining_list.py b/app/view/another_window/remaining_list.py index ed753697..166f6916 100644 --- a/app/view/another_window/remaining_list.py +++ b/app/view/another_window/remaining_list.py @@ -264,6 +264,8 @@ def init_ui(self): # 初始化卡片列表 self.cards = [] + # 跟踪已添加到布局的卡片 key,防止重复添加 + self._cards_set = set() # 缓存所有创建过的卡片,避免在布局切换时频繁创建/销毁 self._card_cache = {} @@ -473,9 +475,16 @@ class _BatchReporter(QObject): batch_ready = Signal(list) finished = Signal() + def __init__(self): + super().__init__() + self.cancel_requested = False + reporter = _BatchReporter() - reporter.batch_ready.connect(self._on_batch_ready) - reporter.finished.connect(self._on_render_finished) + # 使用闭包传递 reporter,让主线程槽可以区分不同任务的批次并忽略过期批次 + reporter.batch_ready.connect( + lambda batch, rep=reporter: self._on_batch_ready(rep, batch) + ) + reporter.finished.connect(lambda rep=reporter: self._on_render_finished(rep)) # 启动后台任务 task_students = list(self._pending_students) @@ -492,6 +501,12 @@ def __init__(self, students, batch_size, reporter, info_template): def run(self): try: while self.students: + # 检查取消请求,若已取消则尽快退出 + try: + if getattr(self.reporter, "cancel_requested", False): + break + except Exception: + pass batch = [] for _ in range(self.batch_size): if not self.students: @@ -520,6 +535,12 @@ def run(self): batch.append(s) # 发射信号到主线程,主线程负责创建 QWidget + # 在发射前再次检查取消标志,避免发送过期批次 + try: + if getattr(self.reporter, "cancel_requested", False): + break + except Exception: + pass try: self.reporter.batch_ready.emit(batch) except Exception: @@ -534,6 +555,16 @@ def run(self): except Exception: pass + # 请求取消之前正在运行的渲染任务(如果存在) + try: + if self._rendering and self._render_reporter is not None: + try: + self._render_reporter.cancel_requested = True + except Exception: + pass + except Exception: + pass + self._render_reporter = reporter task = StudentRenderTask( task_students, self._batch_size, reporter, self._student_info_text @@ -545,42 +576,101 @@ def _render_next_batch(self): # 该方法现在由后台任务通过 reporter 信号触发,已废弃 return - def _on_batch_ready(self, batch: list): - """主线程槽:接收一批学生数据并创建卡片加入布局""" + def _on_batch_ready(self, reporter, batch: list): + """主线程槽:接收一批学生数据并创建卡片加入布局 + + 参数: + reporter: 发出此批次的 reporter 对象,用于判断批次是否过期 + batch: 学生数据列表(可能包含预计算字段) + """ + # 如果 reporter 已请求取消,则忽略此批次 + try: + if getattr(reporter, "cancel_requested", False): + return + except Exception: + pass + if not batch: return - # 创建卡片并直接加入布局缓存 + # 创建卡片并直接加入布局缓存(避免重复添加) for student in batch: key = student.get("name") + if key in self._cards_set: + # 已存在,跳过 + continue + card = self._card_cache.get(key) if card is None: card = self.create_student_card(student) if card is not None: self._card_cache[key] = card + if card is not None: + # 确保卡片不在另一个父控件下 + try: + if card.parent() is not None and card.parent() is not self: + card.setParent(None) + except Exception: + pass + self.cards.append(card) + self._cards_set.add(key) - # 将新卡片添加到布局(不清空已有布局) + # 将新卡片添加到布局(只放置尚未加入布局的卡片) try: columns = self._calculate_columns( max(self.width(), self.sizeHint().width()) ) - start_index = len(self.cards) - len(batch) - for i, card in enumerate(self.cards[start_index:], start=start_index): + + for i, card in enumerate(list(self.cards)): + # 如果卡片已经在布局中则跳过 + try: + if self.grid_layout.indexOf(card) != -1: + continue + except Exception: + pass + row = i // columns col = i % columns - self.grid_layout.addWidget(card, row, col) - if not card.isVisible(): - card.show() + + # 如果目标格位已有其它控件,先移除避免重叠 + try: + existing_item = self.grid_layout.itemAtPosition(row, col) + if existing_item is not None: + existing_widget = existing_item.widget() + if existing_widget is not None and existing_widget is not card: + try: + self.grid_layout.removeWidget(existing_widget) + except Exception: + pass + try: + existing_widget.hide() + except Exception: + pass + except Exception: + pass + + try: + self.grid_layout.addWidget(card, row, col) + if not card.isVisible(): + card.show() + except Exception: + logger.exception("向网格添加卡片失败") for col in range(columns): self.grid_layout.setColumnStretch(col, 1) except Exception: logger.exception("增量渲染时布局更新失败") - def _on_render_finished(self): - """后台渲染完成后的槽""" + def _on_render_finished(self, reporter): + """后台渲染完成后的槽,接收 reporter 用于忽略过期任务""" + try: + if getattr(reporter, "cancel_requested", False): + return + except Exception: + pass + self._rendering = False self._pending_students = [] # 最后触发完整布局更新以修正位置 @@ -618,6 +708,11 @@ def _clear_grid_layout(self): except Exception: pass widget.hide() + # 清空已记录的已添加卡片集合 + try: + self._cards_set.clear() + except Exception: + pass def create_student_card(self, student: Dict[str, Any]) -> CardWidget: """创建学生卡片 From a7ee4ccaddef4e9f8e9b7a7fd7affd2feb96eeec Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Sun, 16 Nov 2025 19:24:39 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=E9=87=87=E5=8F=96=E6=9B=B4?= =?UTF-8?q?=E5=8A=A0=E4=BC=98=E7=A7=80=E7=9A=84=E7=95=8C=E9=9D=A2=E5=BB=B6?= =?UTF-8?q?=E8=BF=9F=E5=8A=A0=E8=BD=BD=E7=AD=96=E7=95=A5=E4=BB=A5=E8=8E=B7?= =?UTF-8?q?=E5=BE=97=E6=9B=B4=E5=A5=BD=E7=9A=84=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/page_building/page_template.py | 86 ++++++++++++----- .../custom_settings/page_management.py | 94 +++++++++++++++---- app/view/settings/settings.py | 20 +++- 3 files changed, 155 insertions(+), 45 deletions(-) diff --git a/app/page_building/page_template.py b/app/page_building/page_template.py index 3e854349..652a9243 100644 --- a/app/page_building/page_template.py +++ b/app/page_building/page_template.py @@ -177,7 +177,8 @@ def __init__(self, page_config: dict, parent: QFrame = None): self.page_config = page_config # 页面配置字典 self.ui_created = False - self.pages = {} # 存储页面组件 + self.pages = {} # 存储页面组件 (scroll areas) + self.page_infos = {} # 存储页面附加信息: display, layout, loaded self.current_page = None # 当前页面 self.base_path = "app.view.settings.list_management" # 默认基础路径 @@ -222,9 +223,19 @@ def add_pages(self): for page_name, display_name in self.page_config.items(): self.add_page(page_name, display_name) - # 如果有页面,设置第一个页面为当前页面 - if self.pages: - first_page_name = next(iter(self.pages)) + # 如果有页面,设置第一个页面为当前页面并仅加载第一个页面的内容 + if self.page_infos: + first_page_name = next(iter(self.page_infos)) + # 延迟一点点创建第一个页面的内容,避免阻塞 + QTimer.singleShot( + 0, + lambda n=first_page_name: self._load_page_content( + n, + self.page_infos[n]["display"], + self.page_infos[n]["scroll"], + self.page_infos[n]["layout"], + ), + ) self.switch_to_page(first_page_name) def add_page(self, page_name: str, display_name: str): @@ -279,14 +290,12 @@ def add_page(self, page_name: str, display_name: str): # 存储滑动区域引用 self.pages[page_name] = scroll_area - - # 延迟加载实际页面组件 - QTimer.singleShot( - 0, - lambda: self._load_page_content( - page_name, display_name, scroll_area, inner_layout - ), - ) + self.page_infos[page_name] = { + "display": display_name, + "scroll": scroll_area, + "layout": inner_layout, + "loaded": False, + } def _load_page_content( self, @@ -314,17 +323,26 @@ def _load_page_content( widget = content_widget_class(self) widget.setObjectName(page_name) - # 清除加载提示 - if inner_layout.count() > 0: - item = inner_layout.itemAt(0) - if item: - inner_layout.removeItem(item) - if item.widget(): - item.widget().deleteLater() + # 清除加载提示(使用安全的 takeAt 循环以避免 Qt C++ 对象提前删除问题) + try: + while inner_layout.count() > 0: + item = inner_layout.takeAt(0) + if not item: + break + w = item.widget() + if w is not None: + w.deleteLater() + except RuntimeError: + # 如果内部对象被底层 Qt 提前销毁,忽略并继续 + pass # 添加实际内容到内部布局 inner_layout.addWidget(widget) + # 标记为已加载 + if page_name in self.page_infos: + self.page_infos[page_name]["loaded"] = True + elapsed = time.perf_counter() - start logger.debug(f"加载页面组件 {page_name} 耗时: {elapsed:.3f}s") @@ -335,13 +353,17 @@ def _load_page_content( except (ImportError, AttributeError) as e: print(f"无法导入页面组件 {page_name}: {e}") - # 清除加载提示 - if inner_layout.count() > 0: - item = inner_layout.itemAt(0) - if item: - inner_layout.removeItem(item) - if item.widget(): - item.widget().deleteLater() + # 清除加载提示(安全地移除所有子项) + try: + while inner_layout.count() > 0: + item = inner_layout.takeAt(0) + if not item: + break + w = item.widget() + if w is not None: + w.deleteLater() + except RuntimeError: + pass # 创建错误页面 error_widget = QWidget() @@ -362,6 +384,10 @@ def _load_page_content( # 添加错误页面到内部布局 inner_layout.addWidget(error_widget) + # 标记为已加载(虽然是错误页面,但不再重复尝试) + if page_name in self.page_infos: + self.page_infos[page_name]["loaded"] = True + # 如果当前页面就是正在加载的页面,确保滑动区域是当前可见的 if self.current_page == page_name: self.stacked_widget.setCurrentWidget(scroll_area) @@ -369,6 +395,14 @@ def _load_page_content( def switch_to_page(self, page_name: str): """切换到指定页面""" if page_name in self.pages: + # 按需加载:如果尚未加载该页面的实际内容,则先加载 + info = self.page_infos.get(page_name) + if info and not info.get("loaded"): + # 调用加载函数(同步执行),传入存储的 inner_layout + self._load_page_content( + page_name, info["display"], info["scroll"], info["layout"] + ) + self.stacked_widget.setCurrentWidget(self.pages[page_name]) self.pivot.setCurrentItem(page_name) self.current_page = page_name diff --git a/app/view/settings/custom_settings/page_management.py b/app/view/settings/custom_settings/page_management.py index 395c1298..1c755ca0 100644 --- a/app/view/settings/custom_settings/page_management.py +++ b/app/view/settings/custom_settings/page_management.py @@ -65,31 +65,91 @@ def make_placeholder(attr_name: str): def _create_deferred(self, name: str): """按需创建延迟注册的子组件并替换占位容器""" + # 更严格的防护:在 factory 调用前后都检查父对象与占位状态 + factories = getattr(self, "_deferred_factories", {}) + if name not in factories: + return + # 尝试从 factories 中弹出 factory,若并发已移除则安全返回 try: - factories = getattr(self, "_deferred_factories", {}) - if name not in factories: - return factory = factories.pop(name) + except Exception: + return + + # 快速检查当前窗口对象是否还存在(避免在被销毁时创建) + if self is None or not hasattr(self, "vBoxLayout"): + return + + # 创建真实 widget 的过程可能在这段时间父对象被销毁,保护 factory 调用 + try: start = time.perf_counter() real_widget = factory() elapsed = time.perf_counter() - start - # 找到占位容器 - placeholder = getattr(self, name, None) - if placeholder is None: - # 没有占位则直接插入 + except RuntimeError as e: + logger.error(f"创建子组件 {name} 失败(父对象可能已销毁): {e}") + return + except Exception as e: + logger.error(f"创建子组件 {name} 未知错误: {e}") + return + + # 找到占位容器 + placeholder = getattr(self, name, None) + # 如果占位不存在或已被替换,则尝试安全插入到主 layout + if placeholder is None: + try: self.vBoxLayout.addWidget(real_widget) - else: - # 替换属性引用为真实 widget - # 保证 placeholder 有 layout - layout = placeholder.layout() - if layout is not None: - layout.addWidget(real_widget) - # 更新属性引用,方便后续直接访问 - setattr(self, name, real_widget) + except RuntimeError as e: + logger.error(f"将子组件 {name} 插入主布局失败(父控件已销毁): {e}") + return + setattr(self, name, real_widget) + logger.debug(f"延迟创建子组件 {name} 耗时: {elapsed:.3f}s") + return + # 如果占位还存在,优先尝试将真实 widget 添加到占位的 layout 中 + layout = None + try: + layout = placeholder.layout() + except Exception: + layout = None + + if layout is None: + try: + # 占位已经无 layout,尝试直接在主布局中替换位置 + # 找到占位在主布局中的索引并替换 + index = -1 + for i in range(self.vBoxLayout.count()): + item = self.vBoxLayout.itemAt(i) + if item and item.widget() is placeholder: + index = i + break + if index >= 0: + try: + # 移除占位并在同位置插入真实 widget + item = self.vBoxLayout.takeAt(index) + widget = item.widget() if item else None + if widget is not None: + widget.deleteLater() + self.vBoxLayout.insertWidget(index, real_widget) + except RuntimeError as e: + logger.error(f"替换占位 {name} 失败(父控件已销毁): {e}") + return + else: + # 未找到占位,回退到追加 + self.vBoxLayout.addWidget(real_widget) + except RuntimeError as e: + logger.error(f"将子组件 {name} 插入主布局失败(父控件已销毁): {e}") + return + setattr(self, name, real_widget) logger.debug(f"延迟创建子组件 {name} 耗时: {elapsed:.3f}s") - except Exception as e: - logger.error(f"创建子组件 {name} 失败: {e}") + return + + # 正常情况下,使用占位的 layout 添加 widget + try: + layout.addWidget(real_widget) + setattr(self, name, real_widget) + logger.debug(f"延迟创建子组件 {name} 耗时: {elapsed:.3f}s") + except RuntimeError as e: + logger.error(f"绑定子组件 {name} 到占位容器失败:父控件已销毁: {e}") + return class page_management_roll_call(GroupHeaderCardWidget): diff --git a/app/view/settings/settings.py b/app/view/settings/settings.py index d8dbb6dc..20c847e7 100644 --- a/app/view/settings/settings.py +++ b/app/view/settings/settings.py @@ -313,12 +313,28 @@ def _create_deferred_page(self, name: str): break if container is None: return + # 如果容器已经被销毁或没有 layout,则跳过 + if not container or not hasattr(container, "layout"): + return + layout = container.layout() + if layout is None: + return + try: real_page = factory() - container.layout().addWidget(real_page) - logger.debug(f"后台预热创建设置页面: {name}") + except RuntimeError as e: + logger.error(f"创建延迟页面 {name} 失败(父容器可能已销毁): {e}") + return except Exception as e: logger.error(f"创建延迟页面 {name} 失败: {e}") + return + + try: + layout.addWidget(real_page) + logger.debug(f"后台预热创建设置页面: {name}") + except RuntimeError as e: + logger.error(f"将延迟页面 {name} 插入容器失败(容器可能已销毁): {e}") + return except Exception as e: logger.error(f"_create_deferred_page 失败: {e}") From 6ae99476cc9117b7b3da666e7da7c81a118feac4 Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Sun, 16 Nov 2025 19:47:58 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=E5=B0=86=E4=B8=8D=E5=B8=A6pivot?= =?UTF-8?q?=E7=9A=84=E9=A1=B5=E9=9D=A2=E4=BC=98=E5=85=88=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E4=BB=A5=E4=BF=9D=E9=9A=9C=E7=94=A8=E6=88=B7=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/view/settings/settings.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/app/view/settings/settings.py b/app/view/settings/settings.py index 20c847e7..77c575bb 100644 --- a/app/view/settings/settings.py +++ b/app/view/settings/settings.py @@ -131,6 +131,8 @@ def createSubInterface(self): # 存储占位 -> factory 映射 self._deferred_factories = {} + # 存储工厂的元信息(例如是否为 pivot 类型),用于预热策略调整 + self._deferred_factories_meta = {} def make_placeholder(name: str): w = QWidget() @@ -144,6 +146,7 @@ def make_placeholder(name: str): self._deferred_factories["homeInterface"] = ( lambda parent=self.homeInterface: settings_window_page.home_page(parent) ) + self._deferred_factories_meta["homeInterface"] = {"is_pivot": False} self.basicSettingsInterface = make_placeholder("basicSettingsInterface") self._deferred_factories["basicSettingsInterface"] = ( @@ -151,6 +154,7 @@ def make_placeholder(name: str): parent ) ) + self._deferred_factories_meta["basicSettingsInterface"] = {"is_pivot": False} self.listManagementInterface = make_placeholder("listManagementInterface") self._deferred_factories["listManagementInterface"] = ( @@ -158,6 +162,7 @@ def make_placeholder(name: str): parent ) ) + self._deferred_factories_meta["listManagementInterface"] = {"is_pivot": True} self.extractionSettingsInterface = make_placeholder( "extractionSettingsInterface" @@ -167,6 +172,9 @@ def make_placeholder(name: str): parent ) ) + self._deferred_factories_meta["extractionSettingsInterface"] = { + "is_pivot": True + } self.notificationSettingsInterface = make_placeholder( "notificationSettingsInterface" @@ -176,6 +184,9 @@ def make_placeholder(name: str): parent ) ) + self._deferred_factories_meta["notificationSettingsInterface"] = { + "is_pivot": True + } self.safetySettingsInterface = make_placeholder("safetySettingsInterface") self._deferred_factories["safetySettingsInterface"] = ( @@ -183,6 +194,7 @@ def make_placeholder(name: str): parent ) ) + self._deferred_factories_meta["safetySettingsInterface"] = {"is_pivot": True} self.customSettingsInterface = make_placeholder("customSettingsInterface") self._deferred_factories["customSettingsInterface"] = ( @@ -190,6 +202,7 @@ def make_placeholder(name: str): parent ) ) + self._deferred_factories_meta["customSettingsInterface"] = {"is_pivot": True} self.voiceSettingsInterface = make_placeholder("voiceSettingsInterface") self._deferred_factories["voiceSettingsInterface"] = ( @@ -197,6 +210,7 @@ def make_placeholder(name: str): parent ) ) + self._deferred_factories_meta["voiceSettingsInterface"] = {"is_pivot": True} self.historyInterface = make_placeholder("historyInterface") self._deferred_factories["historyInterface"] = ( @@ -204,6 +218,7 @@ def make_placeholder(name: str): parent ) ) + self._deferred_factories_meta["historyInterface"] = {"is_pivot": True} self.moreSettingsInterface = make_placeholder("moreSettingsInterface") self._deferred_factories["moreSettingsInterface"] = ( @@ -211,11 +226,13 @@ def make_placeholder(name: str): parent ) ) + self._deferred_factories_meta["moreSettingsInterface"] = {"is_pivot": True} self.aboutInterface = make_placeholder("aboutInterface") self._deferred_factories["aboutInterface"] = ( lambda parent=self.aboutInterface: settings_window_page.about_page(parent) ) + self._deferred_factories_meta["aboutInterface"] = {"is_pivot": False} # 把占位注册到导航,但不要在此刻实例化真实页面 self.initNavigation() @@ -272,8 +289,19 @@ def _background_warmup_pages( names = list(getattr(self, "_deferred_factories", {}).keys()) if not names: return + # 优先预热非 pivot(单页面)项,再预热 pivot 项,保持原有非 pivot 的异步加载策略 + try: + meta = getattr(self, "_deferred_factories_meta", {}) + non_pivot = [ + n for n in names if not meta.get(n, {}).get("is_pivot", False) + ] + pivot = [n for n in names if meta.get(n, {}).get("is_pivot", False)] + ordered = non_pivot + pivot + except Exception: + ordered = names + # 仅预热有限数量的页面,避免一次性占用主线程 - names_to_preload = names[:max_preload] + names_to_preload = ordered[:max_preload] logger.debug( f"后台预热将创建 {len(names_to_preload)} / {len(names)} 个页面" )