import flet as ft import pandas as pd import re import threading import asyncio 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), expand=True, ) 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 = 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, 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._build_ui() def _build_ui(self): self.page.clean() top_bar = ft.Container( content=ft.Row( controls=[ ft.Row( controls=[ 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=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.SPACE_BETWEEN, vertical_alignment=ft.CrossAxisAlignment.CENTER, ), padding=ft.Padding.only(left=24, right=24, top=10, bottom=10), bgcolor=C_CARD, border=ft.Border(bottom=ft.BorderSide(1, C_BORDER)), ) cond_items = [ ("time_match", "时间匹配"), ("all_day_match", "全天匹配"), ("history_avoidance", "历史规避"), ("daily_unique", "每日唯一"), ("balanced_assignment", "均衡分配"), ("teacher_type_rule", "新旧交替"), ] self._cond_refs = {} cond_controls = [] for key, label in cond_items: sw = ft.Switch( value=self.match_conditions[key], on_change=lambda e, k=key: self._on_condition_change(k, e), active_color=C_PRIMARY, inactive_track_color=C_BORDER, active_track_color="#bfdbfe", inactive_thumb_color="#ffffff", height=24, width=40, ) self._cond_refs[key] = sw cond_controls.append( ft.Container( content=ft.Row( controls=[sw, 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=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.STRETCH, ) 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)), ) main_content = ft.Column( controls=[top_bar, toolbar, main_columns, self._log_bar, self._log_body], spacing=0, expand=True, ) self.page.add(main_content) self.add_log("系统就绪", "info") def _set_table_loading(self, table, col_count, text): loading_cell = ft.DataCell( ft.Container( content=ft.Row( controls=[ ft.ProgressRing(width=16, height=16, stroke_width=2, color=C_PRIMARY), ft.Text(text, size=11, color=C_TEXT_SEC, weight=ft.FontWeight.W_500), ], alignment=ft.MainAxisAlignment.CENTER, spacing=8, ), padding=ft.Padding.all(24), ) ) table.rows.clear() table.rows.append(ft.DataRow(cells=[loading_cell] * col_count)) self.page.update() def _clear_table_loading(self, table): table.rows.clear() self.page.update() 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): 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): 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() async def import_teachers(self, e): path = self._ask_file("选择老师信息文件") if not path: return self._set_table_loading(self.teacher_table, 2, "正在导入老师信息...") try: df = await asyncio.get_event_loop().run_in_executor(None, pd.read_excel, path) 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 = "旧" teachers.append({"id": row["老师姓名"], "name": row["老师姓名"], "type": ttype, "availability": avail}) self.teachers = teachers self._refresh_teacher_table() self.add_log(f"导入 {len(self.teachers)} 位老师: {', '.join([t['name'] for t in self.teachers[:5]])}{'...' if len(self.teachers) > 5 else ''}", "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) finally: self._clear_table_loading(self.teacher_table) self._refresh_teacher_table() async def import_students(self, e): path = self._ask_file("选择学生信息文件") if not path: return self._set_table_loading(self.student_table, 2, "正在导入学生信息...") try: df = await asyncio.get_event_loop().run_in_executor(None, pd.read_excel, path) 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} students.append({"id": row["学生姓名"], "name": row["学生姓名"], "availability": avail, "history": history}) self.students = students self._refresh_student_table() self.add_log(f"导入 {len(self.students)} 名学生, 历史记录分布: {dict(sorted({k: sum(1 for s in self.students if len(s['history']) == k) for k in set(len(s['history']) for s in self.students)}.items()))}", "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) finally: self._clear_table_loading(self.student_table) self._refresh_student_table() def _refresh_teacher_table(self): self.teacher_table.rows = [] 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 _refresh_student_table(self): self.student_table.rows = [] for i, s in enumerate(self.students): bg = C_ROW_ALT if i % 2 == 0 else C_CARD h = ", ".join(s["history"][:6]) if len(s["history"]) > 6: h += f" +{len(s['history']) - 6}" 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() @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 = [] for slot in time_str.split(';'): slot = slot.strip() if slot and '-' in slot: parts = slot.split('-') if len(parts) == 2: slots.append((parts[0].strip(), parts[1].strip())) return slots async def start_match(self, e): if not self.teachers: self._show_snackbar("请先导入老师信息", C_WARNING) return if not self.students: self._show_snackbar("请先导入学生信息", C_WARNING) return self._set_table_loading(self.result_table, 5, "正在匹配中...") try: c = dict(self.match_conditions) active = [k for k, v in c.items() if v] self.add_log(f"开始匹配 · 启用 {len(active)} 项规则", "info") 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_type_map = {t["id"]: t["type"] for t in self.teachers} for idx, student in enumerate(self.students): possible = [] hl = student_hist[idx] h_len = len(hl) if h_len == 0: exp_type = "旧" else: last_teacher_name = hl[-1].strip().lower() last_type = None for name, ttype in teacher_type_map.items(): if name.strip().lower() == last_teacher_name: last_type = ttype break if last_type is None or last_type == "新": exp_type = "旧" else: exp_type = "新" hl_normalized = {t.strip().lower() for t in hl} for teacher in self.teachers: if c["history_avoidance"]: tid_norm = teacher["id"].strip().lower() if tid_norm in hl_normalized: continue if c["teacher_type_rule"] and not (not c["history_avoidance"] and h_len > 0): if teacher["type"] != exp_type: continue for day, stime in student["availability"].items(): if day not in teacher["availability"]: continue 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)) if possible: if c["balanced_assignment"]: possible.sort(key=lambda x: teacher_count[x[0]["id"]]) teacher, day, ts, te = possible[0] if c["history_avoidance"]: tid_norm = teacher["id"].strip().lower() if tid_norm in hl_normalized: self.add_log(f"⚠ 历史规避异常: {student['name']} 历史有 {teacher['id']} 但仍被匹配", "warning") 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: matches.append({"student_name": student["name"], "teacher_name": "-", "match_date": "-", "match_period": "-", "status": "未匹配"}) self.matches = matches 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() 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.add_log(f"匹配失败: {ex}", "error") self._show_snackbar(f"匹配失败: {ex}", C_ERROR) finally: self._clear_table_loading(self.result_table) self._refresh_result_table() def _refresh_result_table(self): self.result_table.rows = [] 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._show_snackbar("没有匹配结果可导出", C_WARNING) return path = self._ask_save() if not path: return try: df = pd.DataFrame(self.matches) df.columns = ["学生", "老师", "日期", "时段", "状态"] 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.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("已清空所有数据") def main(page: ft.Page): TeacherStudentMatcher(page) if __name__ == "__main__": ft.run(main)