#!/usr/bin/env python3-- coding: utf-8 --"""监考大屏系统 - Python GUI 版本基于 tkinter + ttkbootstrap 实现,功能对标 H5 版本"""import jsonimport osimport timeimport threadingimport uuidfrom datetime import datetime, timedeltafrom pathlib import Pathtry:import ttkbootstrap as ttkfrom ttkbootstrap.constants import *from ttkbootstrap.dialogs import Messagebox, Queryboxfrom ttkbootstrap.scrolled import ScrolledFrameimport tkinter as tkHAS_BOOTSTRAP = Trueexcept ImportError:import tkinter as tkfrom tkinter import ttk, messageboxHAS_BOOTSTRAP = Falseimport tkinter.filedialog as filedialog==================== 数据层 ====================DATA_FILE = Path(file).parent / "exam_data.json"NOTIF_FILE = Path(file).parent / "exam_notifications.json"SETTINGS_FILE = Path(file).parent / "exam_settings.json"def load_json(path, default=None):if default is None:default = {}try:if path.exists():with open(path, "r", encoding="utf-8") as f:return json.load(f)except Exception:passreturn defaultdef save_json(path, data):with open(path, "w", encoding="utf-8") as f:json.dump(data, f, ensure_ascii=False, indent=2)def generate_id():return uuid.uuid4().hex[:12]def format_datetime(dt_str):"""格式化日期时间字符串"""try:dt = datetime.fromisoformat(dt_str)return dt.strftime("%Y-%m-%d %H:%M")except Exception:return dt_strdef format_time(dt_str):"""格式化时间"""try:dt = datetime.fromisoformat(dt_str)return dt.strftime("%H:%M:%S")except Exception:return dt_strdef format_countdown(seconds):"""格式化倒计时"""if seconds < 0:seconds = 0seconds = int(seconds)h = seconds // 3600m = (seconds % 3600) // 60s = seconds % 60if h > 0:return f"{h:02d}:{m:02d}:{s:02d}"return f"{m:02d}:{s:02d}"def get_exam_status(exam):"""获取考试状态"""now = datetime.now()subjects = exam.get("subjects", [])if not subjects:return "waiting"has_ongoing = Falsehas_future = Falseall_ended = Truefor s in subjects:try:start = datetime.fromisoformat(s["start"])end = datetime.fromisoformat(s["end"])except Exception:continueif start <= now < end:has_ongoing = Trueall_ended = Falseif now < start:has_future = Trueall_ended = Falseif has_ongoing:return "ongoing"if has_future:return "waiting"if all_ended and subjects:return "ended"return "waiting"def get_current_subject(exam):"""获取当前科目"""now = datetime.now()subjects = exam.get("subjects", [])for s in subjects:try:start = datetime.fromisoformat(s["start"])end = datetime.fromisoformat(s["end"])if start <= now < end:return sexcept Exception:continue# 找最近的未开始科目nearest = Nonediff = float("inf")for s in subjects:try:start = datetime.fromisoformat(s["start"])if start > now and (start - now).total_seconds() < diff:diff = (start - now).total_seconds()nearest = sexcept Exception:continuereturn nearest==================== 主应用类 ====================class ExamMonitorApp:"""监考大屏系统主应用"""def __init__(self): self.exams = load_json(DATA_FILE, {"exams": []}).get("exams", []) self.notifications = load_json(NOTIF_FILE, []) self.settings = load_json(SETTINGS_FILE, {"theme": "cosmo"}) self.current_exam_id = None self.screen_running = False self.announced = {} # 创建主窗口 if HAS_BOOTSTRAP: theme = self.settings.get("theme", "cosmo") self.root = ttk.Window( title="📚 监考大屏系统", themename=theme, size=(1200, 750), minsize=(900, 600), ) else: self.root = tk.Tk() self.root.title("📚 监考大屏系统") self.root.geometry("1200x750") self.root.minsize(900, 600) self.root.protocol("WM_DELETE_WINDOW", self.on_close) # 构建UI self._build_header() self._build_notebook() self._build_list_page() self._build_form_page() self._build_screen_page() # 初始渲染 self.refresh_list() # 启动定时器 self._tick()def _tick(self): """全局定时器,每秒更新大屏""" if self.screen_running: self._update_screen() self.root.after(1000, self._tick)def on_close(self): """关闭时保存数据""" self.save_all() self.root.destroy()def save_all(self): """保存所有数据""" save_json(DATA_FILE, {"exams": self.exams}) save_json(NOTIF_FILE, self.notifications) save_json(SETTINGS_FILE, self.settings)# ==================== 头部区域 ====================def _build_header(self): header = ttk.Frame(self.root, padding=10) header.pack(fill=X) ttk.Label( header, text="📚 监考大屏系统", font=("", 18, "bold") ).pack(side=LEFT) # 右侧按钮组 right_frame = ttk.Frame(header) right_frame.pack(side=RIGHT) if HAS_BOOTSTRAP: themes = ["cosmo", "darkly", "journal", "flatly", "superhero"] ttk.Label(right_frame, text="主题:").pack(side=LEFT, padx=(0, 5)) self.theme_var = ttk.StringVar(value=self.settings.get("theme", "cosmo")) theme_cb = ttk.Combobox( right_frame, textvariable=self.theme_var, values=themes, width=10, state="readonly" ) theme_cb.pack(side=LEFT, padx=(0, 10)) theme_cb.bind("<<ComboboxSelected>>", self._change_theme) ttk.Button( right_frame, text="+ 新建考试", command=self._open_create_form, bootstyle="primary" if HAS_BOOTSTRAP else None ).pack(side=LEFT, padx=5)def _change_theme(self, event=None): """切换主题""" if HAS_BOOTSTRAP: theme = self.theme_var.get() self.settings["theme"] = theme self.root.style.theme_use(theme) save_json(SETTINGS_FILE, self.settings)# ==================== Notebook 页面 ====================def _build_notebook(self): self.notebook = ttk.Notebook(self.root) self.notebook.pack(fill=BOTH, expand=True, padx=10, pady=(0, 10)) self.list_frame = ttk.Frame(self.notebook, padding=10) self.form_frame = ttk.Frame(self.notebook, padding=10) self.screen_frame = ttk.Frame(self.notebook, padding=0) self.notebook.add(self.list_frame, text="📋 考试列表") self.notebook.add(self.form_frame, text="✏️ 编辑考试") self.notebook.add(self.screen_frame, text="🖥️ 监控大屏") self.notebook.bind("<<NotebookTabChanged>>", self._on_tab_change)def _on_tab_change(self, event=None): idx = self.notebook.index("current") if idx == 0: self.refresh_list() self.screen_running = False elif idx == 2: self.screen_running = True self._update_screen() else: self.screen_running = False# ==================== 列表页 ====================def _build_list_page(self): # 搜索栏 top_bar = ttk.Frame(self.list_frame) top_bar.pack(fill=X, pady=(0, 10)) ttk.Label(top_bar, text="📋 所有考试", font=("", 14, "bold")).pack(side=LEFT) self.search_var = ttk.StringVar() self.search_var.trace_add("write", lambda *_: self.refresh_list()) search_entry = ttk.Entry(top_bar, textvariable=self.search_var, width=25) search_entry.pack(side=RIGHT, padx=5) ttk.Label(top_bar, text="搜索:").pack(side=RIGHT) # 表格区域 columns = ("name", "school", "subjects", "status", "created") self.tree = ttk.Treeview( self.list_frame, columns=columns, show="headings", height=12 ) self.tree.heading("name", text="考试名称") self.tree.heading("school", text="学校") self.tree.heading("subjects", text="科目数") self.tree.heading("status", text="状态") self.tree.heading("created", text="创建时间") self.tree.column("name", width=200) self.tree.column("school", width=150) self.tree.column("subjects", width=80, anchor=CENTER) self.tree.column("status", width=100, anchor=CENTER) self.tree.column("created", width=160) scrollbar = ttk.Scrollbar(self.list_frame, orient=VERTICAL, command=self.tree.yview) self.tree.configure(yscrollcommand=scrollbar.set) self.tree.pack(side=LEFT, fill=BOTH, expand=True) scrollbar.pack(side=RIGHT, fill=Y) # 操作按钮栏 btn_frame = ttk.Frame(self.list_frame) btn_frame.pack(fill=X, pady=(10, 0), side=BOTTOM) ttk.Button(btn_frame, text="🖥️ 进入大屏", command=self._enter_screen, bootstyle="success" if HAS_BOOTSTRAP else None).pack(side=LEFT, padx=3) ttk.Button(btn_frame, text="✏️ 编辑", command=self._edit_selected, bootstyle="info" if HAS_BOOTSTRAP else None).pack(side=LEFT, padx=3) ttk.Button(btn_frame, text="📢 发通知", command=self._send_notif_dialog, bootstyle="warning" if HAS_BOOTSTRAP else None).pack(side=LEFT, padx=3) ttk.Button(btn_frame, text="🗑️ 删除", command=self._delete_selected, bootstyle="danger" if HAS_BOOTSTRAP else None).pack(side=LEFT, padx=3) # 通知历史 notif_frame = ttk.LabelFrame(self.list_frame, text="📢 最近通知") notif_frame.pack(fill=X, pady=(10, 0), side=BOTTOM) self.notif_listbox = tk.Listbox(notif_frame, height=4, font=("", 10)) if not HAS_BOOTSTRAP else ttk.Treeview( notif_frame, columns=("content", "time"), show="headings", height=4 ) if HAS_BOOTSTRAP: self.notif_listbox.heading("content", text="内容") self.notif_listbox.heading("time", text="时间") self.notif_listbox.column("content", width=400) self.notif_listbox.column("time", width=160) self.notif_listbox.pack(fill=X)def refresh_list(self): """刷新考试列表""" for item in self.tree.get_children(): self.tree.delete(item) search = self.search_var.get().strip().lower() status_map = {"waiting": "等待开始", "ongoing": "进行中", "ended": "已结束"} for exam in self.exams: name = exam.get("name", "") if search and search not in name.lower(): continue school = exam.get("school", "") subj_count = len(exam.get("subjects", [])) status = get_exam_status(exam) created = format_datetime(exam.get("createdAt", "")) self.tree.insert("", END, iid=exam["id"], values=( name, school, subj_count, status_map.get(status, ""), created )) # 刷新通知列表 self._refresh_notifications()def _refresh_notifications(self): """刷新通知历史""" if HAS_BOOTSTRAP: for item in self.notif_listbox.get_children(): self.notif_listbox.delete(item) recent = self.notifications[-10:][::-1] for n in recent: self.notif_listbox.insert("", END, values=( n.get("content", ""), format_datetime(n.get("sentAt", "")) )) else: self.notif_listbox.delete(0, tk.END) recent = self.notifications[-10:][::-1] for n in recent: self.notif_listbox.insert(tk.END, f"{n.get('content', '')} ({format_datetime(n.get('sentAt', ''))})")def _get_selected_exam(self): """获取选中的考试""" sel = self.tree.selection() if not sel: self._show_msg("提示", "请先选择一个考试") return None exam_id = sel[0] for exam in self.exams: if exam["id"] == exam_id: return exam return Nonedef _enter_screen(self): """进入大屏""" exam = self._get_selected_exam() if not exam: return self.current_exam_id = exam["id"] self.announced = {} self.screen_running = True self.notebook.select(2) self._update_screen()def _edit_selected(self): """编辑选中考试""" exam = self._get_selected_exam() if not exam: return self._load_form(exam) self.notebook.select(1)def _delete_selected(self): """删除选中考试""" exam = self._get_selected_exam() if not exam: return if HAS_BOOTSTRAP: result = Messagebox.yesno("确定删除该考试吗?", "确认删除") if result != "Yes": return else: if not messagebox.askyesno("确认删除", "确定删除该考试吗?"): return self.exams = [e for e in self.exams if e["id"] != exam["id"]] self.save_all() self.refresh_list()def _send_notif_dialog(self): """发送通知对话框""" exam = self._get_selected_exam() if not exam: return self._open_notification_window(exam["id"])# ==================== 表单页 ====================def _build_form_page(self): self.form_edit_id = None # 滚动容器 if HAS_BOOTSTRAP: sf = ScrolledFrame(self.form_frame, autohide=True) sf.pack(fill=BOTH, expand=True) container = sf else: canvas = tk.Canvas(self.form_frame) scrollbar = ttk.Scrollbar(self.form_frame, orient=VERTICAL, command=canvas.yview) container = ttk.Frame(canvas) container.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) canvas.create_window((0, 0), window=container, anchor="nw") canvas.configure(yscrollcommand=scrollbar.set) canvas.pack(side=LEFT, fill=BOTH, expand=True) scrollbar.pack(side=RIGHT, fill=Y) # 标题 self.form_title_label = ttk.Label(container, text="新建考试", font=("", 16, "bold")) self.form_title_label.pack(anchor=W, pady=(0, 15)) # 考试名称 row1 = ttk.Frame(container) row1.pack(fill=X, pady=5) ttk.Label(row1, text="考试名称 *", font=("", 11)).pack(anchor=W) self.form_name_var = ttk.StringVar() ttk.Entry(row1, textvariable=self.form_name_var, font=("", 11)).pack(fill=X, pady=2) # 学校名称 row2 = ttk.Frame(container) row2.pack(fill=X, pady=5) ttk.Label(row2, text="学校名称", font=("", 11)).pack(anchor=W) self.form_school_var = ttk.StringVar() ttk.Entry(row2, textvariable=self.form_school_var, font=("", 11)).pack(fill=X, pady=2) # 科目列表 subj_label_frame = ttk.LabelFrame(container, text="科目时间表") subj_label_frame.pack(fill=BOTH, expand=True, pady=10) self.subjects_container = ttk.Frame(subj_label_frame) self.subjects_container.pack(fill=BOTH, expand=True) self.subject_widgets = [] ttk.Button( subj_label_frame, text="+ 添加科目", command=self._add_subject_row, bootstyle="outline" if HAS_BOOTSTRAP else None ).pack(anchor=W, pady=(10, 0)) # 按钮 btn_row = ttk.Frame(container) btn_row.pack(fill=X, pady=15) ttk.Button(btn_row, text="💾 保存", command=self._save_exam, bootstyle="success" if HAS_BOOTSTRAP else None).pack(side=RIGHT, padx=5) ttk.Button(btn_row, text="取消", command=self._cancel_form, bootstyle="secondary" if HAS_BOOTSTRAP else None).pack(side=RIGHT, padx=5)def _add_subject_row(self, data=None): """添加一行科目""" row_frame = ttk.Frame(self.subjects_container, padding=5) row_frame.pack(fill=X, pady=3) # 边框效果 if HAS_BOOTSTRAP: row_frame.configure(bootstyle="default") name_var = ttk.StringVar(value=data.get("name", "") if data else "") start_var = ttk.StringVar(value=data.get("start", "") if data else "") end_var = ttk.StringVar(value=data.get("end", "") if data else "") audio_var = ttk.StringVar(value=data.get("audioFile", "") if data else "") playtime_var = ttk.StringVar(value=data.get("audioPlayTime", "") if data else "") ttk.Label(row_frame, text="科目:").pack(side=LEFT, padx=2) ttk.Entry(row_frame, textvariable=name_var, width=10).pack(side=LEFT, padx=2) ttk.Label(row_frame, text="开始:").pack(side=LEFT, padx=2) ttk.Entry(row_frame, textvariable=start_var, width=16).pack(side=LEFT, padx=2) ttk.Label(row_frame, text="结束:").pack(side=LEFT, padx=2) ttk.Entry(row_frame, textvariable=end_var, width=16).pack(side=LEFT, padx=2) ttk.Label(row_frame, text="音频播放:").pack(side=LEFT, padx=2) ttk.Entry(row_frame, textvariable=playtime_var, width=16).pack(side=LEFT, padx=2) def browse_audio(): path = filedialog.askopenfilename( filetypes=[("音频文件", "*.mp3 *.wav *.ogg *.m4a"), ("所有文件", "*.*")] ) if path: audio_var.set(path) ttk.Button(row_frame, text="📁", command=browse_audio, width=3).pack(side=LEFT, padx=2) def remove_row(): if len(self.subject_widgets) <= 1: self._show_msg("提示", "至少保留一个科目") return row_frame.destroy() self.subject_widgets = [w for w in self.subject_widgets if w["frame"] is not row_frame] ttk.Button(row_frame, text="✕", command=remove_row, width=3, bootstyle="danger-outline" if HAS_BOOTSTRAP else None).pack(side=LEFT, padx=2) self.subject_widgets.append({ "frame": row_frame, "name": name_var, "start": start_var, "end": end_var, "audio": audio_var, "playtime": playtime_var, })def _open_create_form(self): """打开新建表单""" self.form_edit_id = None self.form_title_label.config(text="新建考试") self.form_name_var.set("") self.form_school_var.set("") self._clear_subjects() self._add_subject_row() self.notebook.select(1)def _load_form(self, exam): """加载考试到表单""" self.form_edit_id = exam["id"] self.form_title_label.config(text="编辑考试") self.form_name_var.set(exam.get("name", "")) self.form_school_var.set(exam.get("school", "")) self._clear_subjects() subjects = exam.get("subjects", []) if subjects: for s in subjects: self._add_subject_row(s) else: self._add_subject_row()def _clear_subjects(self): """清空科目列表""" for w in self.subject_widgets: w["frame"].destroy() self.subject_widgets.clear()def _save_exam(self): """保存考试""" name = self.form_name_var.get().strip() if not name: self._show_msg("错误", "请输入考试名称") return school = self.form_school_var.get().strip() subjects = [] for w in self.subject_widgets: sname = w["name"].get().strip() sstart = w["start"].get().strip() send = w["end"].get().strip() audio = w["audio"].get().strip() playtime = w["playtime"].get().strip() if not sname or not sstart or not send: self._show_msg("错误", "请完整填写科目名称、开始和结束时间\n格式: 2026-06-25T08:30") return subj = {"name": sname, "start": sstart, "end": send} if audio: subj["audioFile"] = audio if playtime: subj["audioPlayTime"] = playtime subjects.append(subj) if self.form_edit_id: for exam in self.exams: if exam["id"] == self.form_edit_id: exam["name"] = name exam["school"] = school exam["subjects"] = subjects break else: self.exams.append({ "id": generate_id(), "name": name, "school": school, "subjects": subjects, "createdAt": datetime.now().isoformat(), }) self.save_all() self._show_msg("成功", "考试已保存") self.notebook.select(0) self.refresh_list()def _cancel_form(self): """取消编辑""" self.notebook.select(0)# ==================== 大屏页 ====================def _build_screen_page(self): # 深色背景模拟大屏 self.screen_canvas = tk.Canvas( self.screen_frame, bg="#0b1a33", highlightthickness=0 ) self.screen_canvas.pack(fill=BOTH, expand=True) # 绑定resize事件 self.screen_canvas.bind("<Configure>", self._on_screen_resize) # 大屏文本元素 self.scr_exam_title = self.screen_canvas.create_text( 0, 0, text="未选择考试", fill="#ffffff", font=("", 28, "bold"), anchor="center" ) self.scr_school = self.screen_canvas.create_text( 0, 0, text="", fill="#aabbcc", font=("", 14), anchor="center" ) self.scr_subject = self.screen_canvas.create_text( 0, 0, text="", fill="#ffffff", font=("", 22), anchor="center" ) self.scr_countdown = self.screen_canvas.create_text( 0, 0, text="--:--", fill="#4f7df3", font=("Consolas", 52, "bold"), anchor="center" ) self.scr_time_range = self.screen_canvas.create_text( 0, 0, text="", fill="#8899aa", font=("", 13), anchor="center" ) self.scr_current_time = self.screen_canvas.create_text( 0, 0, text="", fill="#667788", font=("", 12), anchor="center" ) self.scr_status = self.screen_canvas.create_text( 0, 0, text="请从列表选择考试进入大屏", fill="#ffcc00", font=("", 16), anchor="center" ) # 圆形进度条参数 self.progress_arc = None self.progress_bg = Nonedef _on_screen_resize(self, event=None): """大屏区域resize时重新布局""" w = self.screen_canvas.winfo_width() h = self.screen_canvas.winfo_height() cx, cy = w // 2, h // 2 self.screen_canvas.coords(self.scr_exam_title, cx, cy - 180) self.screen_canvas.coords(self.scr_school, cx, cy - 145) self.screen_canvas.coords(self.scr_subject, cx, cy - 100) self.screen_canvas.coords(self.scr_countdown, cx, cy) self.screen_canvas.coords(self.scr_time_range, cx, cy + 70) self.screen_canvas.coords(self.scr_current_time, cx, cy + 100) self.screen_canvas.coords(self.scr_status, cx, cy + 150) # 重绘进度环 self._draw_progress_ring(cx, cy, 0)def _draw_progress_ring(self, cx, cy, progress): """绘制圆形进度环""" r = 80 # 删除旧的 if self.progress_bg: self.screen_canvas.delete(self.progress_bg) if self.progress_arc: self.screen_canvas.delete(self.progress_arc) # 背景环 self.progress_bg = self.screen_canvas.create_oval( cx - r, cy - r, cx + r, cy + r, outline="#1a3355", width=8 ) # 进度弧 extent = -360 * progress self.progress_arc = self.screen_canvas.create_arc( cx - r, cy - r, cx + r, cy + r, start=90, extent=extent, outline="#4f7df3", width=8, style="arc" ) # 确保文本在最上层 self.screen_canvas.tag_raise(self.scr_countdown)def _update_screen(self): """更新大屏显示""" exam = None for e in self.exams: if e["id"] == self.current_exam_id: exam = e break if not exam: self.screen_canvas.itemconfig(self.scr_exam_title, text="未选择考试") self.screen_canvas.itemconfig(self.scr_subject, text="") self.screen_canvas.itemconfig(self.scr_countdown, text="--:--") self.screen_canvas.itemconfig(self.scr_status, text="请从列表选择考试进入大屏") return self.screen_canvas.itemconfig(self.scr_exam_title, text=exam.get("name", "")) self.screen_canvas.itemconfig(self.scr_school, text=exam.get("school", "")) now = datetime.now() current_time_str = now.strftime("%H:%M:%S") self.screen_canvas.itemconfig(self.scr_current_time, text=f"当前时间: {current_time_str}") subj = get_current_subject(exam) w = self.screen_canvas.winfo_width() h = self.screen_canvas.winfo_height() cx, cy = w // 2, h // 2 if subj: try: start = datetime.fromisoformat(subj["start"]) end = datetime.fromisoformat(subj["end"]) except Exception: return self.screen_canvas.itemconfig(self.scr_subject, text=subj["name"]) time_range = f"{start.strftime('%H:%M')} - {end.strftime('%H:%M')}" self.screen_canvas.itemconfig(self.scr_time_range, text=time_range) if start <= now < end: # 考试进行中 remaining = (end - now).total_seconds() total = (end - start).total_seconds() progress = remaining / total if total > 0 else 0 self.screen_canvas.itemconfig(self.scr_countdown, text=format_countdown(remaining)) self.screen_canvas.itemconfig(self.scr_status, text="📝 考试进行中") self._draw_progress_ring(cx, cy, progress) # 语音提醒(简化:打印到控制台) key = f"{exam['id']}_{subj['name']}" if key not in self.announced: self.announced[key] = {} if not self.announced[key].get("start") and remaining > total - 5: self.announced[key]["start"] = True print("[语音] 考试开始") if not self.announced[key].get("fifteen"): if remaining <= 900 and remaining > 895: self.announced[key]["fifteen"] = True print("[语音] 距离考试结束还有15分钟") elif now < start: # 等待开始 remaining = (start - now).total_seconds() self.screen_canvas.itemconfig(self.scr_countdown, text=format_countdown(remaining)) self.screen_canvas.itemconfig( self.scr_status, text=f"⏳ 等待开始 ({start.strftime('%H:%M')})" ) self._draw_progress_ring(cx, cy, 0) else: # 已结束 self.screen_canvas.itemconfig(self.scr_countdown, text="00:00") self.screen_canvas.itemconfig(self.scr_status, text="✅ 本场考试已结束") self._draw_progress_ring(cx, cy, 0) else: # 没有进行中的科目,查找下一场 nearest = None diff = float("inf") for s in exam.get("subjects", []): try: st = datetime.fromisoformat(s["start"]) if st > now and (st - now).total_seconds() < diff: diff = (st - now).total_seconds() nearest = s except Exception: continue if nearest: st = datetime.fromisoformat(nearest["start"]) remaining = (st - now).total_seconds() self.screen_canvas.itemconfig(self.scr_subject, text=nearest["name"]) self.screen_canvas.itemconfig(self.scr_countdown, text=format_countdown(remaining)) self.screen_canvas.itemconfig( self.scr_status, text=f"⏳ 下一场: {nearest['name']}{st.strftime('%H:%M')}" ) self._draw_progress_ring(cx, cy, 0) else: self.screen_canvas.itemconfig(self.scr_subject, text="--") self.screen_canvas.itemconfig(self.scr_countdown, text="00:00") self.screen_canvas.itemconfig(self.scr_status, text="🏁 全部考试已结束") self._draw_progress_ring(cx, cy, 0)# ==================== 通知系统 ====================def _open_notification_window(self, exam_id): """打开通知发送窗口""" win = tk.Toplevel(self.root) win.title("📢 发送通知") win.geometry("400x280") win.resizable(False, False) win.transient(self.root) win.grab_set() ttk.Label(win, text="📢 发送通知", font=("", 14, "bold")).pack(pady=(15, 10)) ttk.Label(win, text="通知内容 *").pack(anchor=W, padx=20) content_text = tk.Text(win, height=4, font=("", 11)) content_text.pack(fill=X, padx=20, pady=5) dur_frame = ttk.Frame(win) dur_frame.pack(fill=X, padx=20, pady=5) ttk.Label(dur_frame, text="显示时长(秒, 0=一直显示):").pack(side=LEFT) dur_var = ttk.StringVar(value="15") ttk.Entry(dur_frame, textvariable=dur_var, width=8).pack(side=LEFT, padx=5) def send(): content = content_text.get("1.0", tk.END).strip() if not content: self._show_msg("错误", "请输入通知内容") return try: duration = int(dur_var.get()) except ValueError: duration = 15 notif = { "id": generate_id(), "examId": exam_id, "content": content, "sentAt": datetime.now().isoformat(), "duration": duration, } self.notifications.append(notif) self.save_all() self._refresh_notifications() # 如果当前大屏显示的就是这个考试,弹出通知 if self.current_exam_id == exam_id and self.screen_running: self._show_screen_notification(notif) win.destroy() self._show_msg("成功", "通知已发送!") btn_frame = ttk.Frame(win) btn_frame.pack(pady=15) ttk.Button(btn_frame, text="发送", command=send, bootstyle="primary" if HAS_BOOTSTRAP else None).pack(side=LEFT, padx=5) ttk.Button(btn_frame, text="取消", command=win.destroy, bootstyle="secondary" if HAS_BOOTSTRAP else None).pack(side=LEFT, padx=5)def _show_screen_notification(self, notif): """在大屏上显示通知弹窗""" win = tk.Toplevel(self.root) win.title("📢 通知") win.geometry("500x250") win.resizable(False, False) win.configure(bg="#1a2b4c") win.attributes("-topmost", True) ttk.Label(win, text="📢 通知", font=("", 20, "bold"), foreground="#e53e3e", background="#1a2b4c").pack(pady=(20, 10)) ttk.Label(win, text=notif["content"], font=("", 16), foreground="#ffffff", background="#1a2b4c", wraplength=450).pack(pady=10) ttk.Label(win, text=f"发送于 {format_datetime(notif['sentAt'])}", font=("", 10), foreground="#8899aa", background="#1a2b4c").pack(pady=5) ttk.Button(win, text="关闭", command=win.destroy).pack(pady=10) # 自动关闭 duration = notif.get("duration", 15) if duration > 0: win.after(duration * 1000, lambda: win.destroy() if win.winfo_exists() else None)# ==================== 工具方法 ====================def _show_msg(self, title, message): """显示消息""" if HAS_BOOTSTRAP: Messagebox.ok(message, title) else: messagebox.showinfo(title, message)def run(self): """启动应用""" self.root.mainloop()==================== 入口 ====================if name == "main":app = ExamMonitorApp()app.run()