Files
HuiPaiKe/开发文档.md
2026-05-07 14:56:53 +08:00

558 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 老师学员匹配系统 - 开发文档
## 项目概述
一个用于老师和学生智能匹配的桌面应用程序基于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 | 新增老师类型标记、首次上课限制、交替匹配规则 |