From 304856429c2b03f4126b7947578876c3b4626fb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E7=81=B5=E7=81=B5?= Date: Wed, 29 Apr 2026 19:11:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84UI=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=EF=BC=8C=E6=B7=BB=E5=8A=A0=E5=8C=B9=E9=85=8D=E6=9D=A1=E4=BB=B6?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E3=80=81=E6=97=A5=E5=BF=97=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E5=8C=B9=E9=85=8D=E7=AE=97=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- generate_test_data.py | 6 +- main.py | 1093 ++++++++++++++++++++--------------------- 2 files changed, 530 insertions(+), 569 deletions(-) diff --git a/generate_test_data.py b/generate_test_data.py index 696977a..287c7c4 100644 --- a/generate_test_data.py +++ b/generate_test_data.py @@ -47,10 +47,10 @@ for i, name in enumerate(student_names): else: student[day] = '' - history_count = min(5, i) + history_count = random.randint(0, 3) available_teachers = [t for t in teacher_names if t != name] - if available_teachers: - history_teachers = random.sample(available_teachers, history_count) + if available_teachers and history_count > 0: + history_teachers = random.sample(available_teachers, min(history_count, len(available_teachers))) student["历史匹配老师"] = ', '.join(history_teachers) else: student["历史匹配老师"] = "" diff --git a/main.py b/main.py index fafd442..716c967 100644 --- a/main.py +++ b/main.py @@ -1,458 +1,483 @@ import flet as ft import pandas as pd -import os +import re + + +C_PRIMARY = "#2563eb" +C_PRIMARY_HOVER = "#1d4ed8" +C_BG = "#f1f5f9" +C_CARD = "#ffffff" +C_TEXT = "#1e293b" +C_TEXT_SEC = "#64748b" +C_TEXT_MUTED = "#94a3b8" +C_BORDER = "#e2e8f0" +C_SUCCESS = "#22c55e" +C_WARNING = "#f59e0b" +C_ERROR = "#ef4444" +C_ROW_ALT = "#f8fafc" +C_TAG_BG = "#f1f5f9" + + +def _btn(text, on_click, primary=False, icon=None): + btn = ft.Button( + content=ft.Row( + controls=[ft.Icon(icon, size=16) if icon else None, ft.Text(text, size=13)], + spacing=6, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + on_click=on_click, + height=38, + style=ft.ButtonStyle( + color="#ffffff" if primary else C_TEXT, + bgcolor=C_PRIMARY if primary else None, + shape=ft.RoundedRectangleBorder(radius=8), + side=ft.BorderSide(1, C_PRIMARY if primary else C_BORDER), + padding=ft.Padding.symmetric(horizontal=16, vertical=0), + ), + ) + return btn + + +def _card(controls, **kwargs): + return ft.Container( + content=ft.Column(controls=controls, spacing=0, **kwargs), + bgcolor=C_CARD, + border_radius=12, + border=ft.Border.all(1, C_BORDER), + ) + + +def _card_header(title, subtitle=None, trailing=None): + row = [ft.Text(title, size=14, weight=ft.FontWeight.W_600, color=C_TEXT)] + if subtitle: + row.append(ft.Text(subtitle, size=11, color=C_TEXT_MUTED)) + header_content = ft.Column( + controls=[ft.Row(row, spacing=8, vertical_alignment=ft.CrossAxisAlignment.CENTER)], + spacing=0, + ) + parts = [header_content] + if trailing: + parts.insert(0, trailing) + header = ft.Container( + content=ft.Row(parts, alignment=ft.MainAxisAlignment.SPACE_BETWEEN, vertical_alignment=ft.CrossAxisAlignment.CENTER) if trailing else header_content, + padding=ft.Padding.only(left=16, right=16, top=10, bottom=8), + ) + line = ft.Container(height=1, bgcolor=C_BORDER) + return header, line + + +def _table(columns, data_rows): + header_cells = [ft.DataColumn(ft.Text(c, size=12, weight=ft.FontWeight.W_600, color=C_TEXT_SEC)) for c in columns] + rows = [] + for i, row_data in enumerate(data_rows): + bg = C_ROW_ALT if i % 2 == 0 else C_CARD + cells = [ft.DataCell(c) for c in row_data] + rows.append(ft.DataRow(cells=cells, color=bg)) + return ft.DataTable( + columns=header_cells, + rows=rows, + border=ft.Border.all(0, C_BORDER), + vertical_lines=ft.BorderSide(0, C_BORDER), + horizontal_lines=ft.BorderSide(1, C_BORDER), + heading_row_height=44, + data_row_min_height=42, + data_row_max_height=42, + column_spacing=16, + horizontal_margin=8, + ) + 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.window_height = 840 + self.page.bgcolor = C_BG 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" + font_family="Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', Helvetica, Arial, sans-serif" ) + self.page.theme_mode = ft.ThemeMode.LIGHT self.teachers = [] self.students = [] self.matches = [] + self.logs = [] + self.match_conditions = { + "time_match": True, + "all_day_match": True, + "history_avoidance": True, + "daily_unique": True, + "balanced_assignment": True, + "teacher_type_rule": True, + } + self._cond_refs = {} - self.setup_ui() + self._build_ui() - def setup_ui(self): + def _build_ui(self): self.page.clean() - header = ft.Container( + top_bar = ft.Container( content=ft.Row( controls=[ - ft.Column( + ft.Row( 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"), + ft.Container( + content=ft.Text("TS", size=16, weight=ft.FontWeight.W_800, color="#ffffff", text_align=ft.TextAlign.CENTER), + width=36, height=36, border_radius=8, + bgcolor=C_PRIMARY, + ), + ft.Column( + controls=[ + ft.Text("老师学员匹配系统", size=18, weight=ft.FontWeight.W_700, color=C_TEXT), + ft.Text("Teacher-Student Matching System", size=11, color=C_TEXT_MUTED), + ], + spacing=1, + ), ], - spacing=2, - horizontal_alignment=ft.CrossAxisAlignment.START, + spacing=12, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + ft.Row( + controls=[ + _btn("导入老师", self.import_teachers, icon=ft.Icons.UPLOAD_FILE_ROUNDED), + _btn("导入学生", self.import_students, icon=ft.Icons.UPLOAD_FILE_ROUNDED), + ft.Container(width=1, height=24, bgcolor=C_BORDER), + _btn("开始匹配", self.start_match, primary=True, icon=ft.Icons.PLAY_ARROW_ROUNDED), + _btn("导出结果", self.export_results, icon=ft.Icons.DOWNLOAD_ROUNDED), + _btn("清空", self.clear_all, icon=ft.Icons.DELETE_OUTLINE_ROUNDED), + ], + spacing=8, + vertical_alignment=ft.CrossAxisAlignment.CENTER, ), ], - 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, + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, vertical_alignment=ft.CrossAxisAlignment.CENTER, ), - padding=ft.Padding.only(left=40, right=40, top=10, bottom=20), + padding=ft.Padding.only(left=24, right=24, top=10, bottom=10), + bgcolor=C_CARD, + border=ft.Border(bottom=ft.BorderSide(1, C_BORDER)), ) - divider1 = ft.Container(height=1, bgcolor="#f0f0f0") + cond_items = [ + ("time_match", "时间匹配"), + ("all_day_match", "全天匹配"), + ("history_avoidance", "历史规避"), + ("daily_unique", "每日唯一"), + ("balanced_assignment", "均衡分配"), + ("teacher_type_rule", "新旧交替"), + ] - main_content = ft.Row( + self._cond_refs = {} + cond_controls = [] + for key, label in cond_items: + cb = ft.Checkbox( + value=self.match_conditions[key], + on_change=lambda e, k=key: self._on_condition_change(k, e), + fill_color=C_PRIMARY, + check_color="#ffffff", + shape=ft.RoundedRectangleBorder(radius=3), + height=20, + width=20, + ) + self._cond_refs[key] = cb + cond_controls.append( + ft.Container( + content=ft.Row( + controls=[cb, ft.Text(label, size=11, color=C_TEXT)], + spacing=2, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + ) + ) + + toolbar = ft.Container( + content=ft.Row( + controls=[ + ft.Icon(ft.Icons.TUNE_ROUNDED, size=14, color=C_TEXT_SEC), + *cond_controls, + ft.Container(expand=True), + ft.TextButton( + "全选", + on_click=self._select_all_conditions, + style=ft.ButtonStyle(color=C_PRIMARY, padding=ft.Padding.symmetric(horizontal=4), visual_density=ft.VisualDensity.COMPACT), + ), + ft.TextButton( + "清空", + on_click=self._deselect_all_conditions, + style=ft.ButtonStyle(color=C_TEXT_MUTED, padding=ft.Padding.symmetric(horizontal=4), visual_density=ft.VisualDensity.COMPACT), + ), + ], + spacing=8, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + padding=ft.Padding.only(left=12, right=8, top=2, bottom=2), + margin=ft.Margin.only(left=24, right=24, top=0, bottom=4), + bgcolor=C_TAG_BG, + border_radius=6, + ) + + teacher_header, teacher_line = _card_header("老师信息", "Teacher Information") + self.teacher_table = _table(["姓名", "类型"], []) + teacher_card = _card([ + teacher_header, + teacher_line, + ft.Container( + content=ft.ListView(controls=[self.teacher_table], padding=ft.Padding.only(left=4, right=4, top=8, bottom=8), expand=True), + padding=ft.Padding.only(left=8, right=8, bottom=8), + expand=True, + ), + ], expand=True) + + student_header, student_line = _card_header("学生信息", "Student Information") + self.student_table = _table(["姓名", "历史老师"], []) + student_card = _card([ + student_header, + student_line, + ft.Container( + content=ft.ListView(controls=[self.student_table], padding=ft.Padding.only(left=4, right=4, top=8, bottom=8), expand=True), + padding=ft.Padding.only(left=8, right=8, bottom=8), + expand=True, + ), + ], expand=True) + + self.status_text = ft.Text("等待匹配…", size=12, color=C_TEXT_MUTED, weight=ft.FontWeight.W_400) + result_header, result_line = _card_header("匹配结果", "Matching Results", trailing=self.status_text) + self.result_table = _table(["学生", "老师", "日期", "时段", "状态"], []) + result_card = _card([ + result_header, + result_line, + ft.Container( + content=ft.ListView(controls=[self.result_table], padding=ft.Padding.only(left=4, right=4, top=8, bottom=8), expand=True), + padding=ft.Padding.only(left=8, right=8, bottom=8), + expand=True, + ), + ], expand=True) + + main_columns = 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), - ), + ft.Container(content=teacher_card, expand=True, margin=ft.Margin.only(right=8)), + ft.Container(content=student_card, expand=True, margin=ft.Margin.only(left=8, right=8)), + ft.Container(content=result_card, expand=True, margin=ft.Margin.only(left=8)), ], spacing=0, expand=True, - vertical_alignment=ft.CrossAxisAlignment.START, + vertical_alignment=ft.CrossAxisAlignment.STRETCH, ) - 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, + self.log_list = ft.ListView(controls=[], spacing=1, padding=ft.Padding.only(left=12, right=12, top=2, bottom=4), height=80) + self._log_expanded = False + self._log_body = ft.Container(content=self.log_list, height=0, clip_behavior=ft.ClipBehavior.NONE) + self._log_toggle_icon = ft.Icon(ft.Icons.EXPAND_MORE_ROUNDED, size=14, color=C_TEXT_MUTED) + self._log_count_text = ft.Text("0", size=10, color=C_TEXT_MUTED) + self._log_bar = ft.Container( + content=ft.Row( + controls=[ + ft.Icon(ft.Icons.RECEIPT_LONG_ROUNDED, size=14, color=C_TEXT_MUTED), + ft.Text("日志", size=11, weight=ft.FontWeight.W_500, color=C_TEXT_MUTED), + self._log_count_text, + ft.Container(expand=True), + ft.TextButton("清空", on_click=self.clear_logs, style=ft.ButtonStyle(color=C_TEXT_MUTED, padding=ft.Padding.symmetric(horizontal=4), visual_density=ft.VisualDensity.COMPACT)), + self._log_toggle_icon, + ], + spacing=4, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + padding=ft.Padding.only(left=12, right=4, top=2, bottom=2), + on_click=self._toggle_log, + bgcolor=C_CARD, + border=ft.Border(top=ft.BorderSide(1, C_BORDER)), ) - 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, - ) + self.page.add(top_bar) + self.page.add(toolbar) + self.page.add(main_columns) + self.page.add(self._log_bar) + self.page.add(self._log_body) - def result_status_text(self): - self.status_text = ft.Text( - "等待匹配...", - size=12, - weight=ft.FontWeight.W_400, - color="#888888", - ) - return self.status_text + self.add_log("系统就绪", "info") - 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 add_log(self, message, level="info"): + colors = {"info": C_TEXT_SEC, "success": C_SUCCESS, "warning": C_WARNING, "error": C_ERROR} + icons = {"info": "info", "success": "check_circle", "warning": "warning", "error": "error"} + icon_name = getattr(ft.Icons, icons.get(level, "info").upper() + "_ROUNDED", ft.Icons.INFO_ROUNDED) + self.logs.append({"message": message, "level": level}) + if len(self.logs) > 100: + self.logs = self.logs[-100:] + self.log_list.controls.append( + ft.Row( + controls=[ + ft.Icon(icon_name, size=12, color=colors.get(level, C_TEXT_SEC)), + ft.Text(message, size=10, color=colors.get(level, C_TEXT_SEC)), + ], + spacing=3, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ) ) + self._log_count_text.value = str(len(self.logs)) + self.log_list.update() + self._log_count_text.update() + + def clear_logs(self, e): + self.logs = [] + self.log_list.controls = [] + self._log_count_text.value = "0" + self.log_list.update() + self._log_count_text.update() + + def _toggle_log(self, e=None): + self._log_expanded = not self._log_expanded + self._log_body.height = 100 if self._log_expanded else 0 + self._log_body.opacity = 1 if self._log_expanded else 0 + self._log_toggle_icon.name = ft.Icons.EXPAND_LESS_ROUNDED if self._log_expanded else ft.Icons.EXPAND_MORE_ROUNDED + self._log_body.update() + self._log_toggle_icon.update() + + def _on_condition_change(self, key, e): + self.match_conditions[key] = e.control.value + labels = {"time_match": "时间匹配", "all_day_match": "全天匹配", "history_avoidance": "历史规避", "daily_unique": "每日唯一", "balanced_assignment": "均衡分配", "teacher_type_rule": "新旧交替"} + self.add_log(f"匹配条件 [{labels[key]}] 已{'开启' if e.control.value else '关闭'}", "info") + + def _select_all_conditions(self, e): + for key in self.match_conditions: + self.match_conditions[key] = True + for key, ref in self._cond_refs.items(): + ref.value = True + self.add_log("已全选匹配条件", "info") + + def _deselect_all_conditions(self, e): + for key in self.match_conditions: + self.match_conditions[key] = False + for key, ref in self._cond_refs.items(): + ref.value = False + self.add_log("已清空匹配条件", "warning") + + def _ask_file(self, title): + import threading + from tkinter import filedialog, Tk + result = {} + def ask(): + root = Tk() + root.withdraw() + root.attributes('-topmost', True) + result['path'] = filedialog.askopenfilename(title=title, filetypes=[("Excel", "*.xlsx"), ("All", "*.*")]) + root.destroy() + t = threading.Thread(target=ask) + t.start() + t.join() + return result.get('path', '') + + def _ask_save(self): + import threading + from tkinter import filedialog, Tk + result = {} + def ask(): + root = Tk() + root.withdraw() + root.attributes('-topmost', True) + result['path'] = filedialog.asksaveasfilename(title="保存匹配结果", defaultextension=".xlsx", filetypes=[("Excel", "*.xlsx"), ("All", "*.*")]) + root.destroy() + t = threading.Thread(target=ask) + t.start() + t.join() + return result.get('path', '') + + def _show_snackbar(self, text, color=C_SUCCESS): + self.page.snack_bar = ft.SnackBar(content=ft.Text(text), bgcolor=color) + self.page.snack_bar.open = True + self.page.update() 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 + path = self._ask_file("选择老师信息文件") + if not path: + return + try: + df = pd.read_excel(path) + self.teachers = [] + fixed = ["老师姓名", "老师类型"] + for _, row in df.iterrows(): + avail = {col: str(row.get(col, "")) for col in df.columns if col not in fixed} + ttype = row.get("老师类型", "").strip() + if ttype in ["新", "新老师"]: + ttype = "新" + elif ttype in ["旧", "旧老师"]: + ttype = "旧" + else: + ttype = "旧" + self.teachers.append({"id": row["老师姓名"], "name": row["老师姓名"], "type": ttype, "availability": avail}) + self._refresh_teacher_table() + self.add_log(f"导入 {len(self.teachers)} 位老师", "success") + self._show_snackbar(f"成功导入 {len(self.teachers)} 位老师") + except Exception as ex: + self.add_log(f"导入老师失败: {ex}", "error") + self._show_snackbar(f"导入失败: {ex}", C_ERROR) def import_students(self, e): - import threading - from tkinter import filedialog - import tkinter as tk + path = self._ask_file("选择学生信息文件") + if not path: + return + try: + df = pd.read_excel(path) + self.students = [] + fixed = ["学生姓名", "历史匹配老师"] + for _, row in df.iterrows(): + h = row.get("历史匹配老师", "") + history = self._parse_history(str(h)) if pd.notna(h) and str(h).strip() else [] + avail = {col: str(row.get(col, "")) for col in df.columns if col not in fixed} + self.students.append({"id": row["学生姓名"], "name": row["学生姓名"], "availability": avail, "history": history}) + self._refresh_student_table() + self.add_log(f"导入 {len(self.students)} 名学生", "success") + self._show_snackbar(f"成功导入 {len(self.students)} 名学生") + except Exception as ex: + self.add_log(f"导入学生失败: {ex}", "error") + self._show_snackbar(f"导入失败: {ex}", C_ERROR) - 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): + def _refresh_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)), - ] - ) - ) + for i, t in enumerate(self.teachers): + bg = C_ROW_ALT if i % 2 == 0 else C_CARD + dot_color = C_PRIMARY if t["type"] == "新" else C_SUCCESS + self.teacher_table.rows.append(ft.DataRow( + color=bg, + cells=[ + ft.DataCell(ft.Text(t["name"], size=13, color=C_TEXT)), + ft.DataCell(ft.Row( + controls=[ + ft.Container(width=6, height=6, border_radius=3, bgcolor=dot_color), + ft.Text(t["type"], size=12, color=dot_color), + ], + spacing=4, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + )), + ], + )) self.page.update() - def update_student_table(self): + def _refresh_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")), - ] - ) - ) + for i, s in enumerate(self.students): + bg = C_ROW_ALT if i % 2 == 0 else C_CARD + h = ", ".join(s["history"][:3]) + if len(s["history"]) > 3: + h += f" +{len(s['history']) - 3}" + self.student_table.rows.append(ft.DataRow( + color=bg, + cells=[ + ft.DataCell(ft.Text(s["name"], size=13, color=C_TEXT)), + ft.DataCell(ft.Text(h if h else "-", size=12, color=C_TEXT_SEC)), + ], + )) self.page.update() - def parse_time_slots(self, time_str): + @staticmethod + def _parse_history(text): + parts = re.split(r"[,,;;、/\\。.]", text) + return [t.strip() for t in parts if t.strip()] + + @staticmethod + def _parse_slots(time_str): if not time_str: return [] slots = [] @@ -461,216 +486,152 @@ class TeacherStudentMatcher: if slot and '-' in slot: parts = slot.split('-') if len(parts) == 2: - start, end = parts - slots.append((start, end)) + slots.append((parts[0].strip(), parts[1].strip())) 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 + self._show_snackbar("请先导入老师信息", C_WARNING) return - if not self.students: - self.page.snack_bar = ft.SnackBar( - content=ft.Text("请先导入学生信息"), - bgcolor="#f59e0b", - ) - self.page.snack_bar.open = True + self._show_snackbar("请先导入学生信息", C_WARNING) return try: + c = self.match_conditions + active = [k for k, v in c.items() if v] + self.add_log(f"开始匹配 · 启用 {len(active)} 项规则", "info") + self.matches = [] + teacher_days = {t["id"]: set() for t in self.teachers} + teacher_count = {t["id"]: 0 for t in self.teachers} + student_hist = [list(s["history"]) for s in self.students] - 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 idx, student in enumerate(self.students): + possible = [] + hl = student_hist[idx] + h_len = len(hl) + exp_type = "旧" if h_len == 0 else ("新" if h_len % 2 == 1 else "旧") for teacher in self.teachers: - if teacher["id"] in student["history"]: + if c["history_avoidance"] and teacher["id"] in hl: continue - - if teacher["type"] != expected_teacher_type: + if c["teacher_type_rule"] and teacher["type"] != exp_type: continue - - for day, student_time in student["availability"].items(): + for day, stime in student["availability"].items(): if day not in teacher["availability"]: continue - - if day in teacher_assigned_days[teacher["id"]]: + if c["daily_unique"] and day in teacher_days[teacher["id"]]: continue + ttime = teacher["availability"][day] + if not stime or not ttime: + continue + s_slots = self._parse_slots(stime) + t_slots = self._parse_slots(ttime) + for ss, se in s_slots: + for ts, te in t_slots: + ok = False + if c["time_match"]: + if c["all_day_match"]: + ok = (ss == ts and se == te) or (ss == '00:00' and se == '24:00') + else: + ok = (ss == ts and se == te) + else: + ok = True + if ok: + possible.append((teacher, day, ts, te)) - teacher_time = teacher["availability"][day] + if possible: + if c["balanced_assignment"]: + possible.sort(key=lambda x: teacher_count[x[0]["id"]]) + teacher, day, ts, te = possible[0] + self.matches.append({"student_name": student["name"], "teacher_name": teacher["name"], "match_date": day, "match_period": f"{ts}-{te}", "status": "成功"}) + student_hist[idx].append(teacher["id"]) + if c["daily_unique"]: + teacher_days[teacher["id"]].add(day) + teacher_count[teacher["id"]] += 1 + else: + self.matches.append({"student_name": student["name"], "teacher_name": "-", "match_date": "-", "match_period": "-", "status": "未匹配"}) - if student_time and teacher_time: - student_slots = self.parse_time_slots(student_time) - teacher_slots = self.parse_time_slots(teacher_time) + self._refresh_result_table() + ok_count = len([m for m in self.matches if m["status"] == "成功"]) + total = len(self.matches) + self.status_text.value = f"成功 {ok_count} / {total}" + self.status_text.color = C_SUCCESS if ok_count == total else C_WARNING + self.page.update() - 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 + if ok_count == total: + self.add_log(f"匹配完成 · 全部 {ok_count} 人成功", "success") + else: + self.add_log(f"匹配完成 · {ok_count}/{total} 成功", "warning") + self._show_snackbar(f"匹配完成:{ok_count}/{total} 成功") except Exception as ex: - self.page.snack_bar = ft.SnackBar( - content=ft.Text(f"匹配失败: {str(ex)}"), - bgcolor="#ef4444", - ) - self.page.snack_bar.open = True + self.add_log(f"匹配失败: {ex}", "error") + self._show_snackbar(f"匹配失败: {ex}", C_ERROR) - def update_result_table(self): + def _refresh_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), - )), - ] - ) - ) + for i, m in enumerate(self.matches): + ok = m["status"] == "成功" + bg = C_ROW_ALT if i % 2 == 0 else C_CARD + dot_color = C_SUCCESS if ok else C_ERROR + status_icon = ft.Icons.CHECK_CIRCLE_ROUNDED if ok else ft.Icons.CANCEL_ROUNDED + self.result_table.rows.append(ft.DataRow( + color=bg, + cells=[ + ft.DataCell(ft.Text(m["student_name"], size=13, color=C_TEXT)), + ft.DataCell(ft.Text(m["teacher_name"], size=13, color=C_TEXT)), + ft.DataCell(ft.Text(m["match_date"], size=12, color=C_TEXT_SEC)), + ft.DataCell(ft.Text(m["match_period"], size=12, color=C_TEXT_SEC)), + ft.DataCell(ft.Row( + controls=[ + ft.Icon(status_icon, size=14, color=dot_color), + ft.Text(m["status"], size=12, color=dot_color), + ], + spacing=4, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + )), + ], + )) 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 + self._show_snackbar("没有匹配结果可导出", C_WARNING) 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: + path = self._ask_save() + if not 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 + df.to_excel(path, index=False) + self.add_log(f"导出成功: {path}", "success") + self._show_snackbar("导出成功") + except PermissionError: + self.add_log(f"导出失败: 权限不足", "error") + self._show_snackbar("导出失败: 权限不足", C_ERROR) except Exception as ex: - self.page.snack_bar = ft.SnackBar( - content=ft.Text(f"导出失败: {str(ex)}"), - bgcolor="#ef4444", - ) - self.page.snack_bar.open = True + self.add_log(f"导出失败: {ex}", "error") + self._show_snackbar(f"导出失败: {ex}", C_ERROR) 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 = C_TEXT_MUTED + self.page.update() + self._show_snackbar("已清空所有数据") - 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)