文件夹结构重组和清理

This commit is contained in:
2025-05-13 18:30:34 +08:00
parent 864ba8c811
commit cb9eb9d441
3 changed files with 1000 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -0,0 +1,510 @@
<!DOCTYPE html>
<html>
<head>
<title>课堂随机点名小程序</title>
<style>
body {
margin: 0;
padding: 20px;
background: linear-gradient(135deg, #ff9a9e 0%, #fad0c4 100%);
font-family: 'Comic Sans MS', cursive;
min-height: 100vh;
}
.container {
max-width: 800px;
margin: 0 auto;
text-align: center;
}
#title {
color: #6a1b9a;
font-size: 2.5em;
text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
margin-bottom: 20px;
}
#counter {
background: rgba(255,255,255,0.8);
color: #e74c3c;
font-size: 1.3em;
font-weight: bold;
padding: 8px 15px;
border-radius: 15px;
display: inline-block;
margin-bottom: 15px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
#display {
height: 200px;
background: rgba(255,255,255,0.9);
border-radius: 20px;
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
margin: 20px 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 2.2em;
color: #2c3e50;
transition: all 0.3s ease;
flex-direction: row;
gap: 20px;
white-space: nowrap;
padding: 0 20px;
overflow-x: auto;
}
.controls {
display: flex;
flex-direction: column;
gap: 15px;
align-items: center;
}
.button-group {
display: flex;
gap: 15px;
align-items: center;
}
button {
padding: 12px 30px;
font-size: 1.2em;
border: none;
border-radius: 25px;
background: #3498db;
color: white;
cursor: pointer;
transition: transform 0.2s, background-color 0.2s;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
button:hover {
transform: translateY(-2px);
background: #2980b9;
}
button:disabled {
background: #bdc3c7 !important; /* Ensure high specificity for disabled background */
cursor: not-allowed;
transform: none;
box-shadow: none;
}
button:disabled:hover {
background: #bdc3c7 !important; /* Keep disabled background on hover */
transform: none;
}
#speedControl {
width: 200px;
margin: 10px 0;
}
.highlight {
background-color: #f1c40f !important;
color: #c0392b !important;
transform: scale(1.1);
}
.student-id {
font-size: 0.8em;
color: #6a1b9a;
font-weight: bold;
}
.pinyin {
font-size: 0.6em;
color: #7f8c8d;
font-style: italic;
}
.export-btn {
background: #27ae60 !important;
}
.clear-btn {
background: #e74c3c !important;
}
.mark-absent-btn {
background: #e67e22 !important;
}
@media (max-width: 480px) {
#display {
font-size: 1.8em;
gap: 10px;
}
.button-group {
flex-direction: column;
}
}
</style>
</head>
<body>
<div class="container">
<h1 id="title">课堂点名小程序</h1>
<div id="counter">已点名: 0/0 人</div>
<div id="display">请先加载学生名单</div>
<div class="controls">
<div>
<label for="fileInput" style="font-size: 1em; color: #555;">加载学生名单 (UTF-8 .txt文件):</label><br>
<input type="file" id="fileInput" accept=".txt" style="margin: 5px 0 15px 0; padding: 5px; border-radius: 5px; border: 1px solid #ccc;">
<p style="font-size: 0.8em; color: #666; margin-top: -10px; margin-bottom: 10px;">
格式: 每行一人, 学号,姓名,拼音 (例如: <code>1001,张三,Zhang San</code>)<br>
或 学号,姓名 (例如: <code>1002,李四</code>)。以 <code>#</code> 开头的行为注释。
</p>
</div>
<input type="range" id="speedControl" min="1" max="10" value="5">
<div class="button-group">
<button id="rollButton" onclick="toggleRoll()">开始点名</button>
<button id="markAbsentButton" onclick="markAbsent()" class="mark-absent-btn">标记未到场</button>
</div>
<button id="exportButton" class="export-btn" onclick="exportAbsentList()">导出未到场名单</button>
<button class="clear-btn" onclick="clearStorage()">清空考勤数据</button>
</div>
</div>
<script>
let names = []; // Student list will be loaded from file or localStorage
let isRolling = false;
let intervalId;
let currentStudent = null;
const display = document.getElementById('display');
const speedControl = document.getElementById('speedControl');
let calledStudents = [];
const rollButton = document.getElementById('rollButton');
const markAbsentButton = document.getElementById('markAbsentButton');
const exportButton = document.getElementById('exportButton');
function setControlsEnabled(enabled) {
rollButton.disabled = !enabled;
markAbsentButton.disabled = !enabled;
exportButton.disabled = !enabled;
if (!enabled) {
display.textContent = "请先加载学生名单";
if (isRolling) stopRoll(); // Stop rolling if controls are disabled
} else if (names.length > 0 && (display.textContent === "请先加载学生名单" || display.textContent === "所有学生均已点过")) {
display.textContent = "点击开始";
}
}
function updateCounter() {
const counter = document.getElementById('counter');
const totalAvailableStudents = names.filter(student => !student.absent).length;
counter.textContent = `已点名: ${calledStudents.length}/${totalAvailableStudents} 人 (总人数: ${names.length})`;
}
document.addEventListener('DOMContentLoaded', () => {
const customTitle = prompt("请输入课堂名称:") || "杨昱幸老师的课堂";
document.getElementById('title').textContent = `${customTitle}点名系统`;
const savedAttendanceData = localStorage.getItem('attendanceData');
if (savedAttendanceData) {
try {
const parsedNames = JSON.parse(savedAttendanceData);
if (Array.isArray(parsedNames)) {
names = parsedNames;
} else {
names = [];
console.warn("localStorage 'attendanceData' was not an array.");
}
} catch (e) {
console.error("Error parsing 'attendanceData' from localStorage:", e);
names = [];
}
}
const savedCalledStudents = localStorage.getItem('calledStudentsData');
if (savedCalledStudents) {
try {
const parsedCalled = JSON.parse(savedCalledStudents);
if (Array.isArray(parsedCalled)) {
// Filter calledStudents to ensure they are still in the current names list (if names loaded)
const currentIds = new Set(names.map(s => s.id));
calledStudents = parsedCalled.filter(id => currentIds.has(id));
} else {
calledStudents = [];
console.warn("localStorage 'calledStudentsData' was not an array.");
}
} catch (e) {
console.error("Error parsing 'calledStudentsData' from localStorage:", e);
calledStudents = [];
}
}
document.getElementById('fileInput').addEventListener('change', handleFileSelect);
updateCounter();
setControlsEnabled(names.length > 0);
});
function handleFileSelect(event) {
const file = event.target.files[0];
if (file) {
if (file.type === "text/plain" || file.name.endsWith(".txt")) {
const reader = new FileReader();
reader.onload = (e) => {
const fileContent = e.target.result;
const newStudentsFromFile = parseStudentFile(fileContent);
if (newStudentsFromFile.length > 0) {
// Try to get existing absent statuses from localStorage for merging
const existingAttendanceData = JSON.parse(localStorage.getItem('attendanceData') || '[]');
const attendanceMap = new Map();
if (Array.isArray(existingAttendanceData)) {
existingAttendanceData.forEach(s => {
if (s.absent) { // Only map students marked absent
attendanceMap.set(s.id, true);
}
});
}
// Create the new 'names' array from the file, merging absence status
names = newStudentsFromFile.map(student => ({
...student,
absent: attendanceMap.get(student.id) || false // Default to not absent
}));
calledStudents = []; // Reset called students when a new list is loaded
localStorage.setItem('attendanceData', JSON.stringify(names)); // Save new master list
localStorage.removeItem('calledStudentsData'); // Clear old called students list
alert(`${names.length}名学生已从文件加载。\n已尝试合并之前记录的缺勤状态。\n已点名计数已重置。`);
currentStudent = null; // Reset current student selection
updateCounter();
setControlsEnabled(true);
} else {
alert("文件为空或格式无法解析。\n请确保文件为UTF-8编码每行格式为\n学号,姓名,拼音 (可选)\n或 学号,姓名\n以 # 开头的行为注释。");
// Reset file input if parsing fails or empty so user can re-select
document.getElementById('fileInput').value = null;
}
};
reader.onerror = () => {
alert("读取文件失败!");
setControlsEnabled(names.length > 0);
};
reader.readAsText(file, 'UTF-8');
} else {
alert("请选择一个 .txt 文件。");
document.getElementById('fileInput').value = null; // Reset if wrong file type
}
}
}
function parseStudentFile(content) {
const lines = content.split(/\r?\n/);
const students = [];
const ids = new Set(); // To check for duplicate IDs
lines.forEach((line, index) => {
line = line.trim();
if (line && !line.startsWith("#")) {
const parts = line.split(',');
if (parts.length >= 2) {
const id = parts[0].trim();
const name = parts[1].trim();
if (id && name) { // Ensure ID and Name are not empty
if (ids.has(id)) {
console.warn(`Skipping line ${index + 1} due to duplicate ID: ${id}`);
alert(`警告: 文件中发现重复学号 "${id}" (行 ${index + 1}),该重复条目已被忽略。`);
} else {
students.push({
id: id,
name: name,
pinyin: (parts[2] || "").trim()
});
ids.add(id);
}
} else {
console.warn(`Skipping line ${index + 1} due to empty ID or Name: ${line}`);
}
} else if (line) { // Non-empty line that doesn't have enough parts
console.warn(`Skipping line ${index + 1} due to incorrect format (not enough parts): ${line}`);
}
}
});
return students;
}
function toggleRoll() {
if (names.length === 0) {
alert("请先加载学生名单。");
return;
}
!isRolling ? startRoll() : stopRoll();
}
function startRoll() {
const availableStudents = names.filter(student =>
!student.absent && !calledStudents.includes(student.id)
);
if (availableStudents.length === 0) {
isRolling = false;
rollButton.textContent = "开始点名";
if (names.length === 0) {
display.textContent = "请先加载学生名单";
alert("没有学生名单可供点名。");
} else if (names.filter(student => !student.absent).length === 0) {
display.textContent = "均已标记未到";
alert("所有学生均已标记为未到场!无法开始点名。");
} else {
display.textContent = "所有学生均已点过";
alert("所有可点名学生都已点名完毕!");
}
return;
}
isRolling = true;
rollButton.textContent = "暂停点名";
const speedValue = parseInt(speedControl.value, 10);
const speed = Math.max(20, 100 - (speedValue * 8));
intervalId = setInterval(() => {
currentStudent = availableStudents[Math.floor(Math.random() * availableStudents.length)];
display.innerHTML = `
<span class="student-id">${currentStudent.id}</span>
<span>${currentStudent.name}</span>
<span class="pinyin">(${(currentStudent.pinyin || '')})</span>
`;
}, speed);
}
function stopRoll() {
isRolling = false;
clearInterval(intervalId);
rollButton.textContent = "开始点名";
if (currentStudent) {
display.classList.add('highlight');
setTimeout(() => display.classList.remove('highlight'), 2000);
if (!calledStudents.includes(currentStudent.id)) {
calledStudents.push(currentStudent.id);
localStorage.setItem('calledStudentsData', JSON.stringify(calledStudents));
updateCounter();
}
} else {
const availableForRoll = names.filter(s => !s.absent && !calledStudents.includes(s.id)).length;
if (names.length > 0 && availableForRoll === 0 && names.filter(s => !s.absent).length > 0) {
display.textContent = "所有学生均已点过";
} else if (names.length > 0) {
// display.textContent = "点击开始";
} else {
display.textContent = "请先加载学生名单";
}
}
}
function markAbsent() {
if (!currentStudent) {
alert('请先通过“开始点名”选中一位学生。');
return;
}
const targetStudent = names.find(s => s.id === currentStudent.id);
if (targetStudent) {
if (targetStudent.absent) {
alert(`${targetStudent.name} (${targetStudent.id}) 已被标记为未到场。`);
return;
}
targetStudent.absent = true;
localStorage.setItem('attendanceData', JSON.stringify(names));
alert(`已标记 ${targetStudent.name} (${targetStudent.id}) 为未到场`);
updateCounter();
if (isRolling) {
clearInterval(intervalId);
startRoll();
}
} else {
alert(`错误:无法在名单中找到学生 ${currentStudent.name} (${currentStudent.id})。`);
}
}
function clearStorage() {
if (confirm('确定要清空所有考勤数据和已加载的学生名单吗?\n此操作不可恢复您需要重新加载学生名单文件。')) {
names = []; // Clear the student list from memory
calledStudents = []; // Reset called list
currentStudent = null; // Reset current selection
localStorage.removeItem('attendanceData'); // Remove student list from storage
localStorage.removeItem('calledStudentsData'); // Clear called students from storage
alert('所有考勤数据和学生名单已清空!');
display.textContent = "请先加载学生名单";
document.getElementById('fileInput').value = null; // Reset file input
updateCounter(); // Update counter to reflect 0 students
setControlsEnabled(false); // Disable control buttons
}
}
function exportAbsentList() {
const absentStudents = names.filter(student => student.absent);
if (absentStudents.length === 0) {
alert("当前没有记录未到场学生。");
return;
}
const csvHeader = "\uFEFF学号,姓名,拼音"; // UTF-8 BOM for Excel
const csvRows = absentStudents.map(s => `${s.id},${s.name},${s.pinyin || ''}`);
const csvContent = [csvHeader, ...csvRows].join('\r\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
const titleElement = document.getElementById('title');
const classroomName = titleElement ? titleElement.textContent.replace(/点名系统|小程序/gi, "").trim() : "课堂";
const date = new Date();
const dateString = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`;
link.download = `${classroomName}_未到场名单_${dateString}.csv`;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
speedControl.addEventListener('input', () => {
if (isRolling) {
clearInterval(intervalId);
startRoll();
}
});
</script>
</div> <!-- 结束.container -->
<div class="footer">
Ver. 1.8 @杨昱幸 2025. All Rights Reserved.
</div>
</body>
<style>
/* 在原有样式最后添加 */
.footer {
text-align: center;
font-size: 0.9em;
color: #6a1b9a;
padding: 15px 0;
margin-top: 30px;
font-family: 'Comic Sans MS', cursive;
}
@media (max-width: 480px) {
.footer {
font-size: 0.8em;
padding: 10px 0;
}
}
</style>
</html>

View File

@@ -0,0 +1,490 @@
import tkinter as tk
# Add PhotoImage to the import
from tkinter import ttk, filedialog, messagebox, PhotoImage
import random
# import time
import os
import csv
from datetime import date
from pypinyin import pinyin, Style
import sys # Import sys for path joining
class RollCallApp:
def __init__(self, master):
self.master = master
master.title("课堂点名系统")
# --- Set Window Icon ---
try:
# Determine the base path (works for scripts and frozen executables)
if getattr(sys, 'frozen', False):
base_path = sys._MEIPASS # PyInstaller temp folder
else:
base_path = os.path.dirname(os.path.abspath(__file__))
icon_path = os.path.join(base_path, 'rollcall_icon.png') # Make sure 'rollcall_icon.png' exists here!
if os.path.exists(icon_path):
# Use PhotoImage for broader compatibility (PNG, GIF)
app_icon = PhotoImage(file=icon_path)
master.iconphoto(True, app_icon) # The 'True' makes it the default icon
else:
print(f"Warning: Icon file not found at {icon_path}. Using default icon.")
except tk.TclError as e:
# Handle cases where the image format might be unsupported or file is corrupt
print(f"Warning: Could not load icon '{icon_path}': {e}. Using default icon.")
except Exception as e:
# Catch any other potential errors during icon loading
print(f"Warning: An unexpected error occurred while loading the icon: {e}. Using default icon.")
# --- End Set Window Icon ---
master.minsize(width=1100, height=700)
# ... (the rest of your __init__ code remains exactly the same) ...
# --- Define Font Families (User specified) ---
self.font_family_ui = "微软雅黑"
self.font_family_display = "华文中宋"
# --- Define Font Sizes (Slightly adjusted for modern feel, easy to revert) ---
self.font_size_standard = 14
self.font_size_counter = 18
self.font_size_display = 42
self.font_size_title = 28
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_standard_tuple = (self.font_family_ui, self.font_size_standard)
self.font_button_tuple = (self.font_family_ui, self.font_size_standard, "bold")
self.font_footer_tuple = (self.font_family_ui, 11)
# --- Style and Colors ---
self.style = ttk.Style(master)
try:
self.style.theme_use('clam')
except tk.TclError:
print("Clam theme not available, using default theme. Styling will still be applied.")
self.COLOR_BACKGROUND = "#ECEFF1"
self.COLOR_TEXT = "#263238"
self.COLOR_PRIMARY = "#007BFF"
self.COLOR_PRIMARY_DARK = "#0056b3"
self.COLOR_PRIMARY_HOVER = "#0069D9"
self.COLOR_PRIMARY_TEXT = "#FFFFFF"
self.COLOR_SECONDARY_BG = "#FFFFFF"
self.COLOR_BORDER = "#B0BEC5"
self.COLOR_DISABLED_FG = "#78909C"
self.COLOR_DISABLED_BG = "#CFD8DC"
self.COLOR_NAME_DISPLAY_DEFAULT_BG = self.COLOR_SECONDARY_BG
self.COLOR_NAME_DISPLAY_DEFAULT_FG = self.COLOR_TEXT
self.COLOR_NAME_DISPLAY_FLASH_BG = "#FFF9C4" # Pale Yellow (Material Design Yellow 100)
self.COLOR_NAME_DISPLAY_FLASH_FG = self.COLOR_TEXT
self.COLOR_NAME_DISPLAY_SELECTED_BG = "#C8E6C9" # Pale Green (Material Design Green 100)
self.COLOR_NAME_DISPLAY_SELECTED_FG = "#2E7D32" # Dark Green (Material Design Green 800)
self.COLOR_NAME_DISPLAY_ABSENT_BG = "#FFCDD2" # Pale Red (Material Design Red 100)
self.COLOR_NAME_DISPLAY_ABSENT_FG = "#C62828" # Dark Red (Material Design Red 800)
master.configure(bg=self.COLOR_BACKGROUND)
self.style.configure(".", background=self.COLOR_BACKGROUND, foreground=self.COLOR_TEXT,
font=self.font_standard_tuple)
self.style.configure("TFrame", background=self.COLOR_BACKGROUND)
self.style.configure("TLabel", background=self.COLOR_BACKGROUND, foreground=self.COLOR_TEXT)
self.style.configure("Title.TLabel", font=self.font_title_tuple, foreground=self.COLOR_PRIMARY)
self.style.configure("Counter.TLabel", font=self.font_counter_tuple)
self.style.configure("Value.TLabel", font=self.font_standard_tuple)
self.style.configure("Footer.TLabel", font=self.font_footer_tuple, foreground="#546E7A")
self.style.configure("TButton", font=self.font_button_tuple, padding=(12, 7), relief="raised")
self.style.map("TButton",
foreground=[('disabled', self.COLOR_DISABLED_FG),
('pressed', self.COLOR_PRIMARY_TEXT),
('active', self.COLOR_PRIMARY_TEXT),
('!disabled', self.COLOR_PRIMARY_TEXT)],
background=[('disabled', self.COLOR_DISABLED_BG),
('pressed', self.COLOR_PRIMARY_DARK),
('active', self.COLOR_PRIMARY_HOVER),
('!disabled', self.COLOR_PRIMARY)],
relief=[('pressed', 'sunken'), ('!pressed', 'raised')])
self.style.configure("TEntry", font=self.font_standard_tuple, fieldbackground=self.COLOR_SECONDARY_BG,
relief="flat")
self.style.map("TEntry",
bordercolor=[('focus', self.COLOR_PRIMARY), ('!focus', self.COLOR_BORDER)],
borderwidth=[('focus', 1), ('!focus', 1)],
lightcolor=[('focus', self.COLOR_PRIMARY)])
self.style.configure("Horizontal.TScale", background=self.COLOR_BACKGROUND, troughcolor=self.COLOR_SECONDARY_BG)
# --- Data storage ---
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)
# --- UI Elements ---
self.title_label = ttk.Label(master, text="课堂随机点名系统", style="Title.TLabel")
self.title_label.pack(pady=(25, 15))
self.counter_label = ttk.Label(master, text="已点名0/0人 总人数0", style="Counter.TLabel")
self.counter_label.pack(pady=(0, 20))
self.name_display_border_frame = tk.Frame(master, background=self.COLOR_BORDER, relief="flat", bd=1)
self.name_display_border_frame.pack(pady=10, padx=30, fill=tk.X)
self.name_display = tk.Label(self.name_display_border_frame, text="请先加载名单", font=self.font_display_tuple,
bg=self.COLOR_NAME_DISPLAY_DEFAULT_BG,
fg=self.COLOR_NAME_DISPLAY_DEFAULT_FG,
justify="center")
self.name_display.pack(fill=tk.X, ipady=15, padx=1, pady=1)
flash_control_frame = ttk.Frame(master, style="TFrame")
flash_control_frame.pack(pady=15, fill=tk.X, padx=30)
duration_frame = ttk.Frame(flash_control_frame) # Will inherit style from parent or "."
duration_frame.pack(pady=8, anchor='center')
ttk.Label(duration_frame, text="闪烁时间 (秒):").pack(side=tk.LEFT, padx=(0,10))
self.duration_slider = ttk.Scale(duration_frame, from_=1, to=5, orient=tk.HORIZONTAL,
variable=self.flash_duration_var, length=250,
command=lambda v: self.flash_duration_var.set(int(float(v))),
style="Horizontal.TScale")
self.duration_slider.pack(side=tk.LEFT, padx=5)
self.duration_label = ttk.Label(duration_frame, textvariable=self.flash_duration_var, width=3, 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=8, anchor='center')
ttk.Label(interval_frame, text="闪烁间隔 (毫秒):").pack(side=tk.LEFT, padx=(0,10))
self.speed_interval_slider = ttk.Scale(interval_frame, from_=25, to=300, orient=tk.HORIZONTAL,
variable=self.flash_interval_var, length=250,
command=lambda v: self.flash_interval_var.set(int(float(v))),
style="Horizontal.TScale")
self.speed_interval_slider.pack(side=tk.LEFT, padx=5)
self.interval_label = ttk.Label(interval_frame, textvariable=self.flash_interval_var, width=4, style="Value.TLabel")
self.interval_label.pack(side=tk.LEFT)
self.speed_interval_slider.config(state=tk.DISABLED)
# --- File Selection Area ---
file_frame = ttk.Frame(master)
file_frame.pack(pady=20, padx=30)
ttk.Label(file_frame, text="学生名单 (学号,姓名.txt):").pack(side=tk.LEFT, padx=(0,10))
self.file_entry = ttk.Entry(file_frame, textvariable=self.file_path, width=25, state='readonly')
self.file_entry.pack(side=tk.LEFT, padx=5, ipady=2) # Small ipady for entry height consistency
self.load_button = ttk.Button(file_frame, text="选择文件", command=self.load_file)
self.load_button.pack(side=tk.LEFT, padx=(5,0))
# --- Control Button Area ---
button_frame = ttk.Frame(master)
button_frame.pack(pady=25)
# Grid padx/pady for spacing between buttons
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=10, pady=8)
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=10, pady=8)
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=10, pady=8)
self.clear_button = ttk.Button(button_frame, text="清空考勤数据", command=self.clear_data, state=tk.DISABLED)
self.clear_button.grid(row=1, column=1, padx=10, pady=8)
# --- 底部版权信息 ---
self.footer_label = ttk.Label(
master,
text="Ver. 2.2 @杨昱幸. All Rights Reserved.",
style="Footer.TLabel",
anchor='center'
)
self.footer_label.pack(side=tk.BOTTOM, fill=tk.X, pady=(15,10))
self.update_counter()
# ... (load_file, _reset_data_core, etc. methods remain unchanged) ...
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.NORMAL)
pinyin_str = "".join([item[0] for item in pinyin_list])
student_info = (student_id_str, name, pinyin_str)
loaded_students.append(student_info)
else: invalid_lines.append((line_num, "学号或姓名为空", original_line))
else: invalid_lines.append((line_num, "格式错误", original_line))
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.name_display.config(text="名单已加载,准备点名", fg=self.COLOR_NAME_DISPLAY_DEFAULT_FG, bg=self.COLOR_NAME_DISPLAY_DEFAULT_BG)
self.master.update_idletasks()
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.strip()}'"
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.name_display.config(text="请先加载名单", fg=self.COLOR_NAME_DISPLAY_DEFAULT_FG, bg=self.COLOR_NAME_DISPLAY_DEFAULT_BG)
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}人 (总人数:{total}")
def start_roll_call(self):
if self.is_flashing: return
if not self.remaining_students:
self.name_display.config(text="所有学生已点完!", fg=self.COLOR_NAME_DISPLAY_SELECTED_FG, bg=self.COLOR_NAME_DISPLAY_SELECTED_BG)
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.is_flashing = True
self.name_display.config(bg=self.COLOR_NAME_DISPLAY_FLASH_BG, fg=self.COLOR_NAME_DISPLAY_FLASH_FG)
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() # Cleanly stop if conditions change
return
display_student = random.choice(self.remaining_students)
display_text = f"{display_student[0]}{display_student[1]}{display_student[2]}"
# Text color for flashing already set in start_roll_call
self.name_display.config(text=display_text)
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 # Already stopped or never started
if self.flash_timer:
self.master.after_cancel(self.flash_timer)
self.flash_timer = None
# self.stop_timer is the timer for the duration of flashing.
# If this method is called by that timer, self.stop_timer will be None automatically.
# If called externally (e.g. by _flash_name), cancel it.
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: # Should not happen if stop_flashing was called correctly
self.is_flashing = False # Force stop
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
# Restore button states
self.load_button.config(state=tk.NORMAL)
self.clear_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.name_display.config(text="所有学生已点完!", fg=self.COLOR_NAME_DISPLAY_SELECTED_FG, bg=self.COLOR_NAME_DISPLAY_SELECTED_BG)
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)
display_text = f"{self.current_student_info[0]}{self.current_student_info[1]}{self.current_student_info[2]}"
self.name_display.config(text=display_text, fg=self.COLOR_NAME_DISPLAY_SELECTED_FG, bg=self.COLOR_NAME_DISPLAY_SELECTED_BG)
self.update_counter()
if self.remaining_students:
self.start_button.config(state=tk.NORMAL)
else: # No more students left to call
self.start_button.config(state=tk.DISABLED)
self.name_display.config(text=display_text + " (最后一位)", fg=self.COLOR_NAME_DISPLAY_SELECTED_FG, bg=self.COLOR_NAME_DISPLAY_SELECTED_BG)
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: # Check if they were marked present first
self.called_students.remove(self.current_student_info)
if self.current_student_info not in self.absent_students: # Avoid duplicates
self.absent_students.append(self.current_student_info)
self.update_counter() # Counter includes both called and absent
display_text = f"{self.current_student_info[0]}{self.current_student_info[1]}{self.current_student_info[2]} [未到]"
self.name_display.config(text=display_text, fg=self.COLOR_NAME_DISPLAY_ABSENT_FG, bg=self.COLOR_NAME_DISPLAY_ABSENT_BG)
self.mark_absent_button.config(state=tk.DISABLED) # Prevent re-marking for current student
self.export_button.config(state=tk.NORMAL) # Enable export since there's an absent student
# else: Student might already be in absent_students or not selected, do nothing.
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]) # Sort by student ID
with open(filepath, 'w', encoding='utf-8-sig', newline='') as f:
writer = csv.writer(f)
writer.writerow(["学号", "姓名"]) # Header row
for student in sorted_absent:
writer.writerow([student[0], student[1]]) # Student ID and Name
messagebox.showinfo("导出成功", f"未到场名单已成功导出为 CSV 文件:\n{filepath}")
except Exception as e:
messagebox.showerror("导出失败", f"导出文件时出错: {e}")
def clear_data(self):
if not self.all_students: # No student list loaded
messagebox.showinfo("提示", "尚未加载学生名单,无数据可清空。")
return
if messagebox.askyesno("确认", "确定要清空当前考勤数据吗?\n这将重置点名状态,但学生名单会保留。"):
self._reset_data_core() # Clears called, absent, current_student, timers
# Re-initialize remaining_students from all_students
self.remaining_students = self.all_students[:]
random.shuffle(self.remaining_students)
self.name_display.config(text="考勤数据已清空,可重新点名", fg=self.COLOR_NAME_DISPLAY_DEFAULT_FG, bg=self.COLOR_NAME_DISPLAY_DEFAULT_BG)
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) # No student selected yet
self.export_button.config(state=tk.DISABLED) # Absent list is now empty
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) # Load button should generally be active
# --- Main program entry point ---
if __name__ == "__main__":
missing_libs = []
try:
import pypinyin
except ImportError:
missing_libs.append("pypinyin (`pip install pypinyin`)")
try:
import csv
except ImportError: # csv is standard, but good practice if it were external
missing_libs.append("csv (standard library module)")
if missing_libs:
temp_root = tk.Tk()
temp_root.withdraw()
messagebox.showerror("依赖缺失", f"请先安装或确保以下库可用:\n" + "\n".join(missing_libs))
temp_root.destroy()
exit()
root = tk.Tk()
app = RollCallApp(root)
root.mainloop()