import tkinter as tk
from tkinter import messagebox, simpledialog, ttk
import random
import json
import os
from datetime import datetime, timedelta
from pypinyin import lazy_pinyin, Style
from threading import Thread
import openpyxl
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
class VirtualKeyboard:
"""虚拟键盘类(完全修复:可点击、可输入)"""
def __init__(self, parent):
self.parent = parent
self.window = None
self.current_entry = None
self.keys = [
['1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '='],
['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']'],
['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', "'"],
['z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/']
]
def show(self, entry_widget):
"""显示键盘并关联到指定 Entry"""
self.current_entry = entry_widget
if self.window is None or not self.window.winfo_exists():
self._create_window()
else:
self.window.deiconify() # 显示窗口
self.window.lift() # 置顶
self.window.focus_force()
def _create_window(self):
"""创建键盘窗口"""
self.window = tk.Toplevel(self.parent)
self.window.title("虚拟键盘")
self.window.geometry("600x250")
self.window.resizable(False, False)
self.window.attributes('-topmost', True) # 始终置顶
self.window.configure(bg='#f0f8ff')
# 防止关闭后无法重建
self.window.protocol("WM_DELETE_WINDOW", self.hide)
# 创建按键区域
for i, row in enumerate(self.keys):
frame = tk.Frame(self.window, bg='#f0f8ff')
frame.pack(pady=2)
for key in row:
# 使用默认参数捕获当前 key,避免闭包问题
btn = tk.Button(
frame,
text=key,
width=4,
height=2,
bg='#ffffff',
fg='#333333',
font=('Arial', 10),
command=lambda k=key: self.insert_key(k) # 关键:k=key 固定当前字符
)
btn.pack(side=tk.LEFT, padx=1)
# 功能按钮行
func_frame = tk.Frame(self.window, bg='#f0f8ff')
func_frame.pack(pady=5)
tk.Button(
func_frame, text="空格", width=8, height=2,
bg='#4CAF50', fg='white',
command=lambda: self.insert_key(" ")
).pack(side=tk.LEFT, padx=2)
tk.Button(
func_frame, text="删除", width=8, height=2,
bg='#f44336', fg='white',
command=self.delete_key
).pack(side=tk.LEFT, padx=2)
tk.Button(
func_frame, text="隐藏", width=8, height=2,
bg='#607D8B', fg='white',
command=self.hide
).pack(side=tk.LEFT, padx=2)
def insert_key(self, char):
"""插入字符到当前 Entry"""
if self.current_entry and self.current_entry.winfo_exists():
try:
self.current_entry.insert(tk.INSERT, char)
except tk.TclError:
print(f"无法插入字符 '{char}':控件已销毁")
def delete_key(self):
"""模拟退格键"""
if self.current_entry and self.current_entry.winfo_exists():
try:
pos = self.current_entry.index(tk.INSERT)
if pos > 0:
self.current_entry.delete(pos - 1)
except tk.TclError as e:
print("删除失败:", e)
def hide(self):
"""隐藏键盘"""
if self.window and self.window.winfo_exists():
self.window.withdraw()
def destroy(self):
"""销毁键盘窗口"""
if self.window:
self.window.destroy()
self.window = None
class ClassManager:
"""班级积分管理系统主类"""
def __init__(self, root):
self.root = root
self.student_frames = {}
self.root.title("13班积分管理系统")
self.root.geometry("1200x800")
self.root.resizable(False, False)
self.root.configure(bg='#f0f8ff')
self.virtual_keyboard = VirtualKeyboard(root)
# DPI 感知(仅 Windows)
try:
import ctypes
ctypes.windll.shcore.SetProcessDpiAwareness(1)
except:
pass
# 字体设置
self.title_font = ("微软雅黑", 20, "bold")
self.button_font = ("微软雅黑", 10)
self.text_font = ("微软雅黑", 9)
self.small_font = ("微软雅黑", 8)
# 颜色常量
self.BG_NORMAL = "white"
self.BG_SELECTED = "#e3f2fd"
self.COLOR_POSITIVE = "#4CAF50"
self.COLOR_NEGATIVE = "#f44336"
self.COLOR_NEUTRAL = "#333"
self.selected_students = set()
# ======== 排序方式:默认按积分 ========
self.sort_mode = "score" # 可选: "score", "name"
# 连续操作模式标志位
self.continuous_mode = False
self.continuous_var = tk.BooleanVar(value=self.continuous_mode)
# 加载用户设置
self.load_settings()
# 登录验证
if not self.check_login_password():
self.root.destroy()
return
# 初始化数据
self.students = self.load_data()
if not self.students:
self.initialize_from_roster()
self.admin_password = self.load_admin_password()
# 构建拼音缓存
self.pinyin_cache = {}
self.build_pinyin_cache()
# 小组管理
self.groups = self.load_groups()
# 创建界面
self.create_widgets()
self.update_display()
# 绑定窗口大小变化事件(防抖)
self.resize_after_id = None
self.root.bind("<Configure>", self.on_window_resize)
# 拖动变量
self.mini_window = None
self.mini_drag_data = {"x": 0, "y": 0}
# 上次保存时间(节流)
self.last_save_time = 0
# 协议关闭
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
def build_pinyin_cache(self):
"""构建姓名到拼音首字母和全拼的缓存"""
for name in self.students:
if not name:
continue
initials = ''.join([p[0].lower() for p in lazy_pinyin(name, style=Style.FIRST_LETTER)])
full_py = ''.join(lazy_pinyin(name, style=Style.NORMAL)).lower()
self.pinyin_cache[name] = {'initials': initials, 'full_pinyin': full_py}
def check_login_password(self):
login_password = self.load_login_password()
if not login_password:
return self.setup_login_password()
attempts = 3
while attempts > 0:
password = self.ask_password("登录验证", "请输入登录密码:")
if password == login_password:
return True
attempts -= 1
msg = f"密码错误,还剩{attempts}次机会" if attempts > 0 else "密码错误次数过多"
messagebox.showerror("密码错误", msg, parent=self.root)
if attempts == 0:
return False
return False
def setup_login_password(self):
password = self.ask_password("设置登录密码", "请设置登录密码:")
if not password:
return False
confirm = self.ask_password("确认密码", "请再次输入:")
if password != confirm:
messagebox.showerror("错误", "两次输入不一致")
return False
self.save_login_password(password)
messagebox.showinfo("成功", "登录密码设置成功")
return True
def load_login_password(self):
try:
if os.path.exists("login_password.json"):
with open("login_password.json", "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
print("加载登录密码失败:", e)
return None
def save_login_password(self, pwd):
with open("login_password.json", "w", encoding="utf-8") as f:
json.dump(pwd, f, ensure_ascii=False)
def on_closing(self):
if messagebox.askokcancel("退出", "确定要退出吗?"):
self.save_data_async()
self.save_settings()
# 销毁虚拟键盘窗口
self.virtual_keyboard.destroy()
if self.mini_window and self.mini_window.winfo_exists():
self.mini_window.destroy()
self.root.destroy()
def load_admin_password(self):
try:
if os.path.exists("admin_password.json"):
with open("admin_password.json", "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
print("加载管理密码失败:", e)
return "12"
def save_admin_password(self):
with open("admin_password.json", "w", encoding="utf-8") as f:
json.dump(self.admin_password, f, ensure_ascii=False)
def change_login_password(self):
old = self.ask_password("验证原密码", "请输入原密码:")
current = self.load_login_password()
if old != current:
messagebox.showerror("错误", "原密码错误!")
return
new = self.ask_password("新密码", "请输入新密码:")
if not new:
return
confirm = self.ask_password("确认", "请再输一次:")
if new != confirm:
messagebox.showerror("错误", "两次不一致!")
return
self.save_login_password(new)
messagebox.showinfo("成功", "登录密码修改成功!")
def change_admin_password(self):
old = self.ask_password("验证原密码", "请输入管理密码:")
if old != self.admin_password:
messagebox.showerror("错误", "原密码错误!")
return
new = self.ask_password("新管理密码", "请输入两位数字:")
if not new or len(new) != 2 or not new.isdigit():
messagebox.showerror("错误", "必须是两位数字!")
return
confirm = self.ask_password("确认", "请再输一次:")
if new != confirm:
messagebox.showerror("错误", "两次不一致!")
return
self.admin_password = new
self.save_admin_password()
messagebox.showinfo("成功", "管理密码修改成功!")
def forget_login_password(self):
admin = self.ask_password("验证管理密码", "请输入管理密码:")
if admin != self.admin_password:
messagebox.showerror("错误", "管理密码错误!")
return
new = self.ask_password("重置登录密码", "请输入新登录密码:")
if not new:
return
confirm = self.ask_password("确认", "请再输一次:")
if new != confirm:
messagebox.showerror("错误", "两次不一致!")
return
self.save_login_password(new)
messagebox.showinfo("成功", "登录密码已重置!")
def ask_password(self, title, prompt):
dialog = tk.Toplevel(self.root)
dialog.title(title)
dialog.geometry("400x200")
dialog.transient(self.root)
dialog.grab_set()
dialog.configure(bg='#f0f8ff')
tk.Label(dialog, text=prompt, font=self.text_font, bg='#f0f8ff').pack(pady=20)
pwd_var = tk.StringVar()
entry = tk.Entry(dialog, textvariable=pwd_var, show='*', font=self.text_font, width=20)
entry.pack(pady=10)
# 绑定虚拟键盘
entry.bind("<Button-1>", lambda e: self.virtual_keyboard.show(entry))
result = []
def on_ok():
result.append(pwd_var.get())
dialog.destroy()
def on_cancel():
result.append(None)
dialog.destroy()
btn_frame = tk.Frame(dialog, bg='#f0f8ff')
btn_frame.pack(pady=20)
tk.Button(btn_frame, text="确定", command=on_ok, bg='#4CAF50', fg='white', width=10).pack(side=tk.LEFT, padx=10)
tk.Button(btn_frame, text="取消", command=on_cancel, bg='#f44336', fg='white', width=10).pack(side=tk.LEFT, padx=10)
entry.focus_set()
dialog.wait_window()
return result[0] if result else None
def initialize_from_roster(self):
roster = [
"安琪儿", "白睦尧", "白沛妍", "鲍星焱", "边子轩", "常佳怡", "钞依冉", "崔浩然", "崔嘉诺",
"高博程", "高方圆", "高海翔", "高瑞泽", "高硕", "高雅萍", "郭欣宇", "韩静舒", "贺佳萱",
"贺予绘", "黄钰涵", "李佳倩", "李金格", "李俊辰", "梁家伊", "刘博宇", "刘锐宇", "刘思乔",
"刘旭尧", "刘雅涵", "柳登程", "马涛涛", "孟家旭", "苗宇辰", "裴雨轩", "拓雨诺", "王嘉浩",
"王婷雨", "王怡婷", "王梓", "谢孟芝", "徐茂瑞", "杨少轩", "杨雪琦", "尤宇琪", "尤子潇",
"张皓茗", "张皓雅", "张家瑜", "张朴淳", "张瑞霞", "张雅琪", "赵雅彤", "赵勇杰", "郑梦茹"
]
for name in roster:
self.students[name] = {"score": 0, "history": []}
self.save_data_throttled()
def load_data(self):
try:
if os.path.exists("class_data.json"):
with open("class_data.json", "r", encoding="utf-8") as f:
data = json.load(f)
# 兼容旧格式:{name: score}
for k, v in data.items():
if isinstance(v, int):
data[k] = {"score": v, "history": []}
return data
except Exception as e:
print("加载数据失败:", e)
return {}
def save_data_throttled(self):
now = datetime.now().timestamp()
if hasattr(self, 'last_save_time') and now - self.last_save_time < 2:
return
self.save_data_async()
self.last_save_time = now
def save_data_async(self):
def _save():
try:
with open("class_data.json", "w", encoding="utf-8") as f:
json.dump(self.students, f, ensure_ascii=False, indent=2)
except Exception as e:
print("异步保存失败:", e)
Thread(target=_save, daemon=True).start()
def load_groups(self):
"""加载学生分组信息"""
try:
if os.path.exists("groups.json"):
with open("groups.json", "r", encoding="utf-8") as f:
data = json.load(f)
# 验证数据格式
for group_name, members in data.items():
if not isinstance(members, list):
continue
for name in members[:]:
if name not in self.students:
members.remove(name)
return data
except Exception as e:
print("加载小组失败:", e)
# 默认创建几个小组
return {}
def save_groups(self):
"""异步保存小组信息"""
def _save():
try:
with open("groups.json", "w", encoding="utf-8") as f:
json.dump(self.groups, f, ensure_ascii=False, indent=2)
except Exception as e:
print("保存小组失败:", e)
Thread(target=_save, daemon=True).start()
def add_student(self):
name = simpledialog.askstring("添加学生", "请输入学生姓名:", parent=self.root)
if not name:
return
if name in self.students:
messagebox.showwarning("重复", f"学生 {name} 已存在!")
return
self.students[name] = {"score": 0, "history": []}
initials = ''.join([p[0].lower() for p in lazy_pinyin(name, style=Style.FIRST_LETTER)])
full_py = ''.join(lazy_pinyin(name, style=Style.NORMAL)).lower()
self.pinyin_cache[name] = {'initials': initials, 'full_pinyin': full_py}
self.save_data_throttled()
self.update_display()
messagebox.showinfo("成功", f"已添加学生:{name}")
def show_add_points_menu(self):
self.adjust_points_with_buttons("加分", 1)
def show_subtract_points_menu(self):
self.adjust_points_with_buttons("减分", -1)
def adjust_points_with_buttons(self, action, multiplier):
if not self.selected_students:
messagebox.showwarning("提示", "请先选择学生!", parent=self.root)
return
dialog = tk.Toplevel(self.root)
dialog.title(action)
dialog.geometry("440x220")
dialog.resizable(False, False)
dialog.transient(self.root)
dialog.grab_set()
dialog.configure(bg='#f0f8ff')
tk.Label(
dialog, text=f"请选择{action}分数:", font=self.text_font, bg='#f0f8ff'
).pack(pady=(15, 5))
button_frame = tk.Frame(dialog, bg='#f0f8ff')
button_frame.pack(pady=10)
values = [1, 2, 3, 4, 5, 10]
colors = ['#4CAF50', '#2196F3', '#FF9800', '#9C27B0', '#607D8B', '#795548']
texts = [f"{v}分" for v in values]
for i in range(len(values)):
btn = tk.Button(
button_frame, text=texts[i], font=self.button_font, width=8, height=2,
bg=colors[i], fg='white',
command=lambda v=values[i]: self.do_adjust_points(dialog, v * multiplier)
)
btn.pack(side=tk.LEFT, padx=8)
custom_frame = tk.Frame(dialog, bg='#f0f8ff')
custom_frame.pack(pady=8)
tk.Button(
custom_frame, text="自定义", font=self.button_font, width=8, height=2,
bg='#607D8B', fg='white',
command=lambda: self.do_adjust_points_custom(dialog, multiplier)
).pack()
tk.Button(
dialog, text="取消", font=self.button_font, width=10,
bg='#f44336', fg='white', command=dialog.destroy
).pack(pady=10)
dialog.update_idletasks()
x = self.root.winfo_x() + (self.root.winfo_width() // 2) - (dialog.winfo_width() // 2)
y = self.root.winfo_y() + (self.root.winfo_height() // 2) - (dialog.winfo_height() // 2)
dialog.geometry(f"+{x}+{y}")
dialog.focus_force()
def do_adjust_points(self, dialog, change):
now = datetime.now().strftime("%Y-%m-%d %H:%M")
for name in self.selected_students:
self.students[name]["score"] += change
self.students[name]["history"].append({
"time": now,
"change": change,
"type": "add" if change > 0 else "sub"
})
if not self.continuous_mode:
self.selected_students.clear()
dialog.destroy()
self.save_data_throttled()
self.update_display()
messagebox.showinfo("成功", f"{change:+} 分操作完成!", parent=self.root)
def do_adjust_points_custom(self, dialog, multiplier):
value = simpledialog.askinteger("自定义", "请输入分数:", parent=dialog, minvalue=1, maxvalue=100)
if value is not None:
self.do_adjust_points(dialog, value * multiplier)
def random_select_multi(self):
dialog = tk.Toplevel(self.root)
dialog.title("随机抽选")
dialog.geometry("320x180")
dialog.resizable(False, False)
dialog.transient(self.root)
dialog.grab_set()
dialog.configure(bg='#f0f8ff')
tk.Label(dialog, text="请选择抽取人数:", font=self.text_font, bg='#f0f8ff').pack(pady=10)
ctrl_frame = tk.Frame(dialog, bg='#f0f8ff')
ctrl_frame.pack(pady=10)
count_var = tk.IntVar(value=1)
def decrease():
if count_var.get() > 1:
count_var.set(count_var.get() - 1)
def increase():
count_var.set(count_var.get() + 1)
tk.Button(ctrl_frame, text="−", font=("Arial", 14), width=3, command=decrease, bg="#f44336", fg="white").pack(side=tk.LEFT)
num_label = tk.Label(
ctrl_frame, textvariable=count_var, font=("Arial", 16, "bold"), width=5, relief=tk.SUNKEN, bg="white"
)
num_label.pack(side=tk.LEFT, padx=10)
tk.Button(ctrl_frame, text="+", font=("Arial", 14), width=3, command=increase, bg="#4CAF50", fg="white").pack(side=tk.LEFT)
def do_select():
n = count_var.get()
total = len(self.students)
if n > total:
messagebox.showwarning("提示", f"学生总数只有 {total} 人,无法抽取 {n} 人!", parent=dialog)
return
candidates = list(self.students.keys())
selected = random.sample(candidates, n)
self.selected_students.clear()
for name in selected:
self.selected_students.add(name)
self.update_display()
result = "\n".join([f"第{i + 1}位:{name}" for i, name in enumerate(selected)])
messagebox.showinfo(f"抽中 {n} 人", f"🎉 抽中名单:\n\n{result}", parent=dialog)
dialog.destroy()
btn_frame = tk.Frame(dialog, bg='#f0f8ff')
btn_frame.pack(pady=20)
tk.Button(
btn_frame, text="确定抽取", font=self.button_font, width=10,
bg='#4CAF50', fg='white', command=do_select
).pack(side=tk.LEFT, padx=10)
tk.Button(
btn_frame, text="取消", font=self.button_font, width=10,
bg='#f44336', fg='white', command=dialog.destroy
).pack(side=tk.LEFT, padx=10)
dialog.focus_force()
def show_history(self):
if not self.selected_students:
messagebox.showwarning("提示", "请先选择学生!", parent=self.root)
return
selected_names = list(self.selected_students)
if len(selected_names) == 1:
name = selected_names[0]
history = self.students[name]["history"]
if not history:
messagebox.showinfo("历史记录", f"{name} 暂无积分变动。", parent=self.root)
return
hist_text = "\n".join([f"[{r['time']}] {'+' if r['change'] > 0 else ''}{r['change']}" for r in history])
detail_win = tk.Toplevel(self.root)
detail_win.title(f"{name} 的积分历史")
detail_win.geometry("400x300")
text = tk.Text(detail_win, font=self.text_font, wrap=tk.WORD)
text.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
text.insert(tk.END, hist_text)
text.config(state=tk.DISABLED)
return
# 多个学生:使用 ttk.Notebook 标签页
self.show_multi_history_tabs(selected_names)
def show_multi_history_tabs(self, names):
"""显示多个学生的积分历史(标签页形式)"""
win = tk.Toplevel(self.root)
win.title(f"多名学生积分历史(共{len(names)}人)")
win.geometry("800x500")
win.configure(bg='#f0f8ff')
notebook = ttk.Notebook(win)
notebook.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
total_changes = []
for name in names:
data = self.students[name]
history = data["history"]
tab = tk.Frame(notebook, bg='white')
notebook.add(tab, text=name[:6])
if not history:
lbl = tk.Label(tab, text="暂无积分变动", font=self.text_font, fg="#999", bg="white")
lbl.pack(pady=20)
continue
net_change = sum(r["change"] for r in history)
total_changes.append((name, net_change))
text = tk.Text(tab, font=self.text_font, wrap=tk.WORD, height=15)
scrollbar = tk.Scrollbar(tab, orient=tk.VERTICAL, command=text.yview)
text.configure(yscrollcommand=scrollbar.set)
for record in history:
change = record["change"]
sign = "+" if change > 0 else ""
line = f"[{record['time']}] {sign}{change}\n"
text.insert(tk.END, line, ("positive" if change > 0 else "negative"))
text.tag_configure("positive", foreground="#4CAF50")
text.tag_configure("negative", foreground="#f44336")
text.config(state=tk.DISABLED, bg="white")
text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
stat_frame = tk.Frame(win, bg='#f0f8ff')
stat_frame.pack(fill=tk.X, padx=10, pady=5)
total_net = sum(c[1] for c in total_changes)
stat_label = tk.Label(
stat_frame,
text=f"共 {len(names)} 人 | 总净变化: {total_net:+}",
font=("微软雅黑", 10, "bold"),
bg='#f0f8ff',
fg="#333"
)
stat_label.pack()
def sort_by_change():
sorted_names = [n for n, _ in sorted(total_changes, key=lambda x: x[1], reverse=True)]
for i in range(len(notebook.tabs()) - 1, -1, -1):
notebook.forget(i)
for name in sorted_names:
if name in self.selected_students:
data = self.students[name]
tab = tk.Frame(notebook, bg='white')
text = tk.Text(tab, font=self.text_font, wrap=tk.WORD)
scrollbar = tk.Scrollbar(tab, command=text.yview)
text.configure(yscrollcommand=scrollbar.set)
for r in data["history"]:
sign = "+" if r["change"] > 0 else ""
text.insert(tk.END, f"[{r['time']}] {sign}{r['change']}\n")
text.config(state=tk.DISABLED, bg="white")
text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
notebook.add(tab, text=name[:6])
tk.Button(
stat_frame, text="按进步排序", font=self.small_font,
command=sort_by_change,
bg="#FF9800", fg="white"
).pack(side=tk.RIGHT)
win.focus_force()
def select_all(self):
self.selected_students = set(self.students.keys())
self.update_display()
def deselect_all(self):
self.selected_students.clear()
self.update_display()
def reset_points(self):
if messagebox.askyesno("确认", "确定要重置所有学生的积分为0吗?", parent=self.root):
now = datetime.now().strftime("%Y-%m-%d %H:%M")
for name in self.students:
old_score = self.students[name]["score"]
if old_score != 0:
self.students[name]["history"].append({
"time": now, "change": -old_score, "type": "reset"
})
self.students[name]["score"] = 0
self.save_data_throttled()
self.update_display()
messagebox.showinfo("成功", "所有积分已重置为0", parent=self.root)
def clear_all_history(self):
if messagebox.askyesno("确认", "确定要清除所有历史记录吗?", parent=self.root):
for name in self.students:
self.students[name]["history"] = []
self.save_data_throttled()
messagebox.showinfo("成功", "所有历史记录已清除", parent=self.root)
def manage_groups(self):
"""打开小组管理界面"""
dialog = tk.Toplevel(self.root)
dialog.title("管理小组")
dialog.geometry("700x500")
dialog.transient(self.root)
dialog.grab_set()
dialog.configure(bg='#f0f8ff')
tk.Label(dialog, text="小组管理", font=self.title_font, bg='#f0f8ff').pack(pady=10)
main_frame = tk.Frame(dialog, bg='#f0f8ff')
main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
left = tk.Frame(main_frame, bg='#f0f8ff')
left.pack(side=tk.LEFT, fill=tk.Y, padx=5)
right = tk.Frame(main_frame, bg='#f0f8ff')
right.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=5)
tk.Label(left, text="小组", font=self.text_font, bg='#f0f8ff').pack(anchor=tk.W)
group_listbox = tk.Listbox(left, font=self.text_font, width=15, height=15)
g_scroll = tk.Scrollbar(left, command=group_listbox.yview)
g_scroll.pack(side=tk.RIGHT, fill=tk.Y)
group_listbox.config(yscrollcommand=g_scroll.set)
for g in self.groups:
group_listbox.insert(tk.END, g)
tk.Label(right, text="成员", font=self.text_font, bg='#f0f8ff').pack(anchor=tk.W)
member_listbox = tk.Listbox(right, font=self.text_font, selectmode=tk.MULTIPLE, height=15)
m_scroll = tk.Scrollbar(right, command=member_listbox.yview)
m_scroll.pack(side=tk.RIGHT, fill=tk.Y)
member_listbox.config(yscrollcommand=m_scroll.set)
member_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
def update_members(*args):
selection = group_listbox.curselection()
if not selection:
return
idx = selection[0]
group_name = group_listbox.get(idx)
member_listbox.delete(0, tk.END)
for name in self.students:
if name in self.groups.get(group_name, []):
member_listbox.insert(tk.END, name)
group_listbox.bind("<<ListboxSelect>>", update_members)
btn_frame = tk.Frame(dialog, bg='#f0f8ff')
btn_frame.pack(pady=10)
def add_group():
name = simpledialog.askstring("新小组", "请输入小组名称:", parent=dialog)
if name and name not in self.groups:
self.groups[name] = []
group_listbox.insert(tk.END, name)
def del_group():
sel = group_listbox.curselection()
if not sel:
return
name = group_listbox.get(sel[0])
if messagebox.askyesno("确认", f"删除小组 {name}?", parent=dialog):
del self.groups[name]
group_listbox.delete(sel[0])
def save_members():
sel = group_listbox.curselection()
if not sel:
return
group_name = group_listbox.get(sel[0])
all_selected = [member_listbox.get(i) for i in member_listbox.curselection()]
self.groups[group_name] = all_selected
self.save_groups()
messagebox.showinfo("成功", "已保存成员", parent=dialog)
tk.Button(btn_frame, text="新建小组", command=add_group, bg="#4CAF50", fg="white").pack(side=tk.LEFT, padx=5)
tk.Button(btn_frame, text="删除小组", command=del_group, bg="#f44336", fg="white").pack(side=tk.LEFT, padx=5)
tk.Button(btn_frame, text="保存成员", command=save_members, bg="#2196F3", fg="white").pack(side=tk.LEFT, padx=5)
tk.Button(btn_frame, text="关闭", command=dialog.destroy, bg="#607D8B", fg="white").pack(side=tk.LEFT, padx=5)
if self.groups:
group_listbox.selection_set(0)
update_members()
def filter_by_category(self, category_type, value):
"""
按分类筛选学生
:param category_type: 'all', 'surname', 'group'
:param value: 对应值
"""
if category_type == "all":
filtered = self.students
elif category_type == "surname":
filtered = {name: data for name, data in self.students.items() if name.startswith(value)}
elif category_type == "group":
member_names = self.groups.get(value, [])
filtered = {name: self.students[name] for name in member_names if name in self.students}
else:
filtered = self.students
desc = f"【{value}】" if value else "全部"
self.display_filtered(filtered, f"{desc} 学生")
def create_widgets(self):
top_frame = tk.Frame(self.root, bg='#3b5998', height=50)
top_frame.pack(fill=tk.X)
top_frame.pack_propagate(False)
tk.Label(top_frame, text="13班积分管理系统", font=self.title_font, fg='white', bg='#3b5998').pack(side=tk.LEFT, padx=20, pady=8)
tk.Button(top_frame, text="缩小", command=self.minimize_window, font=self.button_font, bg='#607D8B', fg='white').pack(side=tk.RIGHT, padx=10)
self.manage_btn = tk.Button(
top_frame, text="管理", command=self.show_management_menu,
font=self.button_font, bg='#E91E63', fg='white'
)
self.manage_btn.pack(side=tk.RIGHT, padx=10)
button_frame = tk.Frame(self.root, bg='#f0f8ff')
button_frame.pack(pady=5, fill=tk.X, padx=10)
buttons = [
("添加学生", self.add_student, '#4CAF50'),
("加分", self.show_add_points_menu, '#2196F3'),
("减分", self.show_subtract_points_menu, '#f44336'),
("随机抽选", self.random_select_multi, '#9C27B0'),
("积分历史", self.show_history, '#FF9800'),
("全选", self.select_all, '#607D8B'),
("取消全选", self.deselect_all, '#795548'),
("生成周报", self.generate_weekly_report, '#009688'),
]
for i, (text, cmd, color) in enumerate(buttons):
tk.Button(
button_frame, text=text, command=cmd, width=10, height=1,
font=self.button_font, bg=color, fg='white'
).grid(row=0, column=i, padx=4, pady=2)
display_frame = tk.Frame(self.root, bg='#f0f8ff')
display_frame.pack(fill=tk.BOTH, expand=True, padx=8, pady=5)
category_frame = tk.Frame(display_frame, bg='#f0f8ff')
category_frame.pack(fill=tk.X, pady=(0, 3))
tk.Label(category_frame, text="分类:", font=self.small_font, bg='#f0f8ff').pack(side=tk.LEFT)
surnames = sorted(set(name[0] for name in self.students.keys()))
btn_frame = tk.Frame(category_frame, bg='#f0f8ff')
btn_frame.pack(side=tk.LEFT)
tk.Button(btn_frame, text="全部", font=("微软雅黑", 8), width=3,
command=lambda: self.filter_by_category("all", None),
bg="#4CAF50", fg="white").pack(side=tk.LEFT, padx=1)
for s in surnames:
tk.Button(btn_frame, text=s, font=("微软雅黑", 8), width=2,
command=lambda x=s: self.filter_by_category("surname", x),
bg="#e0e0e0").pack(side=tk.LEFT, padx=1)
for group_name in self.groups:
tk.Button(btn_frame, text=group_name, font=("微软雅黑", 8), width=4,
command=lambda x=group_name: self.filter_by_category("group", x),
bg="#2196F3", fg="white").pack(side=tk.LEFT, padx=1)
tk.Button(btn_frame, text="管理小组", font=("微软雅黑", 8), width=6,
command=self.manage_groups,
bg="#E91E63", fg="white").pack(side=tk.LEFT, padx=1)
search_frame = tk.Frame(display_frame, bg='#f0f8ff')
search_frame.pack(fill=tk.X, pady=(0, 5))
tk.Label(search_frame, text="搜索:", font=self.text_font, bg='#f0f8ff').pack(side=tk.LEFT)
# === 排序方式选择 ===
sort_frame = tk.Frame(display_frame, bg='#f0f8ff')
sort_frame.pack(fill=tk.X, pady=(0, 5))
tk.Label(sort_frame, text="排序:", font=self.small_font, bg='#f0f8ff').pack(side=tk.LEFT)
self.sort_var = tk.StringVar(value="按积分排序" if self.sort_mode == "score" else "按姓名排序")
sort_combo = ttk.Combobox(
sort_frame,
textvariable=self.sort_var,
values=["按积分排序", "按姓名排序"],
state="readonly",
width=10,
font=self.small_font
)
sort_combo.pack(side=tk.LEFT, padx=5)
sort_combo.bind("<<ComboboxSelected>>", self.on_sort_change)
# 提示标签
tk.Label(sort_frame, text="💡 双击学生可选中", font=self.small_font, fg="#666", bg='#f0f8ff').pack(side=tk.RIGHT)
# 搜索框
self.search_var = tk.StringVar()
self.search_var.trace("w", self.on_search_change)
search_entry = tk.Entry(search_frame, textvariable=self.search_var, font=self.text_font, width=30)
search_entry.pack(side=tk.LEFT, padx=5, fill=tk.X, expand=True)
search_entry.bind("<Button-1>", lambda e: self.virtual_keyboard.show(search_entry))
container = tk.Frame(display_frame, bg='#f0f8ff')
container.pack(fill=tk.BOTH, expand=True)
self.canvas = tk.Canvas(container, bg='#f0f8ff', highlightthickness=0)
scrollbar = tk.Scrollbar(container, orient="vertical", command=self.canvas.yview)
self.scrollable_frame = tk.Frame(self.canvas, bg='#f0f8ff')
self.scrollable_frame.bind("<Configure>", lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")))
self.canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw")
self.canvas.configure(yscrollcommand=scrollbar.set)
self.canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
status_bar = tk.Label(
self.root, text="就绪", bd=1, relief=tk.SUNKEN, anchor=tk.W, height=2
)
status_bar.pack(side=tk.BOTTOM, fill=tk.X, padx=0, pady=0)
def on_window_resize(self, event):
if event.widget != self.root:
return
if self.resize_after_id:
self.root.after_cancel(self.resize_after_id)
self.resize_after_id = self.root.after(50, self.check_and_update_display)
def check_and_update_display(self):
try:
current_width = self.canvas.winfo_width()
if hasattr(self, '_last_canvas_width') and self._last_canvas_width == current_width:
return
self._last_canvas_width = current_width
self.update_display()
except tk.TclError:
pass
def on_search_change(self, *args):
query = self.search_var.get().strip().lower()
if not query:
self.update_display()
return
filtered = {}
for name, data in self.students.items():
info = self.pinyin_cache.get(name, {})
if (query in name.lower() or
query in info.get('full_pinyin', '') or
query == info.get('initials', '') or
all(c in info.get('initials', '') for c in query)):
filtered[name] = data
self.display_filtered(filtered, f"匹配“{query}”的结果")
def display_filtered(self, students_dict, desc="结果"):
for widget in self.scrollable_frame.winfo_children():
widget.destroy()
if not students_dict:
lbl = tk.Label(self.scrollable_frame, text="无匹配学生", font=self.text_font, fg="#999")
lbl.pack(pady=20)
return
items = list(students_dict.items())
if self.sort_mode == "score":
# 按积分降序
sorted_items = sorted(items, key=lambda x: x[1]["score"], reverse=True)
else: # self.sort_mode == "name"
# 按姓名拼音升序
sorted_items = sorted(items, key=lambda x: self.pinyin_cache.get(x[0], {}).get('full_pinyin', ''), reverse=False)
self._create_blocks(sorted_items)
def _create_blocks(self, student_list):
if not student_list:
return
try:
width = max(self.canvas.winfo_width(), 800)
except tk.TclError:
return
blocks_per_row = 9
block_width = 105
block_height = 80
new_frames = {}
for i, (name, data) in enumerate(student_list):
row, col = divmod(i, blocks_per_row)
is_selected = name in self.selected_students
bg_color = self.BG_SELECTED if is_selected else self.BG_NORMAL
highlight_color = "#1976D2" if is_selected else "gray"
highlight_thickness = 2 if is_selected else 1
frame = None
if name in self.student_frames:
try:
if self.student_frames[name].winfo_exists():
frame = self.student_frames[name]
except tk.TclError:
pass
if frame is not None:
frame.grid(row=row, column=col, padx=3, pady=3, sticky="nsew")
frame.config(bg=bg_color, highlightbackground=highlight_color, highlightthickness=highlight_thickness)
for child in frame.winfo_children():
child.config(bg=bg_color)
else:
frame = tk.Frame(
self.scrollable_frame,
width=block_width,
height=block_height,
relief=tk.RAISED,
borderwidth=1,
bg=bg_color,
highlightbackground=highlight_color,
highlightthickness=highlight_thickness
)
frame.grid(row=row, column=col, padx=3, pady=3, sticky="nsew")
frame.grid_propagate(False)
tk.Label(frame, text=f"{i + 1}", font=self.small_font, bg=bg_color, fg='#666').place(x=2, y=1)
tk.Label(frame, text=name, font=self.text_font, bg=bg_color, wraplength=90, fg='#333').place(relx=0.5, y=22, anchor=tk.CENTER)
score = data["score"]
score_color = self.COLOR_POSITIVE if score > 0 else self.COLOR_NEGATIVE if score < 0 else self.COLOR_NEUTRAL
score_text = f"+{score}" if score > 0 else str(score)
tk.Label(frame, text=score_text, font=("微软雅黑", 11, "bold"), bg=bg_color, fg=score_color).place(relx=0.5, y=52, anchor=tk.CENTER)
frame.bind("<Button-1>", self.make_click_handler(name))
for child in frame.winfo_children():
child.bind("<Button-1>", lambda e, f=frame: f.event_generate("<Button-1>", x=e.x, y=e.y))
child.bind("<ButtonRelease-1>", lambda e: "break")
child.configure(cursor="")
new_frames[name] = frame
for name, old_frame in self.student_frames.items():
if name not in new_frames:
try:
old_frame.destroy()
except tk.TclError:
pass
self.student_frames = new_frames
self.scrollable_frame.update_idletasks()
self.canvas.config(scrollregion=self.canvas.bbox("all"))
def make_click_handler(self, name):
def handler(event):
widget = event.widget
if not widget.winfo_exists():
return
is_selected = name in self.selected_students
new_state = not is_selected
if new_state:
self.selected_students.add(name)
else:
self.selected_students.discard(name)
frame = self.student_frames.get(name)
if frame and frame.winfo_exists():
bg_color = self.BG_SELECTED if new_state else self.BG_NORMAL
highlight = "#1976D2" if new_state else "gray"
thickness = 2 if new_state else 1
frame.config(bg=bg_color, highlightbackground=highlight, highlightthickness=thickness)
for child in frame.winfo_children():
child.config(bg=bg_color)
self.save_data_throttled()
return handler
def update_display(self):
self.display_filtered(self.students, "全部学生")
def show_management_menu(self):
menu = tk.Menu(self.root, tearoff=0, font=self.text_font)
menu.add_command(label="修改登录密码", command=self.change_login_password)
menu.add_command(label="修改管理密码", command=self.change_admin_password)
menu.add_separator()
menu.add_command(label="忘记登录密码", command=self.forget_login_password)
menu.add_separator()
menu.add_command(label="重置积分", command=self.reset_points)
menu.add_command(label="清除全部历史", command=self.clear_all_history)
menu.add_separator()
menu.add_checkbutton(
label="连续操作模式",
variable=self.continuous_var,
command=self.toggle_continuous_mode,
font=self.text_font
)
x = self.manage_btn.winfo_rootx()
y = self.manage_btn.winfo_rooty() + self.manage_btn.winfo_height()
menu.post(x, y)
def toggle_continuous_mode(self):
self.continuous_mode = self.continuous_var.get()
def minimize_window(self):
if self.mini_window and self.mini_window.winfo_exists():
self.mini_window.lift()
return
self.mini_window = tk.Toplevel(self.root)
self.mini_window.title("13班")
self.mini_window.geometry("100x40")
self.mini_window.overrideredirect(True)
self.mini_window.attributes('-topmost', True)
screen_width = self.mini_window.winfo_screenwidth()
self.mini_window.geometry(f"+{screen_width - 110}+10")
self.mini_window.configure(bg='#3b5998')
label = tk.Label(
self.mini_window, text="班级管理", font=("微软雅黑", 12, "bold"),
fg='white', bg='#3b5998', cursor="hand2"
)
label.pack(fill=tk.BOTH, expand=True)
label.bind("<ButtonPress-1>", self.start_mini_drag)
label.bind("<B1-Motion>", self.on_mini_drag)
label.bind("<Double-Button-1>", self.restore_window)
label.bind("<Enter>", lambda e: label.config(bg='#4a69a0'))
label.bind("<Leave>", lambda e: label.config(bg='#3b5998'))
self.root.withdraw()
def start_mini_drag(self, event):
self.mini_drag_data["x"] = event.x
self.mini_drag_data["y"] = event.y
def on_mini_drag(self, event):
x = self.mini_window.winfo_x() + (event.x - self.mini_drag_data["x"])
y = self.mini_window.winfo_y() + (event.y - self.mini_drag_data["y"])
self.mini_window.geometry(f"+{x}+{y}")
def restore_window(self, event=None):
if self.mini_window and self.mini_window.winfo_exists():
self.mini_window.destroy()
self.mini_window = None
self.root.deiconify()
self.root.lift()
def generate_weekly_report(self):
week_ago = datetime.now() - timedelta(days=7)
report_win = tk.Toplevel(self.root)
report_win.title("积分周报")
report_win.geometry("900x600")
report_win.configure(bg='#f0f8ff')
tk.Label(report_win, text="积分周报(本周变化总览)", font=self.title_font, bg='#f0f8ff').pack(pady=10)
tree_frame = tk.Frame(report_win)
tree_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
scroll = ttk.Scrollbar(tree_frame)
scroll.pack(side=tk.RIGHT, fill=tk.Y)
columns = ("name", "weekly_change", "changes")
tree = ttk.Treeview(tree_frame, columns=columns, show="headings", yscrollcommand=scroll.set)
tree.heading("name", text="姓名")
tree.heading("weekly_change", text="净变化")
tree.heading("changes", text="变动详情")
tree.column("name", width=100, anchor=tk.CENTER)
tree.column("weekly_change", width=80, anchor=tk.CENTER)
tree.column("changes", width=400, anchor=tk.W)
tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scroll.config(command=tree.yview)
data = {}
for name, student in self.students.items():
changes = [
r for r in student["history"]
if datetime.strptime(r["time"], "%Y-%m-%d %H:%M") >= week_ago
]
values = [f"{'+' if c['change'] > 0 else ''}{c['change']}" for c in changes]
summary = " ".join(values) + " 分" if values else "无变动"
net = sum(c["change"] for c in changes)
data[name] = {"summary": summary, "net": net}
for name in sorted(data, key=lambda x: data[x]["net"], reverse=True):
info = data[name]
tag = ("positive",) if info["net"] > 0 else ("negative",) if info["net"] < 0 else ()
tree.insert("", tk.END, values=(name, info["net"], info["summary"]), tags=tag)
tree.tag_configure("positive", foreground=self.COLOR_POSITIVE)
tree.tag_configure("negative", foreground=self.COLOR_NEGATIVE)
btn_frame = tk.Frame(report_win, bg='#f0f8ff')
btn_frame.pack(pady=10)
tk.Button(
btn_frame, text="导出 TXT", command=lambda: self.export_weekly_report_txt(data),
bg='#4CAF50', fg='white', font=self.button_font, width=10
).pack(side=tk.LEFT, padx=5)
tk.Button(
btn_frame, text="导出 Excel", command=lambda: self.export_weekly_report_excel(data),
bg='#FF9800', fg='white', font=self.button_font, width=10
).pack(side=tk.LEFT, padx=5)
def export_weekly_report_txt(self, data):
try:
filename = f"周报_{datetime.now().strftime('%Y%m%d_%H%M')}.txt"
with open(filename, "w", encoding="utf-8") as f:
f.write("13班积分周报\n\n")
for name, d in sorted(data.items(), key=lambda x: x[1]["net"], reverse=True):
f.write(f"{name}: {d['summary']} (净变化: {d['net']})\n")
messagebox.showinfo("成功", f"已导出至 {filename}")
except Exception as e:
messagebox.showerror("错误", str(e))
def export_weekly_report_excel(self, data):
try:
filename = f"周报_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx"
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "积分周报"
cell = ws.cell(1, 1, "13班积分周报")
cell.font = Font(size=16, bold=True, color="1a5fb4")
ws.merge_cells('A1:C1')
headers = ["姓名", "净变化", "详细变动"]
for col_idx, header in enumerate(headers, 1):
cell = ws.cell(2, col_idx, header)
cell.font = Font(bold=True)
cell.fill = PatternFill(start_color="d0ebff", end_color="d0ebff", fill_type="solid")
ws.column_dimensions[get_column_letter(col_idx)].width = [12, 10, 50][col_idx - 1]
for idx, (name, d) in enumerate(sorted(data.items(), key=lambda x: x[1]["net"], reverse=True), start=3):
ws.cell(idx, 1, name)
net_cell = ws.cell(idx, 2, d["net"])
net_cell.font = Font(color="006400" if d["net"] > 0 else "DC143C" if d["net"] < 0 else "000000")
ws.cell(idx, 3, d["summary"])
for row in ws.iter_rows(min_row=2, max_row=len(data) + 2, min_col=1, max_col=3):
for cell in row:
cell.alignment = Alignment(vertical="center", wrap_text=True)
wb.save(filename)
messagebox.showinfo("成功", f"Excel 已导出至:\n{filename}")
except Exception as e:
messagebox.showerror("导出失败", f"无法创建文件:\n{str(e)}")
def save_settings(self):
settings = {
"continuous_mode": self.continuous_mode,
"sort_mode": self.sort_mode
}
try:
with open("settings.json", "w", encoding="utf-8") as f:
json.dump(settings, f, ensure_ascii=False, indent=2)
except Exception as e:
print("保存设置失败:", e)
def load_settings(self):
try:
if os.path.exists("settings.json"):
with open("settings.json", "r", encoding="utf-8") as f:
data = json.load(f)
self.continuous_mode = data.get("continuous_mode", False)
self.continuous_var.set(self.continuous_mode)
self.sort_mode = data.get("sort_mode", "score")
except Exception as e:
print("加载设置失败:", e)
def on_sort_change(self, event=None):
"""当排序方式改变时触发"""
choice = self.sort_var.get()
if choice == "按积分排序":
self.sort_mode = "score"
elif choice == "按姓名排序":
self.sort_mode = "name"
self.save_settings()
self.update_display()
# 启动入口
if __name__ == "__main__":
root = tk.Tk()
app = ClassManager(root)
root.mainloop()
请修复输入密码时键盘失灵的BUG