728 lines
45 KiB
Python
728 lines
45 KiB
Python
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 |