文件夹结构重组和清理
This commit is contained in:
BIN
RollCall_App/rollcall_icon.png
Normal file
BIN
RollCall_App/rollcall_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
510
RollCall_App/课堂随机点名.html
Normal file
510
RollCall_App/课堂随机点名.html
Normal 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>
|
||||
490
RollCall_App/课堂随机点名.py
Normal file
490
RollCall_App/课堂随机点名.py
Normal 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()
|
||||
Reference in New Issue
Block a user