Files
purposed_apps/RollCall_App/课堂随机点名.py
2025-05-19 09:03:20 +08:00

728 lines
45 KiB
Python
Raw Permalink 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 tkinter as tk
from tkinter import ttk, filedialog, messagebox, simpledialog
import tkinter.font
import random
import os
import csv
from datetime import date, timedelta
from pypinyin import pinyin, Style
import json
import sys
import random
# --- Activation Configuration (UUID Based) ---
APP_NAME_FOR_CONFIG = "RollCallApp"
# ---------------------------------------------------------
# --- Trial Configuration ---
TRIAL_DURATION_DAYS = 1
MAX_TRIAL_USES = 3
APP_CONFIG_VERSION_SUFFIX = "_v29_uuid_date_idx"
# ---------------------------------------------------------
# --- Seed Date Configuration (MUST MATCH THE ONE IN acitvation_code.py) ---
current_date = date.today()
SEED_YEAR = current_date.year
SEED_MONTH = current_date.month
SEED_DAY = current_date.day
# ---------------------------------------------------------------------
class ChooseModeDialog(simpledialog.Dialog):
def __init__(self, parent, title=None):
self._user_choice = None # Internal variable to store the choice
self.ButtonWidget = ttk.Button
self.LabelWidget = ttk.Label
# self.result is initialized to None by simpledialog.Dialog's __init__
# It will be populated correctly by self.apply() if OK, or remain None if Cancelled by 'X'
# or set by our _set_choice_and_cancel for explicit Exit button.
super().__init__(parent, title)
def body(self, master_frame):
self.LabelWidget(master_frame, text="欢迎使用课堂随机点名系统!\n\n请选择启动模式:", justify=tk.LEFT).pack(pady=20, padx=20)
return None # No specific widget to focus by default in body
def buttonbox(self):
box = ttk.Frame(self)
btn_pady = (5, 10)
btn_padx = 10
self.trial_button = self.ButtonWidget(box, text="免费试用", width=12, command=lambda: self._set_choice_and_ok('trial'))
self.trial_button.pack(side=tk.LEFT, padx=btn_padx, pady=btn_pady)
self.activate_button = self.ButtonWidget(box, text="激活软件", width=12, command=lambda: self._set_choice_and_ok('activate'))
self.activate_button.pack(side=tk.LEFT, padx=btn_padx, pady=btn_pady)
self.exit_button = self.ButtonWidget(box, text="退出", width=10, command=lambda: self._set_choice_and_explicit_cancel('exit'))
self.exit_button.pack(side=tk.LEFT, padx=btn_padx, pady=btn_pady)
self.bind("<Return>", lambda e: self._set_choice_and_ok('trial'))
self.bind("<Escape>", lambda e: self._set_choice_and_explicit_cancel('exit')) # Or just super().cancel() if None result is OK for Escape
self.initial_focus = self.trial_button
box.pack()
def _set_choice_and_ok(self, choice):
self._user_choice = choice
# simpledialog.Dialog.ok() calls self.validate(), then self.apply().
# self.apply() will set self.result. Then ok() destroys the window.
super().ok()
def _set_choice_and_explicit_cancel(self, choice):
self._user_choice = choice
# For an explicit cancel action (like an "Exit" button), we want to set
# self.result before calling cancel, because simpledialog.Dialog.cancel()
# itself doesn't set self.result (it remains None if only cancel is called).
self.result = self._user_choice
super().cancel() # This destroys the window.
# This method is called by simpledialog.Dialog.ok() after validate() passes.
# It's the standard place to set self.result for "OK" actions.
def apply(self):
self.result = self._user_choice
# Note: If the user closes the dialog using the window manager's 'X' button,
# simpledialog.Dialog's default WM_DELETE_WINDOW handler calls its own self.cancel().
# In this scenario, self.apply() is not called, and self._user_choice might not be set by a button.
# self.result will remain None (its initial value from simpledialog.Dialog).
# The calling code in RollCallApp (`choice is None`) correctly handles this as an exit condition.
class ActivationManager:
def __init__(self):
self.config_dir = self._get_config_dir()
self.activation_file = os.path.join(self.config_dir, f".activated_status{APP_CONFIG_VERSION_SUFFIX}")
self.trial_status_file = os.path.join(self.config_dir, f".trial_status{APP_CONFIG_VERSION_SUFFIX}")
if not os.path.exists(self.config_dir):
try:
os.makedirs(self.config_dir, exist_ok=True)
except OSError:
self.config_dir = os.path.dirname(os.path.abspath(__file__))
self.activation_file = os.path.join(self.config_dir, f".activated_status{APP_CONFIG_VERSION_SUFFIX}_local")
self.trial_status_file = os.path.join(self.config_dir, f".trial_status{APP_CONFIG_VERSION_SUFFIX}_local")
def _get_config_dir(self):
if sys.platform == "win32":
path = os.path.join(os.getenv('APPDATA'), APP_NAME_FOR_CONFIG)
elif sys.platform == "darwin":
path = os.path.join(os.path.expanduser('~/Library/Application Support'), APP_NAME_FOR_CONFIG)
else:
path = os.path.join(os.path.expanduser('~/.config'), APP_NAME_FOR_CONFIG)
return path
def _generate_expected_key_from_uuid(self, uuid_str):
if not uuid_str:
return ""
s = uuid_str.replace('-', '').lower()
if len(s) != 32:
return ""
date_seed_value = SEED_YEAR * 10000 + SEED_MONTH * 100 + SEED_DAY
rng = random.Random(date_seed_value)
all_possible_indices = list(range(32))
rng.shuffle(all_possible_indices)
dynamic_indices = sorted(all_possible_indices[:8])
try:
key_chars = [s[i] for i in dynamic_indices]
expected_key = "".join(key_chars).upper()
return expected_key
except IndexError:
return ""
def normalize_code(self, code_str):
if not code_str: return ""
return code_str.upper().replace("-", "").replace(" ", "")
def format_code_for_display(self, raw_key_str):
if not raw_key_str or len(raw_key_str) != 8:
return raw_key_str
return f"{raw_key_str[:4]}-{raw_key_str[4:]}"
def is_activated(self):
return os.path.exists(self.activation_file)
def activate(self, entered_uuid, entered_key):
if not entered_uuid or not entered_key:
return False
normalized_entered_uuid = entered_uuid.strip().lower()
normalized_entered_key = self.normalize_code(entered_key)
expected_key = self._generate_expected_key_from_uuid(normalized_entered_uuid)
if normalized_entered_key == expected_key and expected_key != "":
try:
with open(self.activation_file, 'w') as f:
f.write(normalized_entered_uuid)
if os.path.exists(self.trial_status_file):
try:
os.remove(self.trial_status_file)
except OSError:
pass
return True
except Exception as e:
messagebox.showerror("激活错误", f"无法保存激活状态: {e}")
return False
return False
def prompt_for_activation(self, master):
while True:
uuid_str = simpledialog.askstring("激活 - 步骤 1/2",
"----------------->请输入您的 UUID<-----------------",
parent=master)
if uuid_str is None:
return False
key_str = simpledialog.askstring("激活 - 步骤 2/2",
"------>请输入您的激活密钥 (例如: ABCD-EFGH)<------",
parent=master)
if key_str is None:
return False
if self.activate(uuid_str, key_str):
messagebox.showinfo("激活成功", "软件已成功激活!感谢使用。", parent=master)
return True
else:
retry = messagebox.askretrycancel("激活失败", "UUID 或激活密钥无效,请重试。", parent=master)
if not retry:
return False
def _read_trial_status(self):
if not os.path.exists(self.trial_status_file):
return None
try:
with open(self.trial_status_file, 'r') as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return None
def _write_trial_status(self, data):
try:
with open(self.trial_status_file, 'w') as f:
json.dump(data, f)
return True
except IOError:
return False
def start_trial_if_needed(self):
if self._read_trial_status() is None:
today_iso = date.today().isoformat()
initial_status = {
"start_date": today_iso,
"uses_left": MAX_TRIAL_USES
}
self._write_trial_status(initial_status)
return True
return False
def is_trial_valid(self):
status = self._read_trial_status()
if status is None:
return False
if status.get("uses_left", 0) <= 0:
return False
try:
start_date_obj = date.fromisoformat(status.get("start_date", ""))
expiry_date = start_date_obj + timedelta(days=TRIAL_DURATION_DAYS)
if date.today() > expiry_date:
return False
except (ValueError, TypeError):
return False
return True
def consume_trial_use(self):
status = self._read_trial_status()
if status and status.get("uses_left", 0) > 0:
status["uses_left"] -= 1
self._write_trial_status(status)
def get_trial_status_message(self):
status = self._read_trial_status()
if status is None:
return "无法获取试用信息。"
uses_left = status.get("uses_left", 0)
start_date_str = status.get("start_date", "")
message_parts = []
expired_by_uses = uses_left <= 0
expired_by_date = False
expiry_date_str = "未知"
if start_date_str:
try:
start_date_obj = date.fromisoformat(start_date_str)
expiry_date = start_date_obj + timedelta(days=TRIAL_DURATION_DAYS)
expiry_date_str = expiry_date.isoformat()
if date.today() > expiry_date:
expired_by_date = True
except (ValueError, TypeError):
message_parts.append("试用日期信息错误。")
if expired_by_uses and expired_by_date:
return f"试用次数已用尽且试用期已于 {expiry_date_str} 结束。"
elif expired_by_uses:
return "试用次数已用尽。"
elif expired_by_date:
return f"试用期已于 {expiry_date_str} 结束。"
message_parts.append(f"剩余 {uses_left} 次使用")
if start_date_str and not expired_by_date:
message_parts.append(f"试用到期: {expiry_date_str}")
return "".join(message_parts) + ""
class RollCallApp:
def __init__(self, master):
self.master = master
self.is_trial_mode = False
self._app_should_exit = False # Flag to indicate if app should exit prematurely
self.activation_manager = ActivationManager()
proceed_with_app = False
if self.activation_manager.is_activated():
master.title("课堂随机点名系统 (已激活)")
self.is_trial_mode = False
proceed_with_app = True
else:
dialog = ChooseModeDialog(master, title="选择启动模式")
choice = dialog.result
if choice == 'activate':
if self.activation_manager.prompt_for_activation(self.master):
master.title("课堂随机点名系统 (已激活)")
self.is_trial_mode = False
proceed_with_app = True
else:
messagebox.showwarning("激活失败", "激活未成功,将进入试用模式。", parent=self.master)
self.activation_manager.start_trial_if_needed()
if self.activation_manager.is_trial_valid():
self.activation_manager.consume_trial_use()
trial_message = self.activation_manager.get_trial_status_message()
master.title(f"课堂随机点名系统 (试用模式 - {trial_message})")
self.is_trial_mode = True
# messagebox.showinfo("试用模式", f"已进入试用模式。\n{trial_message}", parent=self.master)
proceed_with_app = True
else:
trial_end_message = self.activation_manager.get_trial_status_message()
messagebox.showerror("无法使用", f"激活失败,且试用亦不可用。\n({trial_end_message})\n软件即将退出。", parent=self.master)
elif choice == 'trial':
self.activation_manager.start_trial_if_needed()
if self.activation_manager.is_trial_valid():
self.activation_manager.consume_trial_use()
trial_message = self.activation_manager.get_trial_status_message()
master.title(f"课堂随机点名系统 (试用模式 - {trial_message})")
self.is_trial_mode = True
# messagebox.showinfo("试用模式", f"软件当前为试用模式。\n{trial_message}", parent=self.master)
proceed_with_app = True
else:
trial_end_message = self.activation_manager.get_trial_status_message()
messagebox.showwarning("试用结束", f"{trial_end_message}\n请输入激活码以继续使用,或退出。", parent=self.master)
if self.activation_manager.prompt_for_activation(self.master):
master.title("课堂随机点名系统 (已激活)")
self.is_trial_mode = False
proceed_with_app = True
else:
messagebox.showerror("激活必需", "软件未激活且试用期已过,即将退出。", parent=self.master)
elif choice == 'exit' or choice is None:
pass # proceed_with_app remains False
if not proceed_with_app:
self._app_should_exit = True # Set flag to exit
# Don't destroy master here yet, mainloop might not have started.
# The check after app instantiation will handle this.
return # Exit constructor early
# --- Continue with app setup only if proceed_with_app is True ---
self._default_width = 640
self._default_height = 480
master.minsize(width=self._default_width, height=self._default_height)
master.geometry(f"{self._default_width}x{self._default_height}")
try:
if getattr(sys, 'frozen', False):
base_path = sys._MEIPASS
else:
base_path = os.path.dirname(os.path.abspath(__file__))
icon_file_name = 'rollcall_icon.ico'
icon_path = os.path.join(base_path, icon_file_name)
if os.path.exists(icon_path):
master.iconbitmap(icon_path)
else:
print(f"Warning: Icon file '{icon_file_name}' not found: {icon_path}.")
except tk.TclError as e:
print(f"Warning: Could not load icon '{icon_path}': {e}.")
except Exception as e:
print(f"Warning: Unexpected error loading icon '{icon_path}': {e}.")
self.font_family_ui = "微软雅黑"
self.font_family_display = "华文中宋"
self.base_font_size_standard = 10
self.base_font_size_counter = 12
self.base_font_size_display = 26
self.base_font_size_pinyin_display = 16
self.base_font_size_title = 16
self.base_font_size_footer = 8
self.base_font_size_button = self.base_font_size_standard
self.font_size_standard = self.base_font_size_standard
self.font_size_counter = self.base_font_size_counter
self.font_size_display = self.base_font_size_display
self.font_size_pinyin_display = self.base_font_size_pinyin_display
self.font_size_title = self.base_font_size_title
self.font_size_footer = self.base_font_size_footer
self.font_size_button = self.base_font_size_button
self.font_title_tuple = (self.font_family_ui, self.font_size_title, "bold")
self.font_counter_tuple = (self.font_family_ui, self.font_size_counter)
self.font_display_tuple = (self.font_family_display, self.font_size_display, "bold")
self.font_pinyin_display_tuple = (self.font_family_display, self.font_size_pinyin_display, "bold")
self.font_standard_tuple = (self.font_family_ui, self.font_size_standard)
self.font_button_tuple = (self.font_family_ui, self.font_size_button, "bold")
self.font_footer_tuple = (self.font_family_ui, self.font_size_footer)
self.base_pady_name_display = 6
self.base_button_padding = (8, 5)
self.base_entry_padding = (2, 2)
self.base_duration_slider_length = 160
self.base_speed_slider_length = 160
self.last_scale_factor = 1.0
self.resize_timer = None
self._fonts_scaled_at_least_once = False
self.current_theme = "light"
self.themes_colors = {
"light": {"background": "#ECEFF1", "text": "#263238", "primary": "#007BFF", "primary_dark": "#0056b3", "primary_hover": "#0069D9", "primary_text": "#FFFFFF", "secondary_bg": "#FFFFFF", "border": "#B0BEC5", "disabled_fg": "#78909C", "disabled_bg": "#CFD8DC", "title_label_fg": "#007BFF", "footer_text": "#546E7A", "name_display_default_fg": "#263238", "name_display_default_bg": "#FFFFFF", "name_display_flash_fg": "#263238", "name_display_flash_bg": "#FFF9C4", "name_display_selected_fg": "#2E7D32", "name_display_selected_bg": "#C8E6C9", "name_display_absent_fg": "#C62828", "name_display_absent_bg": "#FFCDD2"},
"dark": {"background": "#2D2D2D", "text": "#E0E0E0", "primary": "#448AFF", "primary_dark": "#2962FF", "primary_hover": "#5393FF", "primary_text": "#FFFFFF", "secondary_bg": "#373737", "border": "#505050", "disabled_fg": "#888888", "disabled_bg": "#454545", "title_label_fg": "#448AFF", "footer_text": "#A0A0A0", "name_display_default_fg": "#E0E0E0", "name_display_default_bg": "#373737", "name_display_flash_fg": "#FFE082", "name_display_flash_bg": "#4A3F23", "name_display_selected_fg": "#A5D6A7", "name_display_selected_bg": "#1B5E20", "name_display_absent_fg": "#EF9A9A", "name_display_absent_bg": "#B71C1C"}
}
self.style = ttk.Style(master)
try:
self.style.theme_use('clam')
except tk.TclError:
print("Clam theme not available, using default theme.")
self.all_students = []
self.remaining_students = []
self.called_students = []
self.absent_students = []
self.current_student_info = None
self.flash_timer = None
self.stop_timer = None
self.is_flashing = False
self.file_path = tk.StringVar(value="尚未选择文件")
self.flash_duration_var = tk.IntVar(value=1)
self.flash_interval_var = tk.IntVar(value=25)
self.footer_label = ttk.Label(master, text="Ver. 2.9 @杨昱幸. All Rights Reserved.", style="Footer.TLabel", anchor='center')
self.main_content_frame = ttk.Frame(master, style="TFrame")
self.title_label = ttk.Label(self.main_content_frame, text="课堂随机点名系统", style="Title.TLabel")
self.title_label.pack(pady=(10, 5))
self.counter_label = ttk.Label(self.main_content_frame, text="已点: 0 人/总共0人", style="Counter.TLabel")
self.counter_label.pack(pady=(0, 8))
self.name_display_border_frame = tk.Frame(self.main_content_frame, relief="flat", bd=1)
self.name_display_border_frame.pack(pady=5, padx=15, fill=tk.X)
self.name_display = tk.Text(self.name_display_border_frame, font=self.font_display_tuple, wrap=tk.NONE, height=2, relief="flat", bd=0, pady=self.base_pady_name_display, tabs=("1c", "center"))
self.name_display.pack(fill=tk.X, padx=1, pady=1)
self.current_name_display_content_tuple = ("请先加载名单", "", "")
self.current_name_display_fg_key = "name_display_default_fg"
self.current_name_display_bg_key = "name_display_default_bg"
self.current_name_display_is_placeholder = True
flash_control_frame = ttk.Frame(self.main_content_frame, style="TFrame")
flash_control_frame.pack(pady=8, fill=tk.X, padx=15)
duration_frame = ttk.Frame(flash_control_frame)
duration_frame.pack(pady=3, anchor='center')
ttk.Label(duration_frame, text="闪烁时间 (秒):").pack(side=tk.LEFT, padx=(0,5))
self.duration_slider = ttk.Scale(duration_frame, from_=1, to=5, orient=tk.HORIZONTAL, variable=self.flash_duration_var, length=self.base_duration_slider_length, command=lambda v: self.flash_duration_var.set(int(float(v))), style="Horizontal.TScale")
self.duration_slider.pack(side=tk.LEFT, padx=3)
self.duration_label = ttk.Label(duration_frame, textvariable=self.flash_duration_var, width=2, style="Value.TLabel")
self.duration_label.pack(side=tk.LEFT)
self.duration_slider.config(state=tk.DISABLED)
interval_frame = ttk.Frame(flash_control_frame)
interval_frame.pack(pady=3, anchor='center')
ttk.Label(interval_frame, text="闪烁间隔 (毫秒):").pack(side=tk.LEFT, padx=(0,5))
self.speed_interval_slider = ttk.Scale(interval_frame, from_=25, to=300, orient=tk.HORIZONTAL, variable=self.flash_interval_var, length=self.base_speed_slider_length, command=lambda v: self.flash_interval_var.set(int(float(v))), style="Horizontal.TScale")
self.speed_interval_slider.pack(side=tk.LEFT, padx=3)
self.interval_label = ttk.Label(interval_frame, textvariable=self.flash_interval_var, width=3, style="Value.TLabel")
self.interval_label.pack(side=tk.LEFT)
self.speed_interval_slider.config(state=tk.DISABLED)
file_frame = ttk.Frame(self.main_content_frame)
file_frame.pack(pady=10, padx=15)
ttk.Label(file_frame, text="TXT名单 (一行一个'学号,姓名'):").pack(side=tk.LEFT, padx=(0,5))
self.file_entry = ttk.Entry(file_frame, textvariable=self.file_path, width=18, state='readonly')
self.file_entry.pack(side=tk.LEFT, padx=3)
self.load_button = ttk.Button(file_frame, text="选择文件", command=self.load_file)
self.load_button.pack(side=tk.LEFT, padx=(3,0))
button_frame = ttk.Frame(self.main_content_frame)
button_frame.pack(pady=10)
self.start_button = ttk.Button(button_frame, text="开始点名", command=self.start_roll_call, state=tk.DISABLED)
self.start_button.grid(row=0, column=0, padx=5, pady=4)
self.mark_absent_button = ttk.Button(button_frame, text="标记未到场", command=self.mark_absent, state=tk.DISABLED)
self.mark_absent_button.grid(row=0, column=1, padx=5, pady=4)
self.export_button = ttk.Button(button_frame, text="导出未到场(CSV)", command=self.export_absent_list, state=tk.DISABLED)
self.export_button.grid(row=1, column=0, padx=5, pady=4)
self.clear_button = ttk.Button(button_frame, text="清空考勤数据", command=self.clear_data, state=tk.DISABLED)
self.clear_button.grid(row=1, column=1, padx=5, pady=4)
self.theme_toggle_button = ttk.Button(button_frame, text="切换至深色模式", command=self.toggle_theme)
self.theme_toggle_button.grid(row=2, column=0, columnspan=2, padx=5, pady=4, sticky="ew")
self.footer_label.pack(side=tk.BOTTOM, fill=tk.X, pady=(5,5))
self.main_content_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
self._update_all_fonts_and_widgets(1.0)
self._set_name_display_text("请先加载名单", is_placeholder=True)
self.update_counter()
self.master.bind("<Configure>", self.on_window_resize)
def toggle_theme(self):
self.current_theme = "dark" if self.current_theme == "light" else "light"
if self.current_theme == "dark":
self.theme_toggle_button.config(text="切换至浅色模式")
else:
self.theme_toggle_button.config(text="切换至深色模式")
self._update_all_fonts_and_widgets(self.last_scale_factor)
self._render_name_display()
def on_window_resize(self, event=None):
if event and event.widget != self.master: return
if self.resize_timer: self.master.after_cancel(self.resize_timer)
current_width = self.master.winfo_width()
current_height = self.master.winfo_height()
self.resize_timer = self.master.after(150, self._perform_resize_actions, current_width, current_height)
def _perform_resize_actions(self, current_width, current_height):
if current_width <= 1 or current_height <= 1: return
scale_w = current_width / self._default_width
scale_h = current_height / self._default_height
scale_factor = min(scale_w,scale_h)
if scale_factor < 0.2: scale_factor = 0.2
if abs(scale_factor - self.last_scale_factor) < 0.015 and self._fonts_scaled_at_least_once: return
self._update_all_fonts_and_widgets(scale_factor)
self.last_scale_factor = scale_factor
self._fonts_scaled_at_least_once = True
def _update_all_fonts_and_widgets(self, scale_factor):
colors = self.themes_colors[self.current_theme]
self.master.configure(bg=colors['background'])
self.name_display_border_frame.config(background=colors['border'])
MIN_FONT_SIZE = 6
self.font_size_standard = max(MIN_FONT_SIZE, int(self.base_font_size_standard * scale_factor))
self.font_size_counter = max(MIN_FONT_SIZE, int(self.base_font_size_counter * scale_factor))
self.font_size_display = max(MIN_FONT_SIZE + 4, int(self.base_font_size_display * scale_factor))
self.font_size_pinyin_display = max(MIN_FONT_SIZE + 2, int(self.base_font_size_pinyin_display * scale_factor))
self.font_size_title = max(MIN_FONT_SIZE + 3, int(self.base_font_size_title * scale_factor))
self.font_size_footer = max(max(1, MIN_FONT_SIZE - 2), int(self.base_font_size_footer * scale_factor))
self.font_size_button = max(MIN_FONT_SIZE, int(self.base_font_size_button * scale_factor))
self.font_title_tuple = (self.font_family_ui, self.font_size_title, "bold")
self.font_counter_tuple = (self.font_family_ui, self.font_size_counter)
self.font_display_tuple = (self.font_family_display, self.font_size_display, "bold")
self.font_pinyin_display_tuple = (self.font_family_display, self.font_size_pinyin_display, "bold")
self.font_standard_tuple = (self.font_family_ui, self.font_size_standard)
self.font_button_tuple = (self.font_family_ui, self.font_size_button, "bold")
self.font_footer_tuple = (self.font_family_ui, self.font_size_footer)
scaled_button_padding = (max(2, int(self.base_button_padding[0] * scale_factor)), max(1, int(self.base_button_padding[1] * scale_factor)))
scaled_entry_padding = (max(1, int(self.base_entry_padding[0] * scale_factor)), max(1, int(self.base_entry_padding[1] * scale_factor)))
scaled_duration_slider_length = max(60, int(self.base_duration_slider_length * scale_factor))
scaled_speed_slider_length = max(60, int(self.base_speed_slider_length * scale_factor))
self.style.configure(".", background=colors['background'], foreground=colors['text'], font=self.font_standard_tuple)
self.style.configure("TFrame", background=colors['background'])
for child in self.main_content_frame.winfo_children():
if isinstance(child, ttk.Label): child.configure(background=colors['background'], foreground=colors['text'])
elif isinstance(child, ttk.Frame):
for sub_child in child.winfo_children():
if isinstance(sub_child, ttk.Label): sub_child.configure(background=colors['background'], foreground=colors['text'])
elif isinstance(sub_child, ttk.Frame):
for sub_sub_child in sub_child.winfo_children():
if isinstance(sub_sub_child, ttk.Label): sub_sub_child.configure(background=colors['background'], foreground=colors['text'])
self.style.configure("TLabel", background=colors['background'], foreground=colors['text'], font=self.font_standard_tuple)
self.style.configure("Title.TLabel", font=self.font_title_tuple, foreground=colors['title_label_fg'], background=colors['background'])
self.style.configure("Counter.TLabel", font=self.font_counter_tuple, background=colors['background'], foreground=colors['text'])
self.style.configure("Value.TLabel", font=self.font_standard_tuple, background=colors['background'], foreground=colors['text'])
self.style.configure("Footer.TLabel", font=self.font_footer_tuple, foreground=colors['footer_text'], background=colors['background'])
self.title_label.configure(style="Title.TLabel"); self.counter_label.configure(style="Counter.TLabel")
if hasattr(self, 'duration_label'): self.duration_label.configure(style="Value.TLabel")
if hasattr(self, 'interval_label'): self.interval_label.configure(style="Value.TLabel")
if hasattr(self, 'footer_label'): self.footer_label.configure(style="Footer.TLabel")
self.style.configure("TButton", font=self.font_button_tuple, padding=scaled_button_padding)
self.style.map("TButton", foreground=[('disabled', colors['disabled_fg']), ('pressed', colors['primary_text']), ('active', colors['primary_text']), ('!disabled', colors['primary_text'])], background=[('disabled', colors['disabled_bg']), ('pressed', colors['primary_dark']), ('active', colors['primary_hover']), ('!disabled', colors['primary'])], relief=[('pressed', 'sunken'), ('!pressed', 'raised')])
self.style.configure("TEntry", font=self.font_standard_tuple, fieldbackground=colors['secondary_bg'], foreground=colors['text'], relief="flat", padding=scaled_entry_padding, bordercolor=colors['border'], lightcolor=colors['primary'], borderwidth=1)
self.style.map("TEntry", bordercolor=[('focus', colors['primary']), ('!focus', colors['border'])], lightcolor=[('focus', colors['primary'])])
self.style.configure("Horizontal.TScale", background=colors['background'], troughcolor=colors['secondary_bg'])
self.duration_slider.config(length=scaled_duration_slider_length)
self.speed_interval_slider.config(length=scaled_speed_slider_length)
self.name_display.config(font=self.font_display_tuple)
self._render_name_display(scale_factor)
def _set_name_display_text(self, text_content, fg_color_key_override=None, bg_color_key_override=None, is_placeholder=False):
if is_placeholder:
self.current_name_display_content_tuple = (str(text_content), "", "")
self.current_name_display_fg_key = "name_display_default_fg"; self.current_name_display_bg_key = "name_display_default_bg"
else:
if isinstance(text_content, tuple) and len(text_content) == 3: self.current_name_display_content_tuple = text_content
else: self.current_name_display_content_tuple = (str(text_content), "", ""); is_placeholder = True
if fg_color_key_override and bg_color_key_override: self.current_name_display_fg_key = fg_color_key_override; self.current_name_display_bg_key = bg_color_key_override
else: self.current_name_display_fg_key = "name_display_default_fg"; self.current_name_display_bg_key = "name_display_default_bg"
self.current_name_display_is_placeholder = is_placeholder
self._render_name_display()
def _render_name_display(self, scale_factor_for_pady=None):
if scale_factor_for_pady is None: scale_factor_for_pady = self.last_scale_factor
active_theme_colors = self.themes_colors[self.current_theme]
text_tuple = self.current_name_display_content_tuple
current_fg_color = active_theme_colors[self.current_name_display_fg_key]
current_bg_color = active_theme_colors[self.current_name_display_bg_key]
is_placeholder = self.current_name_display_is_placeholder
self.name_display.config(state=tk.NORMAL); self.name_display.delete("1.0", tk.END)
scaled_pady_name_display = max(2, int(self.base_pady_name_display * scale_factor_for_pady))
self.name_display.config(bg=current_bg_color, pady=scaled_pady_name_display)
self.name_display.tag_configure("main_name", font=self.font_display_tuple, foreground=current_fg_color)
self.name_display.tag_configure("pinyin", font=self.font_pinyin_display_tuple, foreground=current_fg_color)
self.name_display.tag_configure("center", justify='center')
first_line_str = ""; second_line_str = " "
if is_placeholder: first_line_str = text_tuple[0]
else:
base_val, pinyin_val, suffix_val = text_tuple
first_line_str = base_val
if suffix_val: first_line_str += suffix_val
if pinyin_val: second_line_str = pinyin_val
self.name_display.insert(tk.END, first_line_str, ("center", "main_name"))
self.name_display.insert(tk.END, "\n")
self.name_display.insert(tk.END, second_line_str, ("center", "pinyin"))
self.name_display.config(height=2); self.name_display.config(state=tk.DISABLED)
def load_file(self):
filepath = filedialog.askopenfilename(title="请选择学生名单文件 (学号,姓名)", filetypes=[("Text files", "*.txt")])
if not filepath: return
try:
self._reset_data_full(); self.file_path.set(os.path.basename(filepath))
loaded_students = []; invalid_lines = []; line_num = 0
with open(filepath, 'r', encoding='utf-8') as f:
for line in f:
line_num += 1; original_line = line; line = line.strip()
if not line: continue
parts = line.split(',', 1)
if len(parts) == 2:
student_id_str = parts[0].strip(); name = parts[1].strip()
if student_id_str and name:
pinyin_list = pinyin(name, style=Style.TONE); pinyin_str = "".join([item[0] for item in pinyin_list])
loaded_students.append((student_id_str, name, pinyin_str))
else: invalid_lines.append((line_num, "学号或姓名为空", original_line.strip()))
else: invalid_lines.append((line_num, "格式错误 (应为 学号,姓名)", original_line.strip()))
if not loaded_students: messagebox.showwarning("警告", "文件未包含有效格式的学生信息 (学号,姓名)。"); self._reset_ui_after_load_fail(); return
self.all_students = loaded_students; self.remaining_students = self.all_students[:]; random.shuffle(self.remaining_students)
self._set_name_display_text("名单已加载,准备点名", is_placeholder=True)
self.update_counter(); self.start_button.config(state=tk.NORMAL); self.clear_button.config(state=tk.NORMAL)
self.export_button.config(state=tk.DISABLED); self.mark_absent_button.config(state=tk.DISABLED)
self.duration_slider.config(state=tk.NORMAL); self.speed_interval_slider.config(state=tk.NORMAL)
info_msg = f"成功加载 {len(self.all_students)} 位学生!"
if invalid_lines:
info_msg += f"\n\n但有 {len(invalid_lines)} 行格式无效已被跳过:"
for i, (ln, reason, orig) in enumerate(invalid_lines[:5]): info_msg += f"\n{ln}行: {reason} -> '{orig}'"
if len(invalid_lines) > 5: info_msg += "\n..."
messagebox.showwarning("加载部分成功", info_msg)
else: messagebox.showinfo("成功", info_msg)
except FileNotFoundError: messagebox.showerror("错误", f"文件未找到: {filepath}"); self._reset_ui_after_load_fail()
except Exception as e: messagebox.showerror("错误", f"加载文件时出错: {e}"); self._reset_ui_after_load_fail()
def _reset_data_core(self):
self.remaining_students = []; self.called_students = []; self.absent_students = []; self.current_student_info = None
if self.flash_timer: self.master.after_cancel(self.flash_timer); self.flash_timer = None
if self.stop_timer: self.master.after_cancel(self.stop_timer); self.stop_timer = None
self.is_flashing = False
def _reset_data_full(self): self._reset_data_core(); self.all_students = []; self.file_path.set("尚未选择文件")
def _reset_ui_after_load_fail(self):
self.file_path.set("尚未选择文件"); self._set_name_display_text("请先加载名单", is_placeholder=True)
self.start_button.config(state=tk.DISABLED); self.mark_absent_button.config(state=tk.DISABLED)
self.export_button.config(state=tk.DISABLED); self.clear_button.config(state=tk.DISABLED)
self.duration_slider.config(state=tk.DISABLED); self.speed_interval_slider.config(state=tk.DISABLED)
self.update_counter()
def update_counter(self):
total = len(self.all_students); called_count = len(self.called_students) + len(self.absent_students)
self.counter_label.config(text=f"已点: {called_count}人/总共{total}")
def start_roll_call(self):
if self.is_flashing: return
if not self.remaining_students:
self._set_name_display_text("所有学生已点完!", fg_color_key_override="name_display_selected_fg", bg_color_key_override="name_display_selected_bg", is_placeholder=True)
messagebox.showinfo("提示", "所有学生均已点名."); self.start_button.config(state=tk.DISABLED); self.mark_absent_button.config(state=tk.DISABLED); return
self.start_button.config(state=tk.DISABLED); self.load_button.config(state=tk.DISABLED); self.mark_absent_button.config(state=tk.DISABLED)
self.clear_button.config(state=tk.DISABLED); self.export_button.config(state=tk.DISABLED); self.duration_slider.config(state=tk.DISABLED)
self.speed_interval_slider.config(state=tk.DISABLED); self.theme_toggle_button.config(state=tk.DISABLED)
self.is_flashing = True; self._flash_name()
duration_ms = self.flash_duration_var.get() * 1000
self.stop_timer = self.master.after(duration_ms, self.stop_flashing)
def _flash_name(self):
if not self.is_flashing or not self.remaining_students:
if self.is_flashing: self.stop_flashing()
return
display_student = random.choice(self.remaining_students)
base_text = f"{display_student[0]}{display_student[1]}"; pinyin_text = display_student[2]
self._set_name_display_text((base_text, pinyin_text, ""), fg_color_key_override="name_display_flash_fg", bg_color_key_override="name_display_flash_bg")
flash_interval_ms = self.flash_interval_var.get()
self.flash_timer = self.master.after(flash_interval_ms, self._flash_name)
def stop_flashing(self):
if not self.is_flashing: return
if self.flash_timer: self.master.after_cancel(self.flash_timer); self.flash_timer = None
if self.stop_timer: self.master.after_cancel(self.stop_timer); self.stop_timer = None
self.is_flashing = False; self._select_final_student()
def _select_final_student(self):
if self.is_flashing: self.is_flashing = False;
if self.flash_timer: self.master.after_cancel(self.flash_timer); self.flash_timer = None
if self.stop_timer: self.master.after_cancel(self.stop_timer); self.stop_timer = None
self.load_button.config(state=tk.NORMAL); self.clear_button.config(state=tk.NORMAL); self.theme_toggle_button.config(state=tk.NORMAL)
can_enable_sliders = bool(self.all_students)
self.duration_slider.config(state=tk.NORMAL if can_enable_sliders else tk.DISABLED)
self.speed_interval_slider.config(state=tk.NORMAL if can_enable_sliders else tk.DISABLED)
if not self.remaining_students:
self._set_name_display_text("所有学生已点完!", fg_color_key_override="name_display_selected_fg", bg_color_key_override="name_display_selected_bg", is_placeholder=True)
self.start_button.config(state=tk.DISABLED); self.mark_absent_button.config(state=tk.DISABLED)
self.export_button.config(state=tk.NORMAL if self.absent_students else tk.DISABLED); return
self.current_student_info = self.remaining_students.pop(0); self.called_students.append(self.current_student_info)
base_text = f"{self.current_student_info[0]}{self.current_student_info[1]}"; pinyin_text = self.current_student_info[2]; suffix_text = ""
if not self.remaining_students: suffix_text = " (最后一位)"; self.start_button.config(state=tk.DISABLED)
else: self.start_button.config(state=tk.NORMAL)
self._set_name_display_text((base_text, pinyin_text, suffix_text), fg_color_key_override="name_display_selected_fg", bg_color_key_override="name_display_selected_bg")
self.update_counter(); self.mark_absent_button.config(state=tk.NORMAL)
self.export_button.config(state=tk.NORMAL if self.absent_students else tk.DISABLED)
def mark_absent(self):
if not self.current_student_info or self.is_flashing: return
if self.current_student_info in self.called_students:
self.called_students.remove(self.current_student_info)
if self.current_student_info not in self.absent_students: self.absent_students.append(self.current_student_info)
base_text = f"{self.current_student_info[0]}{self.current_student_info[1]}"; pinyin_text = self.current_student_info[2]; suffix_text = " [未到]"
self._set_name_display_text((base_text, pinyin_text, suffix_text), fg_color_key_override="name_display_absent_fg", bg_color_key_override="name_display_absent_bg")
self.mark_absent_button.config(state=tk.DISABLED); self.export_button.config(state=tk.NORMAL)
def export_absent_list(self):
if not self.absent_students: messagebox.showinfo("提示", "当前没有未到场的学生记录。"); return
today_str = date.today().strftime('%Y-%m-%d'); default_filename = f"未到场名单_{today_str}.csv"
filepath = filedialog.asksaveasfilename(title="保存未到场名单 (CSV)", defaultextension=".csv", filetypes=[("CSV files", "*.csv"), ("All files", "*.*")], initialfile=default_filename)
if not filepath: return
try:
sorted_absent = sorted(self.absent_students, key=lambda x: x[0])
with open(filepath, 'w', encoding='utf-8-sig', newline='') as f:
writer = csv.writer(f); writer.writerow(["学号", "姓名"])
for student in sorted_absent: writer.writerow([student[0], student[1]])
messagebox.showinfo("导出成功", f"未到场名单已成功导出为 CSV 文件:\n{filepath}")
except Exception as e: messagebox.showerror("导出失败", f"导出文件时出错: {e}")
def clear_data(self):
if not self.all_students: messagebox.showinfo("提示", "尚未加载学生名单,无数据可清空。"); return
if messagebox.askyesno("确认", "确定要清空当前考勤数据吗?\n这将重置点名状态,但学生名单会保留。"):
self._reset_data_core(); self.remaining_students = self.all_students[:]; random.shuffle(self.remaining_students)
self._set_name_display_text("考勤数据已清空,可重新点名", is_placeholder=True)
can_enable_controls = bool(self.all_students)
self.start_button.config(state=tk.NORMAL if can_enable_controls else tk.DISABLED); self.mark_absent_button.config(state=tk.DISABLED)
self.export_button.config(state=tk.DISABLED); self.clear_button.config(state=tk.NORMAL if can_enable_controls else tk.DISABLED)
self.duration_slider.config(state=tk.NORMAL if can_enable_controls else tk.DISABLED)
self.speed_interval_slider.config(state=tk.NORMAL if can_enable_controls else tk.DISABLED)
self.update_counter(); self.load_button.config(state=tk.NORMAL)
if __name__ == "__main__":
missing_libs = []
try: import pypinyin
except ImportError: missing_libs.append("pypinyin (`pip install pypinyin`)")
if missing_libs:
temp_root_for_error = tk.Tk(); temp_root_for_error.withdraw()
messagebox.showerror("依赖缺失", f"请先安装或确保以下库可用:\n" + "\n".join(missing_libs))
temp_root_for_error.destroy(); sys.exit(1)
root = tk.Tk()
root.withdraw()
app = RollCallApp(root)
if hasattr(app, '_app_should_exit') and app._app_should_exit:
if root.winfo_exists():
root.destroy()
sys.exit()
else: # App should proceed
if root.winfo_exists(): # Should always exist if not exited
root.deiconify()
root.mainloop()
# else: app might have called sys.exit itself, though unlikely with current structure