From 876712fe7eedbb32cac5d4c0a2a1d55b3628e591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E7=81=B5=E7=81=B5?= Date: Tue, 28 Apr 2026 15:13:03 +0800 Subject: [PATCH] init --- .gitignore | 52 ++++ build.bat | 51 ++++ calculate_matches.py | 106 +++++++ generate_test_data.py | 61 ++++ installer.iss | 69 +++++ main.py | 676 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 1015 insertions(+) create mode 100644 .gitignore create mode 100644 build.bat create mode 100644 calculate_matches.py create mode 100644 generate_test_data.py create mode 100644 installer.iss create mode 100644 main.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5294833 --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python + +# PyInstaller +dist/ +build/ +*.spec + +# Virtual environments +venv/ +.venv/ +env/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +*.txt + +# Excel files (示例数据) +老师信息示例.xlsx +学生信息示例.xlsx +匹配结果示例.xlsx + +# Certificates +*.pfx +*.p12 +*.cer +*.crt + +# Build artifacts +*.exe +*.msi +*.zip + +# Documentation (auto-generated) +*.html +*.md \ No newline at end of file diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..b08b14e --- /dev/null +++ b/build.bat @@ -0,0 +1,51 @@ +@echo off +echo 开始优化打包配置... + +pip install pyinstaller pandas openpyxl flet + +echo 清理旧文件... +if exist "dist" rmdir /s /q "dist" +if exist "build" rmdir /s /q "build" +if exist "main.spec" del "main.spec" + +echo 开始打包(优化模式)... +pyinstaller --onefile --windowed ^ + --exclude-module=torch ^ + --exclude-module=torchvision ^ + --exclude-module=transformers ^ + --exclude-module=onnxruntime ^ + --exclude-module=scipy ^ + --exclude-module=IPython ^ + --exclude-module=pytest ^ + --exclude-module=pil ^ + --exclude-module=PIL ^ + --exclude-module=cv2 ^ + --exclude-module=sklearn ^ + --exclude-module=nltk ^ + --exclude-module=spacy ^ + --exclude-module=plotly ^ + --exclude-module=matplotlib ^ + --exclude-module=seaborn ^ + --exclude-module=sympy ^ + --exclude-module=statsmodels ^ + --exclude-module=pandas.plotting ^ + --exclude-module=pandas.io.formats ^ + --exclude-module=jinja2 ^ + --exclude-module=packaging ^ + --exclude-module=aiohttp ^ + --exclude-module=websockets ^ + main.py + +echo 复制示例文件... +if not exist "dist" mkdir "dist" +if exist "老师信息示例.xlsx" copy "老师信息示例.xlsx" "dist\" /Y +if exist "学生信息示例.xlsx" copy "学生信息示例.xlsx" "dist\" /Y + +echo. +echo ======================================== +echo 打包完成! +echo ======================================== +echo. +dir dist +echo. +pause diff --git a/calculate_matches.py b/calculate_matches.py new file mode 100644 index 0000000..7ac7530 --- /dev/null +++ b/calculate_matches.py @@ -0,0 +1,106 @@ +import pandas as pd + +# 读取数据 +teachers_df = pd.read_excel('老师信息示例.xlsx') +students_df = pd.read_excel('学生信息示例.xlsx') + +# 准备数据 +teachers = [] +for _, row in teachers_df.iterrows(): + teacher = {'id': row['老师ID'], 'name': row['姓名']} + availability = {} + for col in teachers_df.columns: + if col not in ['老师ID', '姓名', '其他信息']: + time_slots = [] + if isinstance(row[col], str): + for slot in row[col].split(';'): + slot = slot.strip() + if slot: + start, end = slot.split('-') + time_slots.append((start, end)) + availability[col] = time_slots + teacher['availability'] = availability + teachers.append(teacher) + +students = [] +for _, row in students_df.iterrows(): + student = {'id': row['学生ID'], 'name': row['姓名']} + history = [] + if isinstance(row['历史匹配老师'], str): + history = [t.strip() for t in row['历史匹配老师'].split(',')] + student['history'] = history + availability = {} + for col in students_df.columns: + if col not in ['学生ID', '姓名', '历史匹配老师', '其他信息']: + time_slots = [] + if isinstance(row[col], str): + for slot in row[col].split(';'): + slot = slot.strip() + if slot: + start, end = slot.split('-') + time_slots.append((start, end)) + availability[col] = time_slots + student['availability'] = availability + students.append(student) + +# 创建老师字典,方便查找 +teachers_dict = {teacher['id']: teacher for teacher in teachers} + +# 收集所有可能的匹配 +possible_matches = [] +for student in students: + for teacher in teachers: + if teacher['id'] in student['history']: + continue + for day, student_slots in student['availability'].items(): + if day not in teacher['availability']: + continue + teacher_slots = teacher['availability'][day] + for s_slot in student_slots: + if s_slot in teacher_slots: + possible_matches.append((student['id'], student['name'], teacher['id'], teacher['name'], day, s_slot)) + +# 为每个学生分配一个匹配 +# 使用贪心算法,优先匹配可用选项最少的学生 +students_sorted = sorted(students, key=lambda s: len([m for m in possible_matches if m[0] == s['id']])) +matched_teachers = set() +matched_slots = set() +current_matches = [] + +for student in students_sorted: + # 找到该学生的所有可能匹配,排除已匹配的老师和时间段 + available_matches = [] + for match in possible_matches: + if match[0] == student['id'] and match[2] not in matched_teachers: + slot_key = (match[2], match[4], match[5]) + if slot_key not in matched_slots: + available_matches.append(match) + + # 选择一个匹配 + if available_matches: + # 选择一个老师,该老师还有最多的可用时间段 + remaining_teachers = {} + for match in available_matches: + teacher_id = match[2] + remaining_slots = 0 + for day, slots in teachers_dict[teacher_id]['availability'].items(): + for slot in slots: + if (teacher_id, day, slot) not in matched_slots: + remaining_slots += 1 + remaining_teachers[match] = remaining_slots + + # 选择剩余时间段最多的老师 + best_match_for_student = max(remaining_teachers, key=remaining_teachers.get) + current_matches.append(best_match_for_student) + matched_teachers.add(best_match_for_student[2]) + matched_slots.add((best_match_for_student[2], best_match_for_student[4], best_match_for_student[5])) + +# 输出结果 +print('最优匹配结果:') +print('-' * 80) +print(f'{"学生ID":<10} {"学生姓名":<10} {"老师ID":<10} {"老师姓名":<10} {"日期":<10} {"时间段":<15}') +print('-' * 80) +for match in current_matches: + print(f'{match[0]:<10} {match[1]:<10} {match[2]:<10} {match[3]:<10} {match[4]:<10} {match[5][0]}-{match[5][1]:<15}') +print('-' * 80) +print(f'总匹配数: {len(current_matches)}') diff --git a/generate_test_data.py b/generate_test_data.py new file mode 100644 index 0000000..696977a --- /dev/null +++ b/generate_test_data.py @@ -0,0 +1,61 @@ +import pandas as pd +import random + +random.seed(42) + +days = ['1月1日', '1月2日', '1月3日', '1月4日', '1月5日', '1月6日', '1月7日'] +time_slots = ['9:00-10:30', '10:30-12:00', '14:00-15:30', '15:30-17:00', '18:00-19:30'] + +teacher_count = 30 +student_count = 100 + +teacher_names = [f"张{i}老师" for i in range(1, teacher_count + 1)] +teacher_types = [] +for i in range(teacher_count): + if i < teacher_count * 0.4: + teacher_types.append("新") + else: + teacher_types.append("旧") +random.shuffle(teacher_types) + +teachers = [] +for i, name in enumerate(teacher_names): + teacher = {"老师姓名": name} + teacher["老师类型"] = teacher_types[i] + available_days = random.sample(days, random.randint(1, 3)) + for day in days: + if day in available_days: + num_slots = random.randint(1, 3) + teacher[day] = '; '.join(random.sample(time_slots, num_slots)) + else: + teacher[day] = '' + teachers.append(teacher) + +df_teachers = pd.DataFrame(teachers) +df_teachers.to_excel('老师信息示例.xlsx', index=False) +print(f"已生成 {teacher_count} 位老师数据") + +student_names = [f"学生{i}" for i in range(1, student_count + 1)] +students = [] +for i, name in enumerate(student_names): + student = {"学生姓名": name} + available_days = random.sample(days, random.randint(2, 5)) + for day in days: + if day in available_days: + num_slots = random.randint(1, 3) + student[day] = '; '.join(random.sample(time_slots, num_slots)) + else: + student[day] = '' + + history_count = min(5, i) + available_teachers = [t for t in teacher_names if t != name] + if available_teachers: + history_teachers = random.sample(available_teachers, history_count) + student["历史匹配老师"] = ', '.join(history_teachers) + else: + student["历史匹配老师"] = "" + students.append(student) + +df_students = pd.DataFrame(students) +df_students.to_excel('学生信息示例.xlsx', index=False) +print(f"已生成 {student_count} 名学生数据") \ No newline at end of file diff --git a/installer.iss b/installer.iss new file mode 100644 index 0000000..456ab91 --- /dev/null +++ b/installer.iss @@ -0,0 +1,69 @@ +; Inno Setup Script for Teacher-Student Matching System +; Version 1.0 + +#define MyAppName "Teacher Student Matcher" +#define MyAppVersion "1.0" +#define MyAppPublisher "Teacher Student Matcher" +#define MyAppExeName "main.exe" +#define MyAppURL "https://github.com/teacher-student-matcher" + +[Setup] +; Application Info +AppId={{8E5F2D1A-4C6B-4F3E-9A1D-7B8E2F5C4A3D} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL} + +; Install Directory +DefaultDirName={autopf}\{#MyAppName} +DefaultGroupName={#MyAppName} +DisableProgramGroupPage=yes + +; Output Settings +OutputDir=dist\output +OutputBaseFilename=TeacherStudentMatcher_Setup_v{#MyAppVersion} +Compression=lzma2 +SolidCompression=yes + +; Windows Version +MinVersion=10.0 + +; UI Settings +WizardStyle=modern +WizardResizable=no + +; 64-bit +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" +Name: "japanese"; MessagesFile: "compiler:Languages\Japanese.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Files] +Source: "dist\main.exe"; DestDir: "{app}"; Flags: ignoreversion +Source: "dist\老师信息示例.xlsx"; DestDir: "{app}"; Flags: ignoreversion +Source: "dist\学生信息示例.xlsx"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon + +[Run] +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent + +[UninstallDelete] +Type: filesandordirs; Name: "{app}" + +[Code] +function InitializeSetup(): Boolean; +begin + Result := True; +end; diff --git a/main.py b/main.py new file mode 100644 index 0000000..fafd442 --- /dev/null +++ b/main.py @@ -0,0 +1,676 @@ +import flet as ft +import pandas as pd +import os + +class TeacherStudentMatcher: + def __init__(self, page: ft.Page): + self.page = page + self.page.title = "老师学员匹配系统" + self.page.window_width = 1400 + self.page.window_height = 800 + self.page.theme_mode = ft.ThemeMode.LIGHT + self.page.padding = 0 + + self.page.theme = ft.Theme( + font_family="Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif" + ) + + self.teachers = [] + self.students = [] + self.matches = [] + + self.setup_ui() + + def setup_ui(self): + self.page.clean() + + header = ft.Container( + content=ft.Row( + controls=[ + ft.Column( + controls=[ + ft.Text("老师学员匹配系统", size=28, weight=ft.FontWeight.W_600, color="#1a1a2e"), + ft.Text("Teacher-Student Matching System", size=12, weight=ft.FontWeight.W_400, color="#666666"), + ], + spacing=2, + horizontal_alignment=ft.CrossAxisAlignment.START, + ), + ], + alignment=ft.MainAxisAlignment.START, + ), + padding=ft.Padding.only(left=40, right=40, top=30, bottom=20), + ) + + button_bar = ft.Container( + content=ft.Row( + controls=[ + ft.Text("数据操作", size=12, weight=ft.FontWeight.W_500, color="#888888"), + ft.Container(width=1, height=20, bgcolor="#e0e0e0"), + ft.Button( + "导入老师信息", + on_click=self.import_teachers, + bgcolor="#ffffff", + color="#333333", + height=36, + elevation=0, + style=ft.ButtonStyle( + shape=ft.RoundedRectangleBorder(radius=6), + side=ft.BorderSide(1, "#d0d0d0"), + ), + ), + ft.Button( + "导入学生信息", + on_click=self.import_students, + bgcolor="#ffffff", + color="#333333", + height=36, + elevation=0, + style=ft.ButtonStyle( + shape=ft.RoundedRectangleBorder(radius=6), + side=ft.BorderSide(1, "#d0d0d0"), + ), + ), + ft.Container(width=1, height=20, bgcolor="#e0e0e0"), + ft.Text("匹配操作", size=12, weight=ft.FontWeight.W_500, color="#888888"), + ft.Container(width=1, height=20, bgcolor="#e0e0e0"), + ft.Button( + "开始匹配", + on_click=self.start_match, + bgcolor="#2563eb", + color="#ffffff", + height=36, + elevation=0, + style=ft.ButtonStyle( + shape=ft.RoundedRectangleBorder(radius=6), + ), + ), + ft.Button( + "导出结果", + on_click=self.export_results, + bgcolor="#ffffff", + color="#333333", + height=36, + elevation=0, + style=ft.ButtonStyle( + shape=ft.RoundedRectangleBorder(radius=6), + side=ft.BorderSide(1, "#d0d0d0"), + ), + ), + ft.Button( + "清空", + on_click=self.clear_all, + bgcolor="#ffffff", + color="#666666", + height=36, + elevation=0, + style=ft.ButtonStyle( + shape=ft.RoundedRectangleBorder(radius=6), + side=ft.BorderSide(1, "#d0d0d0"), + ), + ), + ], + spacing=12, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + padding=ft.Padding.only(left=40, right=40, top=10, bottom=20), + ) + + divider1 = ft.Container(height=1, bgcolor="#f0f0f0") + + main_content = ft.Row( + controls=[ + ft.Container( + content=ft.Column( + controls=[ + ft.Container( + content=ft.Column( + controls=[ + ft.Text("老师信息", size=14, weight=ft.FontWeight.W_700, color="#1a1a2e"), + ft.Text("Teacher Information", size=10, weight=ft.FontWeight.W_500, color="#888888"), + ], + spacing=4, + ), + padding=ft.Padding.only(left=24, right=24, top=20, bottom=16), + ), + ft.Container(height=1, bgcolor="#f5f5f5"), + ft.Container( + content=self.create_teacher_table(), + padding=ft.Padding.only(left=16, right=16, bottom=16), + expand=True, + ), + ], + ), + bgcolor="#ffffff", + border_radius=12, + border=ft.Border.all(1, "#f0f0f0"), + expand=True, + margin=ft.Margin.only(left=20, right=10, top=0, bottom=20), + ), + ft.Container( + content=ft.Column( + controls=[ + ft.Container( + content=ft.Column( + controls=[ + ft.Text("学生信息", size=14, weight=ft.FontWeight.W_700, color="#1a1a2e"), + ft.Text("Student Information", size=10, weight=ft.FontWeight.W_500, color="#888888"), + ], + spacing=4, + ), + padding=ft.Padding.only(left=24, right=24, top=20, bottom=16), + ), + ft.Container(height=1, bgcolor="#f5f5f5"), + ft.Container( + content=self.create_student_table(), + padding=ft.Padding.only(left=16, right=16, bottom=16), + expand=True, + ), + ], + ), + bgcolor="#ffffff", + border_radius=12, + border=ft.Border.all(1, "#f0f0f0"), + expand=True, + margin=ft.Margin.only(left=10, right=10, top=0, bottom=20), + ), + ft.Container( + content=ft.Column( + controls=[ + ft.Container( + content=ft.Column( + controls=[ + ft.Text("匹配结果", size=14, weight=ft.FontWeight.W_700, color="#1a1a2e"), + ft.Text("Matching Results", size=10, weight=ft.FontWeight.W_500, color="#888888"), + ], + spacing=4, + ), + padding=ft.Padding.only(left=24, right=24, top=20, bottom=16), + ), + ft.Container(height=1, bgcolor="#f5f5f5"), + ft.Container( + content=self.result_status_text(), + padding=ft.Padding.only(left=24, right=24, top=12, bottom=8), + ), + ft.Container(height=1, bgcolor="#f5f5f5"), + ft.Container( + content=self.create_result_table(), + padding=ft.Padding.only(left=16, right=16, bottom=16), + expand=True, + ), + ], + ), + bgcolor="#ffffff", + border_radius=12, + border=ft.Border.all(1, "#f0f0f0"), + expand=True, + margin=ft.Margin.only(left=10, right=20, top=0, bottom=20), + ), + ], + spacing=0, + expand=True, + vertical_alignment=ft.CrossAxisAlignment.START, + ) + + self.page.add(header) + self.page.add(button_bar) + self.page.add(divider1) + self.page.add(main_content) + + def create_teacher_table(self): + self.teacher_table = ft.DataTable( + columns=[ + ft.DataColumn(ft.Text("姓名", weight=ft.FontWeight.W_500, color="#555555")), + ft.DataColumn(ft.Text("类型", weight=ft.FontWeight.W_500, color="#555555")), + ], + rows=[], + heading_row_color="#fafafa", + data_row_color={"hovered": "#f8f9fa", "selected": "#f0f4ff"}, + border=ft.Border.all(0, "#f0f0f0"), + vertical_lines=ft.border.BorderSide(0, "#f0f0f0"), + horizontal_lines=ft.border.BorderSide(1, "#f5f5f5"), + column_spacing=30, + heading_row_height=40, + data_row_min_height=40, + ) + return ft.ListView( + controls=[self.teacher_table], + spacing=0, + padding=0, + expand=True, + ) + + def create_student_table(self): + self.student_table = ft.DataTable( + columns=[ + ft.DataColumn(ft.Text("姓名", weight=ft.FontWeight.W_500, color="#555555")), + ft.DataColumn(ft.Text("历史老师", weight=ft.FontWeight.W_500, color="#555555")), + ], + rows=[], + heading_row_color="#fafafa", + data_row_color={"hovered": "#f8f9fa", "selected": "#f0f4ff"}, + border=ft.Border.all(0, "#f0f0f0"), + vertical_lines=ft.border.BorderSide(0, "#f0f0f0"), + horizontal_lines=ft.border.BorderSide(1, "#f5f5f5"), + column_spacing=20, + heading_row_height=40, + data_row_min_height=40, + ) + return ft.ListView( + controls=[self.student_table], + spacing=0, + padding=0, + expand=True, + ) + + def result_status_text(self): + self.status_text = ft.Text( + "等待匹配...", + size=12, + weight=ft.FontWeight.W_400, + color="#888888", + ) + return self.status_text + + def create_result_table(self): + self.result_table = ft.DataTable( + columns=[ + ft.DataColumn(ft.Text("学生", weight=ft.FontWeight.W_500, color="#555555")), + ft.DataColumn(ft.Text("老师", weight=ft.FontWeight.W_500, color="#555555")), + ft.DataColumn(ft.Text("日期", weight=ft.FontWeight.W_500, color="#555555")), + ft.DataColumn(ft.Text("时段", weight=ft.FontWeight.W_500, color="#555555")), + ft.DataColumn(ft.Text("状态", weight=ft.FontWeight.W_500, color="#555555")), + ], + rows=[], + heading_row_color="#fafafa", + data_row_color={"hovered": "#f8f9fa", "selected": "#f0f4ff"}, + border=ft.Border.all(0, "#f0f0f0"), + vertical_lines=ft.border.BorderSide(0, "#f0f0f0"), + horizontal_lines=ft.border.BorderSide(1, "#f5f5f5"), + column_spacing=15, + heading_row_height=40, + data_row_min_height=40, + ) + return ft.ListView( + controls=[self.result_table], + spacing=0, + padding=0, + expand=True, + ) + + def import_teachers(self, e): + import threading + from tkinter import filedialog + import tkinter as tk + + result = {} + + def ask_file(): + root = tk.Tk() + root.withdraw() + root.attributes('-topmost', True) + file_path = filedialog.askopenfilename( + title="选择老师信息文件", + filetypes=[("Excel files", "*.xlsx"), ("All files", "*.*")] + ) + result['path'] = file_path + root.destroy() + + thread = threading.Thread(target=ask_file) + thread.start() + thread.join() + + file_path = result.get('path', '') + if file_path: + try: + df = pd.read_excel(file_path) + self.teachers = [] + + fixed_columns = ["老师姓名", "老师类型"] + + for _, row in df.iterrows(): + availability = {} + for col in df.columns: + if col not in fixed_columns: + availability[col] = str(row.get(col, "")) + + teacher_type = row.get("老师类型", "").strip() + if teacher_type in ["新", "新老师"]: + teacher_type = "新" + elif teacher_type in ["旧", "旧老师"]: + teacher_type = "旧" + else: + teacher_type = "旧" + + teacher = { + "id": row.get("老师姓名", ""), + "name": row.get("老师姓名", ""), + "type": teacher_type, + "availability": availability + } + self.teachers.append(teacher) + + self.update_teacher_table() + self.page.snack_bar = ft.SnackBar( + content=ft.Text(f"成功导入 {len(self.teachers)} 位老师"), + bgcolor="#10b981", + ) + self.page.snack_bar.open = True + except Exception as ex: + self.page.snack_bar = ft.SnackBar( + content=ft.Text(f"导入失败: {str(ex)}"), + bgcolor="#ef4444", + ) + self.page.snack_bar.open = True + + def import_students(self, e): + import threading + from tkinter import filedialog + import tkinter as tk + + result = {} + + def ask_file(): + root = tk.Tk() + root.withdraw() + root.attributes('-topmost', True) + file_path = filedialog.askopenfilename( + title="选择学生信息文件", + filetypes=[("Excel files", "*.xlsx"), ("All files", "*.*")] + ) + result['path'] = file_path + root.destroy() + + thread = threading.Thread(target=ask_file) + thread.start() + thread.join() + + file_path = result.get('path', '') + if file_path: + try: + df = pd.read_excel(file_path) + self.students = [] + + fixed_columns = ["学生姓名", "历史匹配老师"] + + for _, row in df.iterrows(): + history = row.get("历史匹配老师", "") + history_teachers = [t.strip() for t in history.split(",")] if isinstance(history, str) else [] + + availability = {} + for col in df.columns: + if col not in fixed_columns: + availability[col] = str(row.get(col, "")) + + student = { + "id": row.get("学生姓名", ""), + "name": row.get("学生姓名", ""), + "availability": availability, + "history": history_teachers + } + self.students.append(student) + + self.update_student_table() + self.page.snack_bar = ft.SnackBar( + content=ft.Text(f"成功导入 {len(self.students)} 名学生"), + bgcolor="#10b981", + ) + self.page.snack_bar.open = True + except Exception as ex: + self.page.snack_bar = ft.SnackBar( + content=ft.Text(f"导入失败: {str(ex)}"), + bgcolor="#ef4444", + ) + self.page.snack_bar.open = True + + def update_teacher_table(self): + self.teacher_table.rows = [] + for teacher in self.teachers: + type_color = "#2563eb" if teacher["type"] == "新" else "#10b981" + self.teacher_table.rows.append( + ft.DataRow( + cells=[ + ft.DataCell(ft.Text(teacher["name"], size=13, weight=ft.FontWeight.W_500, color="#333333")), + ft.DataCell(ft.Text(teacher["type"], size=12, weight=ft.FontWeight.W_600, color=type_color)), + ] + ) + ) + self.page.update() + + def update_student_table(self): + self.student_table.rows = [] + for student in self.students: + history_str = ", ".join(student["history"][:3]) + if len(student["history"]) > 3: + history_str += "..." + self.student_table.rows.append( + ft.DataRow( + cells=[ + ft.DataCell(ft.Text(student["name"], size=13, weight=ft.FontWeight.W_500, color="#333333")), + ft.DataCell(ft.Text(history_str, size=12, weight=ft.FontWeight.W_500, color="#666666")), + ] + ) + ) + self.page.update() + + def parse_time_slots(self, time_str): + if not time_str: + return [] + slots = [] + for slot in time_str.split(';'): + slot = slot.strip() + if slot and '-' in slot: + parts = slot.split('-') + if len(parts) == 2: + start, end = parts + slots.append((start, end)) + return slots + + def start_match(self, e): + if not self.teachers: + self.page.snack_bar = ft.SnackBar( + content=ft.Text("请先导入老师信息"), + bgcolor="#f59e0b", + ) + self.page.snack_bar.open = True + return + + if not self.students: + self.page.snack_bar = ft.SnackBar( + content=ft.Text("请先导入学生信息"), + bgcolor="#f59e0b", + ) + self.page.snack_bar.open = True + return + + try: + self.matches = [] + + teacher_assigned_days = {} + for teacher in self.teachers: + teacher_assigned_days[teacher["id"]] = set() + + teacher_match_count = {} + for teacher in self.teachers: + teacher_match_count[teacher["id"]] = 0 + + for student in self.students: + matched = False + possible_matches = [] + + history_count = len(student["history"]) + expected_teacher_type = "旧" if history_count == 0 else ("新" if history_count % 2 == 1 else "旧") + + for teacher in self.teachers: + if teacher["id"] in student["history"]: + continue + + if teacher["type"] != expected_teacher_type: + continue + + for day, student_time in student["availability"].items(): + if day not in teacher["availability"]: + continue + + if day in teacher_assigned_days[teacher["id"]]: + continue + + teacher_time = teacher["availability"][day] + + if student_time and teacher_time: + student_slots = self.parse_time_slots(student_time) + teacher_slots = self.parse_time_slots(teacher_time) + + for s_start, s_end in student_slots: + for t_start, t_end in teacher_slots: + if s_start == t_start and s_end == t_end: + possible_matches.append((teacher, day, s_start, s_end)) + + if possible_matches: + possible_matches.sort(key=lambda x: teacher_match_count[x[0]["id"]]) + + teacher, day, s_start, s_end = possible_matches[0] + + match = { + "student_name": student["name"], + "teacher_name": teacher["name"], + "match_date": day, + "match_period": f"{s_start}-{s_end}", + "status": "成功" + } + + self.matches.append(match) + + student["history"].append(teacher["id"]) + + teacher_assigned_days[teacher["id"]].add(day) + + teacher_match_count[teacher["id"]] += 1 + + matched = True + + if not matched: + match = { + "student_name": student["name"], + "teacher_name": "-", + "match_date": "-", + "match_period": "-", + "status": "未匹配" + } + self.matches.append(match) + + self.update_result_table() + success_count = len([m for m in self.matches if m["status"] == "成功"]) + self.status_text.value = f"匹配完成 · 成功 {success_count} / {len(self.matches)}" + self.status_text.color = "#10b981" if success_count == len(self.matches) else "#f59e0b" + self.page.snack_bar = ft.SnackBar( + content=ft.Text(f"匹配完成!成功匹配 {success_count} 名学生"), + bgcolor="#10b981", + ) + self.page.snack_bar.open = True + + except Exception as ex: + self.page.snack_bar = ft.SnackBar( + content=ft.Text(f"匹配失败: {str(ex)}"), + bgcolor="#ef4444", + ) + self.page.snack_bar.open = True + + def update_result_table(self): + self.result_table.rows = [] + for match in self.matches: + is_success = match["status"] == "成功" + bg_color = "#f0fdf4" if is_success else "#fef2f2" + status_color = "#10b981" if is_success else "#ef4444" + self.result_table.rows.append( + ft.DataRow( + cells=[ + ft.DataCell(ft.Text(match["student_name"], size=13, weight=ft.FontWeight.W_500, color="#333333")), + ft.DataCell(ft.Text(match["teacher_name"], size=13, weight=ft.FontWeight.W_500, color="#333333")), + ft.DataCell(ft.Text(match["match_date"], size=12, weight=ft.FontWeight.W_500, color="#555555")), + ft.DataCell(ft.Text(match["match_period"], size=12, weight=ft.FontWeight.W_500, color="#666666")), + ft.DataCell(ft.Container( + content=ft.Text(match["status"], size=12, weight=ft.FontWeight.W_600, color=status_color), + bgcolor=bg_color, + border_radius=4, + padding=ft.padding.symmetric(horizontal=8, vertical=2), + )), + ] + ) + ) + self.page.update() + + def export_results(self, e): + if not self.matches: + self.page.snack_bar = ft.SnackBar( + content=ft.Text("没有匹配结果可导出"), + bgcolor="#f59e0b", + ) + self.page.snack_bar.open = True + return + + import threading + from tkinter import filedialog + import tkinter as tk + + result = {} + + def ask_save(): + root = tk.Tk() + root.withdraw() + root.attributes('-topmost', True) + file_path = filedialog.asksaveasfilename( + title="保存匹配结果", + defaultextension=".xlsx", + filetypes=[("Excel files", "*.xlsx"), ("All files", "*.*")] + ) + result['path'] = file_path + root.destroy() + + thread = threading.Thread(target=ask_save) + thread.start() + thread.join() + + file_path = result.get('path', '') + if not file_path: + return + + try: + df = pd.DataFrame(self.matches) + df.columns = ["学生", "老师", "日期", "时段", "状态"] + + df.to_excel(file_path, index=False) + self.page.snack_bar = ft.SnackBar( + content=ft.Text(f"结果已导出到: {file_path}"), + bgcolor="#10b981", + ) + self.page.snack_bar.open = True + except Exception as ex: + self.page.snack_bar = ft.SnackBar( + content=ft.Text(f"导出失败: {str(ex)}"), + bgcolor="#ef4444", + ) + self.page.snack_bar.open = True + + def clear_all(self, e): + self.teachers = [] + self.students = [] + self.matches = [] + + self.teacher_table.rows = [] + self.student_table.rows = [] + self.result_table.rows = [] + + self.status_text.value = "等待匹配..." + self.status_text.color = "#888888" + + self.page.snack_bar = ft.SnackBar( + content=ft.Text("已清空所有数据"), + bgcolor="#6b7280", + ) + self.page.snack_bar.open = True + +def main(page: ft.Page): + TeacherStudentMatcher(page) + +if __name__ == "__main__": + ft.run(main)