# 老师学员匹配系统 - 开发文档 ## 项目概述 一个用于老师和学生智能匹配的桌面应用程序,基于Flet框架开发,支持时间兼容性检查、历史匹配记录规避、新/旧老师交替匹配、每日唯一性约束等功能。 ## 技术栈 | 技术 | 版本 | 用途 | |------|------|------| | Python | 3.13+ | 开发语言 | | Flet | 0.80.4+ | UI框架(基于Flutter) | | pandas | 2.3+ | Excel数据处理 | | openpyxl | 3.1+ | Excel文件读写 | | PyInstaller | 6.16+ | 打包工具 | ## 项目结构 ``` paike/ ├── main.py # 主程序入口 ├── build.bat # 打包脚本 ├── requirements.txt # 依赖列表(可选) ├── 老师信息示例.xlsx # 老师数据示例 ├── 学生信息示例.xlsx # 学生数据示例 ├── generate_test_data.py # 测试数据生成脚本 ├── dist/ # 打包输出目录 │ ├── main.exe # 打包后的可执行文件 │ ├── 老师信息示例.xlsx │ └── 学生信息示例.xlsx └── build/ # PyInstaller构建缓存 ``` ## 核心功能需求 ### 1. 数据导入 - 从Excel文件导入老师信息(包含老师类型) - 从Excel文件导入学生信息(包含历史匹配) - 支持的文件格式:`.xlsx` ### 2. 匹配规则(按优先级) | 优先级 | 规则 | 类型 | 说明 | |-------|------|------|------| | 1 | 首次上课限制 | 硬约束 | 学生第一次上课只能匹配旧老师 | | 2 | 交替匹配规则 | 硬约束 | 后续上课按"新老师→旧老师→新老师→..."交替 | | 3 | 历史匹配规避 | 硬约束 | 学生不能匹配之前上过课的老师 | | 4 | 每日唯一性 | 硬约束 | 每位老师每天只能接待一位学生 | | 5 | 均衡分配 | 软约束 | 优先匹配次数少的老师 | ### 3. 结果导出 - 导出匹配结果到Excel文件 - 用户可选择保存位置 - 中文列标题 ## 数据格式规范 ### 老师信息Excel格式 | 列名 | 数据类型 | 说明 | 示例 | |------|----------|------|------| | 老师姓名 | 字符串 | 老师唯一标识 | 张1老师 | | 老师类型 | 字符串 | 新老师/旧老师 | 旧老师 | | 备注 | 字符串 | 备注信息(可选) | 数学老师 | | 1月1日 | 字符串 | 可用时间段(分号分隔) | 9:00-10:30; 14:00-15:30 | | 1月2日 | 字符串 | 可用时间段(分号分隔) | 10:30-12:00 | | ... | ... | ... | ... | **注意**: - 时间段格式:`开始时间-结束时间` - 多个时间段用分号`;`分隔 - 没有可用时间的日期留空 ### 学生信息Excel格式 | 列名 | 数据类型 | 说明 | 示例 | |------|----------|------|------| | 学生姓名 | 字符串 | 学生唯一标识 | 学生1 | | 历史匹配老师 | 字符串 | 历史老师姓名(逗号分隔) | 张1老师,张2老师 | | 备注 | 字符串 | 备注信息(可选) | | | 1月1日 | 字符串 | 可用时间段(分号分隔) | 9:00-10:30; 14:00-15:30 | | 1月2日 | 字符串 | 可用时间段(分号分隔) | 10:30-12:00 | | ... | ... | ... | ... | ### 匹配结果Excel格式 | 列名 | 说明 | |------|------| | 学生 | 学生姓名 | | 老师 | 匹配的老师姓名 | | 日期 | 匹配日期 | | 时段 | 匹配时间段 | | 状态 | 成功/未匹配 | ## 代码架构 ### 主类:TeacherStudentMatcher ```python class TeacherStudentMatcher: def __init__(self, page: ft.Page): # 初始化页面配置 self.page.title = "老师学员匹配系统" self.page.window_width = 1400 self.page.window_height = 800 # 数据存储 self.teachers = [] # 老师列表 self.students = [] # 学生列表 self.matches = [] # 匹配结果 ``` ### 核心数据结构 ```python # 老师数据结构 teacher = { "id": "老师姓名", "name": "老师姓名", "type": "旧老师", # 新增:老师类型 "availability": { "1月1日": "9:00-10:30; 14:00-15:30", "1月2日": "10:30-12:00", # ... }, "other": "备注信息" } # 学生数据结构 student = { "id": "学生姓名", "name": "学生姓名", "availability": { "1月1日": "9:00-10:30; 14:00-15:30", # ... }, "history": ["张1老师", "张2老师"], # 历史匹配老师列表 "other": "备注信息" } # 匹配结果结构 match = { "student_name": "学生姓名", "teacher_name": "老师姓名", "match_date": "1月1日", "match_period": "9:00-10:30", "status": "成功" } ``` ### 核心方法 #### 1. UI初始化 ```python def setup_ui(self): # 创建页面布局:三栏布局 # - 顶部标题栏 # - 操作按钮栏 # - 主内容区(老师信息 | 学生信息 | 匹配结果) ``` #### 2. 数据导入 ```python def import_teachers(self, e): # 使用tkinter文件对话框选择Excel文件 # 解析Excel数据,提取老师信息 # 动态列(日期)作为可用时间段存储 # 新增:解析"老师类型"字段 def import_students(self, e): # 使用tkinter文件对话框选择Excel文件 # 解析Excel数据,提取学生信息 # 历史匹配老师用逗号分隔 # 动态列(日期)作为可用时间段存储 ``` #### 3. 时间段解析 ```python def parse_time_slots(self, time_str): # 输入: "9:00-10:30; 14:00-15:30" # 输出: [("9:00", "10:30"), ("14:00", "15:30")] # # 解析逻辑: # 1. 按分号分割时间段 # 2. 每个时间段按短横线分割起始和结束时间 # 3. 返回时间对元组列表 ``` #### 4. 匹配算法 ```python def start_match(self, e): # 匹配流程: # 1. 初始化数据结构 # - teacher_assigned_days: 记录老师已分配的日期(新增) # - teacher_match_count: 记录每个老师已匹配的学生数 # # 2. 遍历每个学生 # a. 计算期望老师类型(新增逻辑) # - 历史记录为空 → 旧老师 # - 历史记录奇数 → 新老师 # - 历史记录偶数 → 旧老师 # b. 收集所有可能的匹配 # - 遍历所有老师 # - 跳过历史匹配过的老师 # - 检查老师类型是否符合期望(新增) # - 检查时间兼容性 # - 检查老师该日期是否已被分配(新增:按日期而非时段) # # c. 选择最优匹配 # - 按老师已匹配次数升序排序(优先选择匹配次数少的老师) # - 选择第一个(匹配次数最少的) # # d. 更新状态 # - 记录匹配结果 # - 更新学生的历史匹配记录 # - 标记老师该日期已分配(新增:按日期) # - 增加老师的匹配计数 # # e. 处理未匹配情况 # - 如果没有可用的匹配,记录状态为"未匹配" ``` #### 5. 结果导出 ```python def export_results(self, e): # 使用tkinter文件对话框让用户选择保存位置 # 导出为Excel文件,中文列标题 ``` ## 匹配算法详解 ### 贪心匹配策略 本系统采用贪心算法实现匹配,优先考虑: 1. **首次上课限制**(硬约束) - 如果学生没有历史匹配记录,只能匹配旧老师 - 确保新学生由经验丰富的老师指导 2. **交替匹配规则**(硬约束) - 根据历史匹配次数的奇偶性决定期望老师类型 - 历史次数为偶数(包括0)→ 期望旧老师 - 历史次数为奇数 → 期望新老师 - 实现新/旧老师交替教学 3. **历史匹配规避**(硬约束) - 如果学生与某老师有过匹配记录,跳过该老师 - 确保每个学生接触不同的老师 4. **每日唯一性**(硬约束) - 每位老师每天只能接待一位学生 - 使用`teacher_assigned_days`字典追踪已分配日期 - 老师某天被匹配后,当天其他时段不再接受预约 5. **时间兼容性**(硬约束) - 学生和老师必须在同一天有相同的时间段 - 时间段必须完全一致(如都是9:00-10:30) 6. **均衡分配**(软约束/优化目标) - 优先选择匹配次数较少的老师 - 让更多老师参与到匹配中 - 使用`teacher_match_count`字典实现 ### 伪代码 ``` for each student in students: possible_matches = [] # 计算期望老师类型(新增逻辑) history_count = len(student.history) if history_count == 0: expected_type = "旧老师" # 首次上课 elif history_count % 2 == 1: expected_type = "新老师" # 第2,4,6...次 else: expected_type = "旧老师" # 第3,5,7...次 for each teacher in teachers: if teacher in student.history: continue # 跳过历史老师 if teacher.type != expected_type: continue # 跳过类型不匹配的老师(新增) for each (day, student_time) in student.availability: if day not in teacher.availability: continue if day in teacher_assigned_days[teacher]: continue # 老师当天已被预约(新增) teacher_time = teacher.availability[day] if student_time and teacher_time: student_slots = parse_time_slots(student_time) teacher_slots = parse_time_slots(teacher_time) for each (s_start, s_end) in student_slots: for each (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]]) teacher, day, s_start, s_end = possible_matches[0] # 创建匹配记录 create_match(student, teacher, day, s_start, s_end, "成功") # 更新状态 student.history.append(teacher) teacher_assigned_days[teacher].add(day) # 标记日期已分配(修改) teacher_match_count[teacher] += 1 else: # 没有可用匹配 create_match(student, null, "-", "-", "-", "未匹配") ``` ## UI设计规范 ### 配色方案 | 用途 | 颜色代码 | 说明 | |------|----------|------| | 标题文字 | #1a1a2e | 深色标题 | | 主色调 | #2563eb | 主要操作按钮(蓝色) | | 成功色 | #10b981 | 成功状态(绿色) | | 警告色 | #f59e0b | 警告状态(橙色) | | 错误色 | #ef4444 | 错误状态(红色) | | 背景色 | #f8f9fa | 页面背景 | | 卡片背景 | #ffffff | 卡片白色背景 | | 边框色 | #f0f0f0 | 细微边框 | | 新老师标识 | #2563eb | 蓝色 | | 旧老师标识 | #10b981 | 绿色 | ### 布局结构 ``` ┌─────────────────────────────────────────────────────────────────┐ │ 老师学员匹配系统 [按钮组] │ │ Teacher-Student Matching System │ ├─────────────────────────────────────────────────────────────────┤ │ 数据操作 │ 导入老师 │ 导入学生 │ 匹配操作 │ 开始匹配 │ 导出 │ 清空 │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────────────┐ ┌─────────────────┐ ┌─────────────┐ │ │ │ 老师信息 │ │ 学生信息 │ │ 匹配结果 │ │ │ │ Teacher Info │ │ Student Info │ │ Matching R. │ │ │ ├─────────────────────┤ ├─────────────────┤ ├─────────────┤ │ │ │ 姓名 │ 类型 │ 备注 │ │ 姓名│历史│备注 │ │ 学生│老师│...│ │ │ ├─────────────────────┤ ├─────────────────┤ ├─────────────┤ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─────────────────────┘ └─────────────────┘ └─────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘ ``` ### 窗口配置 ```python self.page.window_width = 1400 # 窗口宽度 self.page.window_height = 800 # 窗口高度 self.page.theme_mode = ft.ThemeMode.LIGHT # 浅色主题 ``` ## Flet API兼容性说明 ### Flet 0.80.4 版本API 本项目针对Flet 0.80.4版本编写,使用了以下兼容API: ```python # 颜色使用字符串而非ft.colors枚举 ft.Text("text", color="#333333") # ✓ 正确 ft.Text("text", color=ft.colors.WHITE) # ✗ 已废弃 # 对齐使用Alignment类 ft.alignment.Alignment(0, 0) # ✓ 正确 ft.alignment.top_left # ✗ 已废弃 # 边框使用Border类 ft.Border.all(1, "#e0e0e0") # ✓ 正确 ft.border.all(1, "#e0e0e0") # ✗ 已废弃 # 内边距使用Padding类 ft.Padding.only(left=20, right=20) # ✓ 正确 ft.padding.only(left=20, right=20) # ✗ 已废弃 # 外边距使用Margin类 ft.Margin.only(left=20, right=20) # ✓ 正确 ft.margin.only(left=20, right=20) # ✗ 已废弃 # 按钮使用Button而非ElevatedButton ft.Button(...) # ✓ 正确 ft.ElevatedButton(...) # ✗ 已废弃 # SnackBar使用page.snack_bar属性 self.page.snack_bar = ft.SnackBar(...) self.page.snack_bar.open = True # ✓ 正确 self.page.show_snack_bar(...) # ✗ 已废弃 # 应用入口使用ft.run ft.run(main) # ✓ 正确 ft.app(target=main) # ✗ 已废弃 # 表格边框使用BorderSide ft.border.BorderSide(1, "#e0e0e0") # ✓ 正确 ``` ## 文件选择对话框 由于Flet的FilePicker在不同版本中存在兼容性问题,本项目使用tkinter的filedialog作为替代: ```python import threading from tkinter import filedialog import tkinter as tk 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', '') ``` ## 安装和运行 ### 开发环境运行 ```bash pip install flet pandas openpyxl python main.py ``` ### 打包为exe ```batch @echo off pip install pyinstaller pandas openpyxl flet pyinstaller --onefile --windowed main.py ``` ### 打包配置优化 排除不必要的模块以减小打包体积: ```batch pyinstaller --onefile --windowed ^ --exclude-module=torch ^ --exclude-module=torchvision ^ --exclude-module=transformers ^ --exclude-module=scipy ^ --exclude-module=matplotlib ^ --exclude-module=pandas.plotting ^ main.py ``` ## 测试数据生成 ```python 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'] # 生成10个老师(5旧5新) teacher_names = [f"张{i}老师" for i in range(1, 11)] teacher_types = ["旧老师", "旧老师", "旧老师", "新老师", "新老师", "旧老师", "新老师", "旧老师", "新老师", "旧老师"] teachers = [] for i, name in enumerate(teacher_names): teacher = {"老师姓名": name} teacher["老师类型"] = teacher_types[i] available_days = random.sample(days, 2) # 每人随机2天空闲 for day in days: if day in available_days: num_slots = random.randint(2, 3) # 每天2-3个时间段 teacher[day] = '; '.join(random.sample(time_slots, num_slots)) else: teacher[day] = '' teacher["备注"] = "" teachers.append(teacher) df_teachers = pd.DataFrame(teachers) df_teachers.to_excel('老师信息示例.xlsx', index=False) # 生成20个学生 student_names = [f"学生{i}" for i in range(1, 21)] students = [] for i, name in enumerate(student_names): student = {"学生姓名": name} available_days = random.sample(days, random.randint(3, 5)) # 3-5天空闲 for day in days: if day in available_days: num_slots = random.randint(2, 3) student[day] = '; '.join(random.sample(time_slots, num_slots)) else: student[day] = '' # 每个学生有0-3个历史老师 history_count = min(3, i) history_teachers = random.sample([t for t in teacher_names if t != name], history_count) student["历史匹配老师"] = ', '.join(history_teachers) student["备注"] = "" students.append(student) df_students = pd.DataFrame(students) df_students.to_excel('学生信息示例.xlsx', index=False) ``` ## 常见问题排查 ### 1. 打包后文件选择对话框不显示 - 确保使用tkinter的filedialog而非Flet的FilePicker - 在独立线程中运行对话框 ### 2. SnackBar不显示 - 使用`page.snack_bar`属性而非`show_snack_bar()`方法 - 设置`open = True`来显示 ### 3. 表格不显示数据 - 确保调用`page.update()`刷新界面 - 检查数据是否正确解析 ### 4. 匹配结果不正确 - 检查时间段格式是否正确(使用分号分隔) - 检查历史老师名称是否与老师姓名完全匹配 - 检查老师类型是否正确标记 ## 项目交付清单 - [x] main.py - 主程序 - [x] build.bat - 打包脚本 - [x] 需求文档.md - 需求说明文档 - [x] 开发文档.md - 技术实现文档 - [x] 老师信息示例.xlsx - 老师测试数据 - [x] 学生信息示例.xlsx - 学生测试数据 - [x] generate_test_data.py - 测试数据生成脚本 - [x] dist/main.exe - 打包后的可执行文件 ## 版本历史 | 版本 | 日期 | 更新内容 | |------|------|----------| | 1.0 | 2026-04-19 | 初始版本,实现核心匹配功能 | | 1.1 | 2026-04-25 | 新增每日唯一性约束(老师每天只能接待一位学生) | | 1.2 | 2026-04-28 | 新增老师类型标记、首次上课限制、交替匹配规则 |