Files
HuiPaiKe/main.py

638 lines
26 KiB
Python
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.
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)