diff --git a/.gitignore b/.gitignore index 10549a19..cbea4b57 100644 --- a/.gitignore +++ b/.gitignore @@ -184,6 +184,7 @@ cython_debug/ # VSCode .vscode/settings.json +.vscode/launch.json /.trae /app/config /app/config diff --git a/app/Language/modules/history.py b/app/Language/modules/history.py index 44ddae27..4aeef9b4 100644 --- a/app/Language/modules/history.py +++ b/app/Language/modules/history.py @@ -226,6 +226,18 @@ "name": "公平抽取", "description": "公平抽取模式", }, + "export": { + "name": "导出记录", + "description": "将当前表格数据导出为文件", + }, + "export_button": { + "name": "导出", + "description": "导出按钮文本", + }, + "export_default_filename": { + "name": "点名记录", + "description": "导出文件默认文件名中的描述文字", + }, }, "EN_US": { "title": { @@ -320,6 +332,18 @@ "name": "Fair pick", "description": "Fair picking mode", }, + "export": { + "name": "Export records", + "description": "Export current table data to a file", + }, + "export_button": { + "name": "Export", + "description": "Export button text", + }, + "export_default_filename": { + "name": "Picking records", + "description": "Description text in the default export filename", + }, "select_weight": { "name": "Show weight", "description": "Whether to show weight in table", @@ -419,6 +443,18 @@ "name": "公平抽出", "description": "公平抽出モード", }, + "export": { + "name": "記録をエクスポート", + "description": "現在のテーブルデータをファイルにエクスポート", + }, + "export_button": { + "name": "エクスポート", + "description": "エクスポートボタンテキスト", + }, + "export_default_filename": { + "name": "点呼記録", + "description": "エクスポートファイルのデフォルトファイル名の説明文", + }, "select_weight": { "name": "重みを表示", "description": "テーブルに重みを表示するかどうか", @@ -460,6 +496,18 @@ "name": ["抽奖时间", "抽取数量", "课程", "权重"], "description": "抽奖历史记录表格列标题(单次记录)", }, + "export": { + "name": "导出记录", + "description": "将当前表格数据导出为文件", + }, + "export_button": { + "name": "导出", + "description": "导出按钮文本", + }, + "export_default_filename": { + "name": "抽奖记录", + "description": "导出文件默认文件名中的描述文字", + }, }, "EN_US": { "title": { @@ -508,6 +556,18 @@ }, "description": "Lottery history table title column header weight (individual)", }, + "export": { + "name": "Export records", + "description": "Export current table data to a file", + }, + "export_button": { + "name": "Export", + "description": "Export button text", + }, + "export_default_filename": { + "name": "Lottery records", + "description": "Description text in the default export filename", + }, }, "JA_JP": { "title": { @@ -556,5 +616,17 @@ }, "description": "抽選履歴テーブルの列タイトル(単一記録)", }, + "export": { + "name": "記録をエクスポート", + "description": "現在のテーブルデータをファイルにエクスポート", + }, + "export_button": { + "name": "エクスポート", + "description": "エクスポートボタンテキスト", + }, + "export_default_filename": { + "name": "抽選記録", + "description": "エクスポートファイルのデフォルトファイル名の説明文", + }, }, } diff --git a/app/Language/modules/list_management.py b/app/Language/modules/list_management.py index 36ea586f..02365872 100644 --- a/app/Language/modules/list_management.py +++ b/app/Language/modules/list_management.py @@ -122,6 +122,14 @@ "name": "选择班级", "description": "选择要显示的点名班级", }, + "search": { + "name": "搜索名单", + "description": "在名单中搜索学生", + }, + "search_placeholder": { + "name": "输入学号、姓名、性别、小组或标签进行筛选", + "description": "搜索框占位符", + }, "HeaderLabels": { "name": ["存在", "学号", "姓名", "性别", "小组", "标签"], "description": "点名表格的列标题", @@ -151,6 +159,14 @@ "name": "Select class", "description": "Select the class to show", }, + "search": { + "name": "Search list", + "description": "Search students in the list", + }, + "search_placeholder": { + "name": "Filter by ID, name, gender, group or tags", + "description": "Search box placeholder", + }, "HeaderLabels": { "name": { "0": "Exist", @@ -190,6 +206,14 @@ "name": "クラスを選択", "description": "表示する点呼クラスを選択", }, + "search": { + "name": "リスト検索", + "description": "リスト内の学生を検索", + }, + "search_placeholder": { + "name": "学籍番号、氏名、性別、グループ、タグで絞り込み", + "description": "検索ボックスのプレースホルダー", + }, "HeaderLabels": { "name": ["存在", "学籍番号", "氏名", "性別", "グループ", "タグ"], "description": "点呼テーブルの列タイトル", @@ -311,6 +335,14 @@ "name": "选择奖池", "description": "选择要显示的抽奖奖池", }, + "search": { + "name": "搜索名单", + "description": "在名单中搜索奖品", + }, + "search_placeholder": { + "name": "输入序号、奖品、权重、标签或数量进行筛选", + "description": "搜索框占位符", + }, "HeaderLabels": { "name": ["存在", "序号", "奖品", "权重", "标签", "数量"], "description": "抽奖表格的列标题", @@ -340,6 +372,14 @@ "name": "Select pool", "description": "Select the pool to show", }, + "search": { + "name": "Search list", + "description": "Search prizes in the list", + }, + "search_placeholder": { + "name": "Filter by serial, prize, weight, tags or count", + "description": "Search box placeholder", + }, "HeaderLabels": { "name": { "0": "Exist", @@ -379,6 +419,14 @@ "name": "賞プールを選択", "description": "表示する抽選賞プールを選択", }, + "search": { + "name": "リスト検索", + "description": "リスト内の賞品を検索", + }, + "search_placeholder": { + "name": "番号、賞品、重み、タグ、数量で絞り込み", + "description": "検索ボックスのプレースホルダー", + }, "HeaderLabels": { "name": ["存在", "番号", "賞品", "重み", "タグ", "数量"], "description": "抽選テーブルの列タイトル", @@ -734,6 +782,184 @@ }, }, }, + "JA_JP": { + "roll_call": { + "class_name_setting": { + "title": { + "name": "クラス名設定", + "description": "クラス名設定通知タイトル", + }, + "content": { + "name": "クラス名設定ウィンドウを開きました", + "description": "クラス名設定通知内容", + }, + }, + "import_student_name": { + "title": { + "name": "学生リストインポート", + "description": "学生リストインポート通知タイトル", + }, + "content": { + "name": "学生リストインポートウィンドウを開きました", + "description": "学生リストインポート通知内容", + }, + }, + "name_setting": { + "title": { + "name": "氏名設定", + "description": "氏名設定通知タイトル", + }, + "content": { + "name": "氏名設定ウィンドウを開きました", + "description": "氏名設定通知内容", + }, + }, + "gender_setting": { + "title": { + "name": "性別設定", + "description": "性別設定通知タイトル", + }, + "content": { + "name": "性別設定ウィンドウを開きました", + "description": "性別設定通知内容", + }, + }, + "group_setting": { + "title": { + "name": "グループ設定", + "description": "グループ設定通知タイトル", + }, + "content": { + "name": "グループ設定ウィンドウを開きました", + "description": "グループ設定通知内容", + }, + }, + "tag_setting": { + "title": { + "name": "タグ設定", + "description": "タグ設定通知タイトル", + }, + "content": { + "name": "タグ設定ウィンドウを開きました", + "description": "タグ設定通知内容", + }, + }, + "export": { + "title": { + "success": { + "name": "エクスポート成功", + "description": "エクスポート成功通知タイトル", + }, + "failure": { + "name": "エクスポート失敗", + "description": "エクスポート失敗通知タイトル", + }, + }, + "content": { + "success": { + "name": "学生リストをエクスポートしました: {path}", + "description": "エクスポート成功通知内容", + }, + "failure": { + "name": "エクスポートするクラスを先に選択してください", + "description": "エクスポート失敗通知内容(クラス未選択)", + }, + "error": { + "name": "{message}", + "description": "エクスポートエラー通知内容", + }, + }, + }, + }, + "lottery": { + "pool_name_setting": { + "title": { + "name": "賞プール名設定", + "description": "賞プール名設定通知タイトル", + }, + "content": { + "name": "賞プール名設定ウィンドウを開きました", + "description": "賞プール名設定通知内容", + }, + }, + "import_prize_name": { + "title": { + "name": "賞品リストインポート", + "description": "賞品リストインポート通知タイトル", + }, + "content": { + "name": "賞品リストインポートウィンドウを開きました", + "description": "賞品リストインポート通知内容", + }, + }, + "prize_setting": { + "title": { + "name": "賞品設定", + "description": "賞品設定通知タイトル", + }, + "content": { + "name": "賞品設定ウィンドウを開きました", + "description": "賞品設定通知内容", + }, + }, + "prize_weight_setting": { + "title": { + "name": "賞品重み設定", + "description": "賞品重み設定通知タイトル", + }, + "content": { + "name": "賞品重み設定ウィンドウを開きました", + "description": "賞品重み設定通知内容", + }, + }, + "tag_setting": { + "title": { + "name": "タグ設定", + "description": "タグ設定通知タイトル", + }, + "content": { + "name": "タグ設定ウィンドウを開きました", + "description": "タグ設定通知内容", + }, + }, + "prize_count_setting": { + "title": { + "name": "賞品数量設定", + "description": "賞品数量設定通知タイトル", + }, + "content": { + "name": "賞品数量設定ウィンドウを開きました", + "description": "賞品数量設定通知内容", + }, + }, + "export": { + "title": { + "success": { + "name": "エクスポート成功", + "description": "エクスポート成功通知タイトル", + }, + "failure": { + "name": "エクスポート失敗", + "description": "エクスポート失敗通知タイトル", + }, + }, + "content": { + "success": { + "name": "賞品リストをエクスポートしました: {path}", + "description": "エクスポート成功通知内容", + }, + "failure": { + "name": "エクスポートする賞プールを先に選択してください", + "description": "エクスポート失敗通知内容(賞プール未選択)", + }, + "error": { + "name": "{message}", + "description": "エクスポートエラー通知内容", + }, + }, + }, + }, + }, } # QFileDialog 文本配置 @@ -749,7 +975,17 @@ "name": "Excel 文件 (*.xlsx);;CSV 文件 (*.csv);;TXT 文件(仅姓名) (*.txt)", "description": "保存学生名单对话框过滤器", }, - } + }, + "export_history": { + "caption": { + "name": "导出点名记录", + "description": "导出点名记录对话框标题", + }, + "filter": { + "name": "Excel 文件 (*.xlsx);;CSV 文件 (*.csv);;TXT 文件 (*.txt)", + "description": "导出点名记录对话框过滤器", + }, + }, }, "lottery": { "export_prize_name": { @@ -761,7 +997,17 @@ "name": "Excel 文件 (*.xlsx);;CSV 文件 (*.csv);;TXT 文件(仅奖品名) (*.txt)", "description": "保存奖品名单对话框过滤器", }, - } + }, + "export_history": { + "caption": { + "name": "导出抽奖记录", + "description": "导出抽奖记录对话框标题", + }, + "filter": { + "name": "Excel 文件 (*.xlsx);;CSV 文件 (*.csv);;TXT 文件 (*.txt)", + "description": "导出抽奖记录对话框过滤器", + }, + }, }, }, "EN_US": { @@ -775,7 +1021,17 @@ "name": "Excel files (*.xlsx);;CSV files (*.csv);;TXT files (name only) (*.txt)", "description": "Save student list dialog filter", }, - } + }, + "export_history": { + "caption": { + "name": "Export picking records", + "description": "Export picking records dialog title", + }, + "filter": { + "name": "Excel files (*.xlsx);;CSV files (*.csv);;TXT files (*.txt)", + "description": "Export picking records dialog filter", + }, + }, }, "lottery": { "export_prize_name": { @@ -787,7 +1043,17 @@ "name": "Excel files (*.xlsx);;CSV files (*.csv);;TXT files (only prizes) (*.txt)", "description": "Save prize list dialog filter", }, - } + }, + "export_history": { + "caption": { + "name": "Export lottery records", + "description": "Export lottery records dialog title", + }, + "filter": { + "name": "Excel files (*.xlsx);;CSV files (*.csv);;TXT files (*.txt)", + "description": "Export lottery records dialog filter", + }, + }, }, }, "JA_JP": { @@ -801,7 +1067,17 @@ "name": "Excelファイル (*.xlsx);;CSVファイル (*.csv);;TXTファイル(氏名のみ) (*.txt)", "description": "学生リスト保存ダイアログフィルター", }, - } + }, + "export_history": { + "caption": { + "name": "点呼記録をエクスポート", + "description": "点呼記録エクスポートダイアログタイトル", + }, + "filter": { + "name": "Excelファイル (*.xlsx);;CSVファイル (*.csv);;TXTファイル (*.txt)", + "description": "点呼記録エクスポートダイアログフィルター", + }, + }, }, "lottery": { "export_prize_name": { @@ -813,7 +1089,17 @@ "name": "Excelファイル (*.xlsx);;CSVファイル (*.csv);;TXTファイル(賞品名のみ) (*.txt)", "description": "賞品リスト保存ダイアログフィルター", }, - } + }, + "export_history": { + "caption": { + "name": "抽選記録をエクスポート", + "description": "抽選記録エクスポートダイアログタイトル", + }, + "filter": { + "name": "Excelファイル (*.xlsx);;CSVファイル (*.csv);;TXTファイル (*.txt)", + "description": "抽選記録エクスポートダイアログフィルター", + }, + }, }, }, } diff --git a/app/view/settings/history/export_utils.py b/app/view/settings/history/export_utils.py new file mode 100644 index 00000000..29c0688f --- /dev/null +++ b/app/view/settings/history/export_utils.py @@ -0,0 +1,406 @@ +import csv + +from loguru import logger +from PySide6.QtWidgets import QFileDialog + +from app.tools.config import NotificationConfig, NotificationType, show_notification +from app.tools.path_utils import get_path +from app.Language.obtain_language import ( + get_any_position_value_async, + get_content_name_async, +) +from app.common.history.history_reader import ( + get_roll_call_student_list, + get_roll_call_history_data, + filter_roll_call_history_by_subject, + get_roll_call_students_data, + get_roll_call_session_data, + get_roll_call_student_stats_data, + check_class_has_gender_or_group, + get_lottery_pool_list, + get_lottery_history_data, + get_lottery_prizes_data, + get_lottery_session_data, + get_lottery_prize_stats_data, +) +from app.common.history.weight_utils import ( + calculate_weight, + format_weight_for_display, +) + + +def _get_name_column_index(current_mode: int): + if current_mode == 0: + return 1 + elif current_mode == 1: + return 2 + return None + + +def _build_roll_call_export_data( + current_name: str, + current_mode: int, + current_subject: str, + current_student_name: str, +): + headers = [] + rows = [] + + cleaned_students = get_roll_call_student_list(current_name) + history_data = get_roll_call_history_data(current_name) + + if current_subject: + history_data = filter_roll_call_history_by_subject( + history_data, current_subject + ) + + has_gender, has_group = check_class_has_gender_or_group(current_name) + has_class_record = False + + if current_mode == 0: + students_data = get_roll_call_students_data( + cleaned_students, history_data, current_subject + ) + students_weight_data = calculate_weight( + students_data, current_name, current_subject + ) + format_weight, _, _ = format_weight_for_display( + students_weight_data, "next_weight" + ) + + headers = ["ID", "Name"] + if has_gender: + headers.append("Gender") + if has_group: + headers.append("Group") + headers.append("Count") + headers.append("Weight") + + for i, student in enumerate(students_data): + row = [ + student.get("id", ""), + student.get("name", ""), + ] + if has_gender: + row.append(student.get("gender", "")) + if has_group: + row.append(student.get("group", "")) + row.append( + str(student.get("total_count_str", student.get("total_count", 0))) + ) + if i < len(students_weight_data): + row.append( + str(format_weight(students_weight_data[i].get("next_weight", ""))) + ) + else: + row.append("") + rows.append(row) + + elif current_mode == 1: + students_data = get_roll_call_session_data( + cleaned_students, history_data, current_subject + ) + has_class_record = any( + student.get("class_name", "") for student in students_data + ) + format_weight, _, _ = format_weight_for_display(students_data, "weight") + + students_data.sort(key=lambda x: x.get("draw_time", ""), reverse=True) + + headers = ["Time", "ID", "Name"] + if has_gender: + headers.append("Gender") + if has_group: + headers.append("Group") + if has_class_record: + headers.append("Subject") + headers.append("Weight") + + for student in students_data: + row = [ + student.get("draw_time", ""), + student.get("id", ""), + student.get("name", ""), + ] + if has_gender: + gender = student.get("gender", "") + row.append(str(gender) if gender else "") + if has_group: + group = student.get("group", "") + row.append(str(group) if group else "") + if has_class_record: + class_name = student.get("class_name", "") + row.append(str(class_name) if class_name else "") + row.append(str(format_weight(student.get("weight", "")))) + rows.append(row) + + else: + students_data = get_roll_call_student_stats_data( + cleaned_students, history_data, current_student_name, current_subject + ) + has_class_record = any( + student.get("class_name", "") for student in students_data + ) + format_weight, _, _ = format_weight_for_display(students_data, "weight") + + students_data.sort(key=lambda x: x.get("draw_time", ""), reverse=True) + + headers = ["Time", "Method", "Count"] + if has_gender: + headers.append("Gender") + if has_group: + headers.append("Group") + if has_class_record: + headers.append("Subject") + headers.append("Weight") + + for student in students_data: + row = [ + student.get("draw_time", ""), + str(student.get("draw_method", "")), + str(student.get("draw_people_numbers", 0)), + ] + if has_gender: + draw_gender = student.get("draw_gender", "") + row.append(draw_gender if draw_gender else "") + if has_group: + draw_group = student.get("draw_group", "") + row.append(draw_group if draw_group else "") + if has_class_record: + class_name = student.get("class_name", "") + row.append(str(class_name) if class_name else "") + row.append(str(format_weight(student.get("weight", "")))) + rows.append(row) + + return headers, rows + + +def _build_lottery_export_data( + current_name: str, + current_mode: int, + current_subject: str, + current_lottery_name: str, +): + headers = [] + rows = [] + + cleaned_lotterys = get_lottery_pool_list(current_name) + history_data = get_lottery_history_data(current_name) + + has_class_record = False + + if current_mode == 0: + lotterys_data = get_lottery_prizes_data(cleaned_lotterys, history_data) + format_weight, _, _ = format_weight_for_display(lotterys_data, "weight") + + headers = ["ID", "Name", "Count", "Weight"] + + for lottery in lotterys_data: + row = [ + lottery.get("id", ""), + lottery.get("name", ""), + str(lottery.get("total_count_str", lottery.get("total_count", 0))), + str(format_weight(lottery.get("weight", 0))), + ] + rows.append(row) + + elif current_mode == 1: + lotterys_data = get_lottery_session_data( + cleaned_lotterys, history_data, current_subject + ) + has_class_record = any( + lottery.get("class_name", "") for lottery in lotterys_data + ) + format_weight, _, _ = format_weight_for_display(lotterys_data, "weight") + + lotterys_data.sort(key=lambda x: x.get("draw_time", ""), reverse=True) + + headers = ["Time", "ID", "Name"] + if has_class_record: + headers.append("Subject") + headers.append("Weight") + + for lottery in lotterys_data: + row = [ + lottery.get("draw_time", ""), + lottery.get("id", ""), + lottery.get("name", ""), + ] + if has_class_record: + class_name = lottery.get("class_name", "") + row.append(str(class_name) if class_name else "") + row.append(str(format_weight(lottery.get("weight", 0)))) + rows.append(row) + + else: + lotterys_data = get_lottery_prize_stats_data( + cleaned_lotterys, history_data, current_lottery_name, current_subject + ) + has_class_record = any( + lottery.get("class_name", "") for lottery in lotterys_data + ) + format_weight, _, _ = format_weight_for_display(lotterys_data, "weight") + + lotterys_data.sort(key=lambda x: x.get("draw_time", ""), reverse=True) + + headers = ["Time", "Count"] + if has_class_record: + headers.append("Subject") + headers.append("Weight") + + for lottery in lotterys_data: + row = [ + lottery.get("draw_time", ""), + str(lottery.get("draw_lottery_numbers", 0)), + ] + if has_class_record: + class_name = lottery.get("class_name", "") + row.append(str(class_name) if class_name else "") + row.append(str(format_weight(lottery.get("weight", "")))) + rows.append(row) + + return headers, rows + + +def _write_excel_stream(target_path, headers, rows): + import pandas as pd + + for chunk_start in range(0, len(rows), 1000): + chunk = rows[chunk_start : chunk_start + 1000] + if chunk_start == 0: + df = pd.DataFrame(chunk, columns=headers) + df.to_excel( + str(target_path), + index=False, + engine="openpyxl", + startrow=0, + ) + else: + from openpyxl import load_workbook + + wb = load_workbook(str(target_path)) + ws = wb.active + for row_data in chunk: + ws.append(row_data) + wb.save(str(target_path)) + + +def _write_csv_stream(target_path, headers, rows): + with open(str(target_path), "w", encoding="utf-8-sig", newline="") as f: + writer = csv.writer(f) + writer.writerow(headers) + for row in rows: + writer.writerow(row) + + +def _write_txt_stream(target_path, headers, rows, current_mode: int): + name_col_idx = _get_name_column_index(current_mode) + with open(str(target_path), "w", encoding="utf-8") as f: + if name_col_idx is not None: + for row in rows: + if name_col_idx < len(row): + f.write(f"{row[name_col_idx]}\n") + else: + f.write("\n") + else: + for row in rows: + f.write("\t".join(str(v) for v in row) + "\n") + + +def export_history_table_data( + table_widget, + current_mode: int, + i18n_domain: str, + current_name: str, + parent_widget=None, + current_subject: str = "", + current_item_name: str = "", +): + file_path, selected_filter = QFileDialog.getSaveFileName( + parent_widget, + get_any_position_value_async( + "qfiledialog", i18n_domain, "export_history", "caption", "name" + ), + f"{current_name}_{get_content_name_async(f'{i18n_domain}_history_table', 'export_default_filename') or ''}-SecRandom", + get_any_position_value_async( + "qfiledialog", i18n_domain, "export_history", "filter", "name" + ), + ) + + if not file_path: + return + + export_type = ( + "excel" + if ".xlsx" in selected_filter + else "csv" + if ".csv" in selected_filter + else "txt" + ) + + if export_type == "excel" and not file_path.endswith(".xlsx"): + file_path += ".xlsx" + elif export_type == "csv" and not file_path.endswith(".csv"): + file_path += ".csv" + elif export_type == "txt" and not file_path.endswith(".txt"): + file_path += ".txt" + + try: + target_path = get_path(file_path) + target_path.parent.mkdir(parents=True, exist_ok=True) + + if i18n_domain == "roll_call": + headers, rows = _build_roll_call_export_data( + current_name, + current_mode, + current_subject, + current_item_name, + ) + else: + headers, rows = _build_lottery_export_data( + current_name, + current_mode, + current_subject, + current_item_name, + ) + + if not rows: + return + + if export_type == "excel": + _write_excel_stream(target_path, headers, rows) + elif export_type == "csv": + _write_csv_stream(target_path, headers, rows) + else: + _write_txt_stream(target_path, headers, rows, current_mode) + + config = NotificationConfig( + title=get_any_position_value_async( + "notification", i18n_domain, "export", "title", "success", "name" + ) or "", + content=( + get_any_position_value_async( + "notification", i18n_domain, "export", "content", "success", "name" + ) + or "" + ).format(path=file_path), + duration=3000, + ) + show_notification(NotificationType.SUCCESS, config, parent=parent_widget) + logger.info(f"历史记录导出成功: {file_path}") + + except Exception as e: + logger.error(f"导出历史记录失败: {e}") + config = NotificationConfig( + title=get_any_position_value_async( + "notification", i18n_domain, "export", "title", "failure", "name" + ) or "", + content=( + get_any_position_value_async( + "notification", i18n_domain, "export", "content", "error", "name" + ) + or "" + ).format(message=str(e)), + duration=3000, + ) + show_notification(NotificationType.ERROR, config, parent=parent_widget) diff --git a/app/view/settings/history/lottery_history_table.py b/app/view/settings/history/lottery_history_table.py index 0037c0c2..80513390 100644 --- a/app/view/settings/history/lottery_history_table.py +++ b/app/view/settings/history/lottery_history_table.py @@ -13,6 +13,7 @@ from app.tools.variable import * from app.tools.path_utils import * from app.tools.personalised import * +from app.tools.config import * from app.tools.settings_default import * from app.tools.settings_access import * from app.Language.obtain_language import * @@ -24,6 +25,7 @@ get_lottery_session_data, get_lottery_prize_stats_data, ) +from .export_utils import export_history_table_data # ================================================== @@ -141,6 +143,20 @@ def create_pool_selection(self): self.mode_subject_widget, ) + # 创建导出按钮 + self.export_button = PushButton( + get_content_name_async("lottery_history_table", "export_button") + ) + self.export_button.setFixedWidth(120) + self.export_button.clicked.connect(self.export_history_data) + + self.addGroup( + get_theme_icon("ic_fluent_document_arrow_down_20_filled"), + get_content_name_async("lottery_history_table", "export"), + get_content_description_async("lottery_history_table", "export"), + self.export_button, + ) + def create_table(self): """创建表格区域""" # 创建表格 @@ -709,6 +725,22 @@ def on_pool_changed(self, index): # 更新当前奖池名称 self.current_pool_name = self.pool_comboBox.currentText() + # 更新模式下拉框中的奖品名称列表 + if hasattr(self, "mode_comboBox"): + current_mode = self.mode_comboBox.currentIndex() + self.mode_comboBox.blockSignals(True) + self.mode_comboBox.clear() + self.all_names = get_all_names("lottery", self.current_pool_name) + self.mode_comboBox.addItems( + get_content_combo_name_async("lottery_history_table", "select_mode") + + self.all_names + ) + if current_mode < self.mode_comboBox.count(): + self.mode_comboBox.setCurrentIndex(current_mode) + else: + self.mode_comboBox.setCurrentIndex(0) + self.mode_comboBox.blockSignals(False) + # 刷新表格数据 self.refresh_data() @@ -977,3 +1009,24 @@ def _update_subject_list(self): except Exception as e: logger.exception(f"更新课程列表失败: {e}") self.available_subjects = [] + + def export_history_data(self): + if not self.current_pool_name: + return + + current_item_name = "" + if self.current_mode >= 2: + if hasattr(self, "mode_comboBox"): + current_item_name = self.mode_comboBox.currentText() + elif hasattr(self, "current_lottery_name"): + current_item_name = self.current_lottery_name + + export_history_table_data( + table_widget=self.table, + current_mode=self.current_mode, + i18n_domain="lottery", + current_name=self.current_pool_name, + parent_widget=self, + current_subject=self.current_subject, + current_item_name=current_item_name, + ) diff --git a/app/view/settings/history/roll_call_history_table.py b/app/view/settings/history/roll_call_history_table.py index 63cbcde4..1a95538c 100644 --- a/app/view/settings/history/roll_call_history_table.py +++ b/app/view/settings/history/roll_call_history_table.py @@ -13,6 +13,7 @@ from app.tools.variable import * from app.tools.path_utils import * from app.tools.personalised import * +from app.tools.config import * from app.tools.settings_default import * from app.tools.settings_access import * from app.Language.obtain_language import * @@ -26,6 +27,7 @@ get_roll_call_student_stats_data, check_class_has_gender_or_group, ) +from .export_utils import export_history_table_data # ================================================== @@ -147,6 +149,20 @@ def create_class_selection(self): self.mode_subject_widget, ) + # 创建导出按钮 + self.export_button = PushButton( + get_content_name_async("roll_call_history_table", "export_button") + ) + self.export_button.setFixedWidth(120) + self.export_button.clicked.connect(self.export_history_data) + + self.addGroup( + get_theme_icon("ic_fluent_document_arrow_down_20_filled"), + get_content_name_async("roll_call_history_table", "export"), + get_content_description_async("roll_call_history_table", "export"), + self.export_button, + ) + def create_table(self): """创建表格区域""" # 创建表格 @@ -842,6 +858,22 @@ def on_class_changed(self, index): # 更新当前班级名称 self.current_class_name = self.class_comboBox.currentText() + # 更新模式下拉框中的学生名称列表 + if hasattr(self, "mode_comboBox"): + current_mode = self.mode_comboBox.currentIndex() + self.mode_comboBox.blockSignals(True) + self.mode_comboBox.clear() + self.all_names = get_all_names("roll_call", self.current_class_name) + self.mode_comboBox.addItems( + get_content_combo_name_async("roll_call_history_table", "select_mode") + + self.all_names + ) + if current_mode < self.mode_comboBox.count(): + self.mode_comboBox.setCurrentIndex(current_mode) + else: + self.mode_comboBox.setCurrentIndex(0) + self.mode_comboBox.blockSignals(False) + # 更新课程列表 self._update_subject_list() @@ -1143,3 +1175,24 @@ def update_table_headers(self): self.table.setColumnCount(len(headers)) self.table.setHorizontalHeaderLabels(headers) + + def export_history_data(self): + if not self.current_class_name: + return + + current_item_name = "" + if self.current_mode >= 2: + if hasattr(self, "mode_comboBox"): + current_item_name = self.mode_comboBox.currentText() + elif hasattr(self, "current_student_name"): + current_item_name = self.current_student_name + + export_history_table_data( + table_widget=self.table, + current_mode=self.current_mode, + i18n_domain="roll_call", + current_name=self.current_class_name, + parent_widget=self, + current_subject=self.current_subject, + current_item_name=current_item_name, + ) diff --git a/app/view/settings/list_management/lottery_table.py b/app/view/settings/list_management/lottery_table.py index 100d9764..9b2d9be2 100644 --- a/app/view/settings/list_management/lottery_table.py +++ b/app/view/settings/list_management/lottery_table.py @@ -35,9 +35,17 @@ def __init__(self, parent=None): self.parent = parent self.setTitle(get_content_name_async("lottery_table", "title")) self.setBorderRadius(8) + self._search_timer = QTimer(self) + self._search_timer.setSingleShot(True) + self._search_timer.setInterval(300) + self._search_timer.timeout.connect(self._apply_search_filter) + # 创建抽奖名单选择区域 QTimer.singleShot(APPLY_DELAY, self.create_lottery_selection) + # 创建搜索栏 + QTimer.singleShot(APPLY_DELAY, self.create_search_bar) + # 创建表格区域 QTimer.singleShot(APPLY_DELAY, self.create_table) @@ -86,6 +94,48 @@ def create_lottery_selection(self): self.lottery_comboBox, ) + def create_search_bar(self): + """创建搜索栏""" + self.search_line_edit = SearchLineEdit() + self.search_line_edit.setPlaceholderText( + get_content_name_async("lottery_table", "search_placeholder") + ) + self.search_line_edit.setClearButtonEnabled(True) + self.search_line_edit.textChanged.connect(self._on_search_text_changed) + + self.addGroup( + get_theme_icon("ic_fluent_search_20_filled"), + get_content_name_async("lottery_table", "search"), + get_content_description_async("lottery_table", "search"), + self.search_line_edit, + ) + + def _on_search_text_changed(self): + """搜索文本变化时启动防抖计时器""" + self._search_timer.start() + + def _apply_search_filter(self): + """根据搜索关键词过滤表格行""" + if not hasattr(self, "table") or self.table is None: + return + if not hasattr(self, "search_line_edit"): + return + + keyword = self.search_line_edit.text().strip().lower() + + for row in range(self.table.rowCount()): + if not keyword: + self.table.setRowHidden(row, False) + continue + + matched = False + for col in range(self.table.columnCount()): + item = self.table.item(row, col) + if item and keyword in item.text().lower(): + matched = True + break + self.table.setRowHidden(row, not matched) + def create_table(self): """创建表格区域""" # 创建表格 @@ -283,6 +333,7 @@ def refresh_data(self): finally: # 恢复信号 self.table.blockSignals(False) + self._apply_search_filter() def save_table_data(self, row, col): """保存表格编辑的数据""" diff --git a/app/view/settings/list_management/roll_call_table.py b/app/view/settings/list_management/roll_call_table.py index b9df18e7..9c6d92fb 100644 --- a/app/view/settings/list_management/roll_call_table.py +++ b/app/view/settings/list_management/roll_call_table.py @@ -35,9 +35,17 @@ def __init__(self, parent=None): self.parent = parent self.setTitle(get_content_name_async("roll_call_table", "title")) self.setBorderRadius(8) + self._search_timer = QTimer(self) + self._search_timer.setSingleShot(True) + self._search_timer.setInterval(300) + self._search_timer.timeout.connect(self._apply_search_filter) + # 创建班级选择区域 QTimer.singleShot(APPLY_DELAY, self.create_class_selection) + # 创建搜索栏 + QTimer.singleShot(APPLY_DELAY, self.create_search_bar) + # 创建表格区域 QTimer.singleShot(APPLY_DELAY, self.create_table) @@ -80,6 +88,48 @@ def create_class_selection(self): self.class_comboBox, ) + def create_search_bar(self): + """创建搜索栏""" + self.search_line_edit = SearchLineEdit() + self.search_line_edit.setPlaceholderText( + get_content_name_async("roll_call_table", "search_placeholder") + ) + self.search_line_edit.setClearButtonEnabled(True) + self.search_line_edit.textChanged.connect(self._on_search_text_changed) + + self.addGroup( + get_theme_icon("ic_fluent_search_20_filled"), + get_content_name_async("roll_call_table", "search"), + get_content_description_async("roll_call_table", "search"), + self.search_line_edit, + ) + + def _on_search_text_changed(self): + """搜索文本变化时启动防抖计时器""" + self._search_timer.start() + + def _apply_search_filter(self): + """根据搜索关键词过滤表格行""" + if not hasattr(self, "table") or self.table is None: + return + if not hasattr(self, "search_line_edit"): + return + + keyword = self.search_line_edit.text().strip().lower() + + for row in range(self.table.rowCount()): + if not keyword: + self.table.setRowHidden(row, False) + continue + + matched = False + for col in range(self.table.columnCount()): + item = self.table.item(row, col) + if item and keyword in item.text().lower(): + matched = True + break + self.table.setRowHidden(row, not matched) + def create_table(self): """创建表格区域""" # 创建表格 @@ -272,6 +322,7 @@ def refresh_data(self): finally: # 恢复信号 self.table.blockSignals(False) + self._apply_search_filter() def save_table_data(self, row, col): """保存表格编辑的数据""" diff --git a/uv.lock b/uv.lock index 9e16b3a3..6cf3df01 100644 --- a/uv.lock +++ b/uv.lock @@ -3543,7 +3543,7 @@ wheels = [ [[package]] name = "pyside6-fluent-widgets" version = "1.11.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "darkdetect" }, { name = "pyside6" }, @@ -3557,7 +3557,7 @@ wheels = [ [[package]] name = "pysidesix-frameless-window" version = "0.8.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "pycocoa", marker = "sys_platform == 'darwin'" }, { name = "pyobjc", marker = "sys_platform == 'darwin'" },