638 lines
26 KiB
Python
638 lines
26 KiB
Python
import flet as ft
|
||
import pandas as pd
|
||
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 = 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:
|
||
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=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)),
|
||
)
|
||
|
||
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)
|
||
|
||
self.add_log("系统就绪", "info")
|
||
|
||
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):
|
||
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):
|
||
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)
|
||
|
||
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"][: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()
|
||
|
||
@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
|
||
|
||
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
|
||
|
||
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]
|
||
|
||
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 c["history_avoidance"] and teacher["id"] in hl:
|
||
continue
|
||
if c["teacher_type_rule"] and 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]
|
||
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": "未匹配"})
|
||
|
||
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)
|
||
|
||
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)
|