当前位置:首页>python>监考大屏系统 - Python GUI 版本

监考大屏系统 - Python GUI 版本

  • 2026-09-29 15:56:52
监考大屏系统 - Python GUI 版本

📚 监考大屏系统 - Python GUI 版本

概述

本系统是基于原 H5 监考大屏页面的 Python 桌面版本实现,使用 tkinter + ttkbootstrap 构建, 提供考试管理、实时倒计时监控大屏、通知推送等核心功能,适合学校机房或教室部署使用。


环境要求

依赖
说明
Python 3.8+
基础运行环境
tkinter
Python 标准库(通常自带)
ttkbootstrap(可选)
提供更美观的主题,未安装时自动降级为原生 tkinter

安装 ttkbootstrap(推荐)

pip install ttkbootstrap

启动

python monitor_gui.py

功能模块

1. 考试列表管理

  • 表格展示所有考试(名称、学校、科目数、状态、创建时间)
  • 支持关键词搜索过滤
  • 状态自动判定:等待开始 / 进行中 / 已结束
  • 操作:进入大屏、编辑、发通知、删除

2. 考试编辑/新建

  • 填写考试名称、学校名称
  • 添加多个科目,每个科目包含:
    • 科目名称
    • 开始时间(格式:2026-06-25T08:30)
    • 结束时间
    • 听力音频文件路径(可选)
    • 音频播放时间(可选)
  • 动态增删科目行
  • 数据持久化到本地 JSON 文件

3. 监控大屏

  • 深色全屏风格显示
  • 实时倒计时(每秒刷新)
  • 圆形进度环可视化剩余时间
  • 自动切换科目(当前科目结束后显示下一场)
  • 状态提示:等待开始 → 考试进行中 → 已结束 → 全部结束
  • 显示当前时间、考试时间段
  • 语音提醒节点(开始、剩余15分钟,输出到控制台)

4. 通知系统

  • 对指定考试发送通知
  • 设置通知显示时长(秒)
  • 大屏模式下弹窗显示通知内容
  • 通知历史记录(最近10条)
  • 自动超时关闭

5. 主题切换

支持 ttkbootstrap 内置主题:

  • cosmo(默认,明亮)
  • darkly(暗色)
  • journal(暖色)
  • flatly(扁平)
  • superhero(深蓝)

数据存储

所有数据持久化在程序同目录下的 JSON 文件中:

文件
内容
exam_data.json
考试列表及科目信息
exam_notifications.json
通知历史记录
exam_settings.json
用户设置(主题等)

与 H5 版本功能对照

功能
H5 版本
Python GUI 版本
考试 CRUD
✅
✅
科目时间管理
✅
✅
大屏倒计时
✅
✅
圆形进度环
✅ SVG
✅ Canvas Arc
主题切换
✅ 浅/深/卡通
✅ ttkbootstrap 多主题
通知发送
✅
✅
通知弹窗
✅
✅ Toplevel 窗口
通知历史
✅
✅
语音播报
✅ Web Speech API
⚠️ 控制台输出(可扩展 pyttsx3)
听力音频播放
✅ HTML5 Audio
⚠️ 路径记录(可扩展 pygame)
数据持久化
localStorage
JSON 文件
复制链接/快捷方式
✅
❌ 桌面版无需

扩展建议

  1. 语音播报:安装 pyttsx3,替换 print("[语音]") 为 TTS 调用
  2. 音频播放:安装 pygame,在对应时间点触发 pygame.mixer.music.play()
  3. 全屏模式:调用 root.attributes("-fullscreen", True) 实现教室投屏
  4. 网络同步:可增加 WebSocket 或 HTTP 接口,支持多终端同步通知

截图说明

程序启动后默认显示考试列表页,通过顶部标签页切换:

  • 📋 考试列表:管理所有考试
  • ✏️ 编辑考试:新建/修改考试信息
  • 🖥️ 监控大屏:全屏倒计时展示

许可

本项目仅供教学和内部使用。

#!/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()

最新文章

随机文章