Tkinter Treeview Color Bug

探讨使用Python的Tkinter库中的ttk.Treeview组件时遇到的颜色设置问题,特别是通过tag_configure方法设置背景色和前景色时遇到的bug。

百度一大堆Tkinter多简单多好用,尝试利用Treeview显示测试结果,期望在错误结果的条目上显示红色,正确的结果条目显示绿色,然而发现想要设置Treeview Item的背景色时,居然无法实现,整得都不想用Tkinter

搜索过程

结论

暂时没有找到解决方案,除非换Python版本什么的(Python 3.7.2好像可以)

吐槽

  • python 的官方文档,就是python安装之后的chm文档,对tkinter的描述真是弱,毫无亲和力
  • tkinter的使用可以看TkDocs,但是人家说了只是简单介绍
  • 想要查看类似API接口文档之类的说明得去tk官网,不过只有tcl语言的例子,还少得可怜
  • tkinter 默认有个bg,fg可以设置,但是没有treeview
  • ttk 有treeview,但去掉了bg,fg,通过tags设置颜色什么的,问题时还出bug了
  • 一个简单的表格颜色都整的这么麻烦
  • 可能我这对 tkinter 用的还不够简单…

附例

# https://bugs.python.org/issue36468
import tkinter
from tkinter import *
from tkinter.ttk import *

root = Tk ()
style = Style (root)
style.configure ("Treeview", foreground="yellow", background="grey", fieldbackground="green")

tree = Treeview (root, columns=('Data'))
tree.heading('#0', text='Item')
tree.heading('#1', text='Data')
tree.insert("", "end", text="Item_0", values=100, tags="A")
tree.insert("", "end", text="Item_1", values=200, tags="B")
tree.insert("", "end", text="Item_2", values=300, tags="C")

tree.tag_configure ("A", foreground="black")    # 无作用
tree.tag_configure ("B", foreground="red")      # 无作用

tree.pack ()

root.mainloop ()
#请作如下修改——1.加入访问访问本地知库时的请求文档数量显示和干预控件;2.进一步优化界面色彩;3.加入GPU使用情况监控;4.输出全部代码 import tkinter as tk from tkinter import ttk, filedialog, messagebox, scrolledtext import ollama import os import time import threading import numpy as np import matplotlib.pyplot as plt from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.figure import Figure import pandas as pd import seaborn as sns import PyPDF2 import docx import markdown from bs4 import BeautifulSoup import openpyxl from PIL import Image import pytesseract import io import psutil from ttkthemes import ThemedTk # 设置中文字体 plt.rcParams['font.sans-serif'] = ['SimHei'] plt.rcParams['axes.unicode_minus'] = False class RAGApplication: def __init__(self, root): self.root = root self.root.title("✨智能RAG应用系统✨") self.root.geometry("1400x900") self.root.configure(bg="#f0f0f0") # 淡灰色背景 # 使用现代主题 self.style = ttk.Style() self.style.theme_use('arc') # 现代主题 # 自定义样式 - 淡色调 self.style.configure('TFrame', background='#f0f0f0') self.style.configure('TLabel', background='#f0f0f0', foreground='#333333') self.style.configure('TLabelframe', background='#f0f0f0', foreground='#333333', borderwidth=1) self.style.configure('TLabelframe.Label', background='#f0f0f0', foreground='#4dabf5') # 淡蓝色标题 self.style.configure('TButton', background='#4dabf5', foreground='#333333', borderwidth=1) # 深色文字按钮 self.style.map('TButton', background=[('active', '#3b99e0')]) self.style.configure('TNotebook', background='#f0f0f0', borderwidth=0) self.style.configure('TNotebook.Tab', background='#e6f0ff', foreground='#333333', padding=[10, 5]) # 淡蓝色标签 self.style.map('TNotebook.Tab', background=[('selected', '#4dabf5')]) # 初始化数据 self.documents = [] self.chunks = [] self.embeddings = [] self.qa_history = [] # 获取 Ollama 中已安装的模型列表 try: models_response = ollama.list() self.all_models = [model['model'] for model in models_response['models']] # 使用 'model' 字段 except Exception as e: print(f"获取 Ollama 模型列表失败: {e}") self.all_models = [] self.default_llm_model = "qwen2:7b" self.default_embedding_model = "bge-m3:latest" # 默认参数 self.params = { "temperature": 0.7, "top_p": 0.9, "max_length": 2048, "num_context_docs": 3, "chunk_size": 500, "chunk_overlap": 100, "chunk_strategy": "固定大小", "separators": "\n\n\n。\n!\n?\n", "embed_batch_size": 1, "enable_stream": True, "show_progress": True, "show_visualization": True, "ocr_enabled": True } # 创建界面 self.create_ui() def create_ui(self): # 主框架 self.main_frame = ttk.Frame(self.root) self.main_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=20) # 标题 title_frame = ttk.Frame(self.main_frame) title_frame.pack(fill=tk.X, pady=(0, 20)) ttk.Label(title_frame, text="✨ 智能RAG应用系统 ✨", font=('Arial', 24, 'bold'), foreground="#4dabf5").pack(side=tk.LEFT) # 淡青色标题 # 状态指示器 status_frame = ttk.Frame(title_frame) status_frame.pack(side=tk.RIGHT) self.status_label = ttk.Label(status_frame, text="● 就绪", foreground="#28a745") # 绿色状态 self.status_label.pack(side=tk.RIGHT, padx=10) # 参数控制面板 self.create_sidebar() # 主内容区域 self.create_main_content() def create_sidebar(self): # 侧边栏框架 self.sidebar = ttk.LabelFrame(self.main_frame, text="⚙️ 参数控制面板", width=300) self.sidebar.pack(side=tk.LEFT, fill=tk.Y, padx=10, pady=10) # 大模型参数 ttk.Label(self.sidebar, text="🔧 大模型参数", font=('Arial', 10, 'bold'), foreground="#333333").pack(pady=(15, 5)) self.temperature = tk.DoubleVar(value=self.params["temperature"]) ttk.Label(self.sidebar, text="温度(temperature)").pack(anchor=tk.W, padx=10) temp_frame = ttk.Frame(self.sidebar) temp_frame.pack(fill=tk.X, padx=10, pady=(0, 5)) ttk.Scale(temp_frame, from_=0.0, to=2.0, variable=self.temperature, length=180, command=lambda v: self.update_param("temperature", float(v))).pack(side=tk.LEFT) self.temp_label = ttk.Label(temp_frame, text=f"{self.temperature.get():.1f}", width=5) self.temp_label.pack(side=tk.RIGHT, padx=5) self.top_p = tk.DoubleVar(value=self.params["top_p"]) ttk.Label(self.sidebar, text="Top P").pack(anchor=tk.W, padx=10) top_p_frame = ttk.Frame(self.sidebar) top_p_frame.pack(fill=tk.X, padx=10, pady=(0, 5)) ttk.Scale(top_p_frame, from_=0.0, to=1.0, variable=self.top_p, length=180, command=lambda v: self.update_param("top_p", float(v))).pack(side=tk.LEFT) self.top_p_label = ttk.Label(top_p_frame, text=f"{self.top_p.get():.2f}", width=5) self.top_p_label.pack(side=tk.RIGHT, padx=5) # 添加大模型名称选择(来自 Ollama) ttk.Label(self.sidebar, text="大模型名称").pack(anchor=tk.W, padx=10) self.llm_model_var = tk.StringVar(value=self.default_llm_model) llm_combobox = ttk.Combobox(self.sidebar, textvariable=self.llm_model_var, values=self.all_models) llm_combobox.pack(padx=10, pady=5) # 嵌入模型选择(来自 Ollama) ttk.Label(self.sidebar, text="嵌入模型名称").pack(anchor=tk.W, padx=10) self.embedding_model_var = tk.StringVar(value=self.default_embedding_model) embed_combobox = ttk.Combobox(self.sidebar, textvariable=self.embedding_model_var, values=self.all_models) embed_combobox.pack(padx=10, pady=5) # RAG参数 ttk.Label(self.sidebar, text="🔧 RAG参数", font=('Arial', 10, 'bold'), foreground="#333333").pack(pady=(15, 5)) self.chunk_size = tk.IntVar(value=self.params["chunk_size"]) ttk.Label(self.sidebar, text="分块大小(字符)").pack(anchor=tk.W, padx=10) chunk_frame = ttk.Frame(self.sidebar) chunk_frame.pack(fill=tk.X, padx=10, pady=(0, 5)) ttk.Scale(chunk_frame, from_=100, to=2000, variable=self.chunk_size, length=180, command=lambda v: self.update_param("chunk_size", int(v))).pack(side=tk.LEFT) self.chunk_label = ttk.Label(chunk_frame, text=f"{self.chunk_size.get()}", width=5) self.chunk_label.pack(side=tk.RIGHT, padx=5) # OCR开关 self.ocr_var = tk.BooleanVar(value=self.params["ocr_enabled"]) ttk.Checkbutton(self.sidebar, text="启用OCR扫描", variable=self.ocr_var, command=lambda: self.update_param("ocr_enabled", self.ocr_var.get())).pack(pady=(15, 5), padx=10, anchor=tk.W) # 使用说明 ttk.Label(self.sidebar, text="📖 使用说明", font=('Arial', 10, 'bold'), foreground="#333333").pack(pady=(15, 5)) instructions = """1. 在"文档上传"页上传您的文档 2. 在"文档处理"页对文档进行分块和嵌入 3. 在"问答交互"页提问并获取答案 4. 在"系统监控"页查看系统状态""" ttk.Label(self.sidebar, text=instructions, justify=tk.LEFT, background="#e6f0ff", # 淡蓝色背景 foreground="#333333", padding=10).pack(fill=tk.X, padx=10, pady=5) def create_main_content(self): # 主内容框架 self.content_frame = ttk.Frame(self.main_frame) self.content_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True) # 创建选项卡 self.notebook = ttk.Notebook(self.content_frame) self.notebook.pack(fill=tk.BOTH, expand=True) # 文档上传页 self.create_upload_tab() # 文档处理页 self.create_process_tab() # 问答交互页 self.create_qa_tab() # 系统监控页 self.create_monitor_tab() def create_upload_tab(self): self.upload_tab = ttk.Frame(self.notebook) self.notebook.add(self.upload_tab, text="📤 文档上传") # 标题 title_frame = ttk.Frame(self.upload_tab) title_frame.pack(fill=tk.X, pady=(10, 20)) ttk.Label(title_frame, text="📤 文档上传与管理", font=('Arial', 14, 'bold'), foreground="#4dabf5").pack(side=tk.LEFT) # 淡青色标题 # 上传区域 upload_frame = ttk.Frame(self.upload_tab) upload_frame.pack(fill=tk.X, pady=10) # 上传按钮 upload_btn = ttk.Button(upload_frame, text="📁 上传文档", command=self.upload_files, style='Accent.TButton') upload_btn.pack(side=tk.LEFT, padx=10) # 清除按钮 clear_btn = ttk.Button(upload_frame, text="🗑️ 清除所有", command=self.clear_documents) clear_btn.pack(side=tk.RIGHT, padx=10) # 文档列表 self.doc_list_frame = ttk.LabelFrame(self.upload_tab, text="📋 已上传文档") self.doc_list_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) # 创建带滚动条的树状视图 tree_frame = ttk.Frame(self.doc_list_frame) tree_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) # 创建滚动条 tree_scroll = ttk.Scrollbar(tree_frame) tree_scroll.pack(side=tk.RIGHT, fill=tk.Y) # 创建树状视图 columns = ("name", "size", "time", "type") self.doc_tree = ttk.Treeview(tree_frame, columns=columns, show="headings", yscrollcommand=tree_scroll.set, height=8) # 设置列标题 self.doc_tree.heading("name", text="文件名") self.doc_tree.heading("size", text="大小") self.doc_tree.heading("time", text="上传时间") self.doc_tree.heading("type", text="类型") # 设置列宽 self.doc_tree.column("name", width=250) self.doc_tree.column("size", width=80) self.doc_tree.column("time", width=150) self.doc_tree.column("type", width=80) self.doc_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) tree_scroll.config(command=self.doc_tree.yview) # 文档统计 self.doc_stats_frame = ttk.Frame(self.upload_tab) self.doc_stats_frame.pack(fill=tk.X, pady=10, padx=10) stats_style = ttk.Style() stats_style.configure('Stats.TLabel', background='#e6f0ff', foreground='#333333', padding=5) # 淡蓝色背景 ttk.Label(self.doc_stats_frame, text="📊 文档统计:", style='Stats.TLabel').pack(side=tk.LEFT, padx=5) self.doc_count_label = ttk.Label(self.doc_stats_frame, text="0", style='Stats.TLabel') self.doc_count_label.pack(side=tk.LEFT, padx=5) ttk.Label(self.doc_stats_frame, text="总字符数:", style='Stats.TLabel').pack(side=tk.LEFT, padx=5) self.char_count_label = ttk.Label(self.doc_stats_frame, text="0", style='Stats.TLabel') self.char_count_label.pack(side=tk.LEFT, padx=5) ttk.Label(self.doc_stats_frame, text="总页数:", style='Stats.TLabel').pack(side=tk.LEFT, padx=5) self.page_count_label = ttk.Label(self.doc_stats_frame, text="0", style='Stats.TLabel') self.page_count_label.pack(side=tk.LEFT, padx=5) def clear_documents(self): if not self.documents: return if messagebox.askyesno("确认", "确定要清除所有文档吗?"): self.documents = [] self.update_doc_list() # ================== 文件读取函数 ================== def read_pdf(self, filepath): """读取PDF文件内容,支持扫描版OCR""" content = "" pages = 0 try: with open(filepath, 'rb') as f: reader = PyPDF2.PdfReader(f) num_pages = len(reader.pages) pages = num_pages for page_num in range(num_pages): page = reader.pages[page_num] text = page.extract_text() # 如果是扫描版PDF,使用OCR识别 if not text.strip() and self.params["ocr_enabled"]: try: # 获取页面图像 images = page.images if images: for img in images: image_data = img.data image = Image.open(io.BytesIO(image_data)) text += pytesseract.image_to_string(image, lang='chi_sim+eng') except Exception as e: print(f"OCR处理失败: {str(e)}") content += text + "\n" except Exception as e: print(f"读取PDF失败: {str(e)}") return content, pages def read_docx(self, filepath): """读取Word文档内容""" content = "" pages = 0 try: doc = docx.Document(filepath) for para in doc.paragraphs: content += para.text + "\n" pages = len(doc.paragraphs) // 50 + 1 # 估算页数 except Exception as e: print(f"读取Word文档失败: {str(e)}") return content, pages def read_excel(self, filepath): """读取Excel文件内容,优化内存使用""" content = "" pages = 0 try: # 使用openpyxl优化大文件读取 wb = openpyxl.load_workbook(filepath, read_only=True) for sheet_name in wb.sheetnames: content += f"\n工作表: {sheet_name}\n" sheet = wb[sheet_name] for row in sheet.iter_rows(values_only=True): row_content = " | ".join([str(cell) if cell is not None else "" for cell in row]) content += row_content + "\n" pages = len(wb.sheetnames) except Exception as e: print(f"读取Excel文件失败: {str(e)}") return content, pages def read_md(self, filepath): """读取Markdown文件内容""" content = "" pages = 0 try: with open(filepath, 'r', encoding='utf-8') as f: html = markdown.markdown(f.read()) soup = BeautifulSoup(html, 'html.parser') content = soup.get_text() pages = len(content) // 2000 + 1 # 估算页数 except Exception as e: print(f"读取Markdown文件失败: {str(e)}") return content, pages def read_ppt(self, filepath): """读取PPT文件内容(简化版)""" content = "" pages = 0 try: # 实际应用中应使用python-pptx库 # 这里仅作演示 content = f"PPT文件内容提取: {os.path.basename(filepath)}" pages = 10 # 假设有10页 except Exception as e: print(f"读取PPT文件失败: {str(e)}") return content, pages def upload_files(self): filetypes = [ ("文本文件", "*.txt"), ("PDF文件", "*.pdf"), ("Word文件", "*.docx *.doc"), ("Excel文件", "*.xlsx *.xls"), ("Markdown文件", "*.md"), ("PPT文件", "*.pptx *.ppt"), ("所有文件", "*.*") ] filenames = filedialog.askopenfilenames(title="选择文档", filetypes=filetypes) if filenames: self.status_label.config(text="● 正在上传文档...", foreground="#ffc107") # 黄色状态 total_pages = 0 for filename in filenames: try: ext = os.path.splitext(filename)[1].lower() if ext == '.txt': with open(filename, 'r', encoding='utf-8') as f: content = f.read() pages = len(content) // 2000 + 1 elif ext == '.pdf': content, pages = self.read_pdf(filename) elif ext in ('.docx', '.doc'): content, pages = self.read_docx(filename) elif ext in ('.xlsx', '.xls'): content, pages = self.read_excel(filename) elif ext == '.md': content, pages = self.read_md(filename) elif ext in ('.pptx', '.ppt'): content, pages = self.read_ppt(filename) else: messagebox.showwarning("警告", f"不支持的文件类型: {ext}") continue # 处理字符编码问题 if not isinstance(content, str): try: content = content.decode('utf-8') except: content = content.decode('latin-1', errors='ignore') self.documents.append({ "name": os.path.basename(filename), "content": content, "size": len(content), "upload_time": time.strftime("%Y-%m-%d %H:%M:%S"), "type": ext.upper().replace(".", ""), "pages": pages }) total_pages += pages # 更新文档列表 self.update_doc_list() except Exception as e: messagebox.showerror("错误", f"无法读取文件 {filename}: {str(e)}") self.status_label.config(text=f"● 上传完成! 共{len(filenames)}个文档", foreground="#28a745") # 绿色状态 self.page_count_label.config(text=str(total_pages)) def update_doc_list(self): # 清空现有列表 for item in self.doc_tree.get_children(): self.doc_tree.delete(item) # 添加新文档 for doc in self.documents: size_kb = doc["size"] / 1024 size_str = f"{size_kb:.1f} KB" if size_kb < 1024 else f"{size_kb / 1024:.1f} MB" self.doc_tree.insert("", tk.END, values=( doc["name"], size_str, doc["upload_time"], doc["type"] )) # 更新统计信息 self.doc_count_label.config(text=str(len(self.documents))) self.char_count_label.config(text=str(sum(d['size'] for d in self.documents))) def create_process_tab(self): self.process_tab = ttk.Frame(self.notebook) self.notebook.add(self.process_tab, text="🔧 文档处理") # 标题 title_frame = ttk.Frame(self.process_tab) title_frame.pack(fill=tk.X, pady=(10, 20)) ttk.Label(title_frame, text="🔧 文档处理与分块", font=('Arial', 14, 'bold'), foreground="#4dabf5").pack(side=tk.LEFT) # 淡青色标题 # 处理按钮 btn_frame = ttk.Frame(self.process_tab) btn_frame.pack(fill=tk.X, pady=10) process_btn = ttk.Button(btn_frame, text="🔄 处理文档", command=self.process_documents, style='Accent.TButton') process_btn.pack(side=tk.LEFT, padx=10) visualize_btn = ttk.Button(btn_frame, text="📊 更新可视化", command=self.show_visualizations) visualize_btn.pack(side=tk.LEFT, padx=10) # 主内容区域 content_frame = ttk.Frame(self.process_tab) content_frame.pack(fill=tk.BOTH, expand=True) # 左侧:可视化区域 self.visual_frame = ttk.LabelFrame(content_frame, text="📈 文档分析") self.visual_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=10, pady=10) # 右侧:分块列表 self.chunk_frame = ttk.LabelFrame(content_frame, text="📋 分块结果") self.chunk_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=10, pady=10) # 创建带滚动条的树状视图 tree_frame = ttk.Frame(self.chunk_frame) tree_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) # 创建滚动条 tree_scroll = ttk.Scrollbar(tree_frame) tree_scroll.pack(side=tk.RIGHT, fill=tk.Y) # 创建树状视图 columns = ("doc_name", "start", "end", "content") self.chunk_tree = ttk.Treeview( tree_frame, columns=columns, show="headings", yscrollcommand=tree_scroll.set, height=15 ) # 设置列标题 self.chunk_tree.heading("doc_name", text="来源文档") self.chunk_tree.heading("start", text="起始位置") self.chunk_tree.heading("end", text="结束位置") self.chunk_tree.heading("content", text="内容预览") # 设置列宽 self.chunk_tree.column("doc_name", width=150) self.chunk_tree.column("start", width=80) self.chunk_tree.column("end", width=80) self.chunk_tree.column("content", width=300) self.chunk_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) tree_scroll.config(command=self.chunk_tree.yview) # 初始显示占位图 self.show_placeholder() def show_placeholder(self): """显示可视化占位图""" for widget in self.visual_frame.winfo_children(): widget.destroy() placeholder = ttk.Label(self.visual_frame, text="文档处理后将显示分析图表", font=('Arial', 12), foreground="#7f8c8d") placeholder.pack(expand=True, pady=50) def process_documents(self): if not self.documents: messagebox.showwarning("警告", "请先上传文档") return # 在新线程中处理文档 threading.Thread(target=self._process_documents_thread, daemon=True).start() def _process_documents_thread(self): # 显示进度条 self.progress_window = tk.Toplevel(self.root) self.progress_window.title("处理进度") self.progress_window.geometry("400x150") self.progress_window.resizable(False, False) self.progress_window.transient(self.root) self.progress_window.grab_set() self.progress_window.configure(bg="#f0f0f0") # 淡灰色背景 # 设置窗口居中 x = self.root.winfo_x() + (self.root.winfo_width() - 400) // 2 y = self.root.winfo_y() + (self.root.winfo_height() - 150) // 2 self.progress_window.geometry(f"+{x}+{y}") # 进度窗口内容 ttk.Label(self.progress_window, text="正在处理文档...", font=('Arial', 11)).pack(pady=(20, 10)) progress_frame = ttk.Frame(self.progress_window) progress_frame.pack(fill=tk.X, padx=20, pady=10) self.progress_var = tk.DoubleVar() progress_bar = ttk.Progressbar(progress_frame, variable=self.progress_var, maximum=100, length=360) progress_bar.pack() self.progress_label = ttk.Label(progress_frame, text="0%") self.progress_label.pack(pady=5) self.status_label.config(text="● 正在处理文档...", foreground="#ffc107") # 黄色状态 self.progress_window.update() try: # 分块处理 self.chunks = self.chunk_documents( self.documents, self.params["chunk_strategy"], self.params["chunk_size"], self.params["chunk_overlap"] ) # 生成嵌入 self.embeddings = self.generate_embeddings(self.chunks) # 更新UI self.root.after(0, self.update_chunk_list) self.root.after(0, self.show_visualizations) self.root.after(0, lambda: messagebox.showinfo("完成", "文档处理完成!")) self.status_label.config(text="● 文档处理完成", foreground="#28a745") # 绿色状态 except Exception as e: self.root.after(0, lambda: messagebox.showerror("错误", f"处理文档时出错: {str(e)}")) self.status_label.config(text="● 处理出错", foreground="#dc3545") # 红色状态 finally: self.root.after(0, self.progress_window.destroy) def chunk_documents(self, documents, strategy, size, overlap): chunks = [] total_docs = len(documents) for doc_idx, doc in enumerate(documents): content = doc['content'] if strategy == "固定大小": for i in range(0, len(content), size - overlap): chunk = content[i:i + size] chunks.append({ "doc_name": doc['name'], "content": chunk, "start": i, "end": min(i + size, len(content)) }) # 更新进度 progress = (doc_idx + 1) / total_docs * 100 self.progress_var.set(progress) self.progress_label.config(text=f"{int(progress)}%") self.progress_window.update() return chunks def generate_embeddings(self, chunks): """单批次处理每个分块,避免API参数类型错误""" embeddings = [] total_chunks = len(chunks) for idx, chunk in enumerate(chunks): try: # 传递单个字符串而不是列表 response = ollama.embeddings( model=self.embedding_model_var.get(), prompt=chunk['content'] # 单个字符串 ) embeddings.append({ "chunk_id": idx, "embedding": response['embedding'], "doc_name": chunk['doc_name'] }) except Exception as e: print(f"生成嵌入时出错: {str(e)}") # 添加空嵌入占位符 embeddings.append({ "chunk_id": idx, "embedding": None, "doc_name": chunk['doc_name'] }) # 更新进度 progress = (idx + 1) / total_chunks * 100 self.progress_var.set(progress) self.progress_label.config(text=f"{int(progress)}%") self.progress_window.update() # 添加延迟避免请求过快 time.sleep(0.1) return embeddings def update_chunk_list(self): # 清空现有列表 for item in self.chunk_tree.get_children(): self.chunk_tree.delete(item) # 添加新分块 for chunk in self.chunks: preview = chunk['content'][:50] + "..." if len(chunk['content']) > 50 else chunk['content'] self.chunk_tree.insert("", tk.END, values=( chunk['doc_name'], chunk['start'], chunk['end'], preview )) def show_visualizations(self): # 清空可视化区域 for widget in self.visual_frame.winfo_children(): widget.destroy() if not self.params["show_visualization"] or not self.chunks: self.show_placeholder() return # 创建图表框架 fig = plt.Figure(figsize=(10, 8), dpi=100) fig.set_facecolor('#f0f0f0') # 淡灰色背景 # 分块大小分布 ax1 = fig.add_subplot(221) ax1.set_facecolor('#e6f0ff') # 淡蓝色背景 chunk_sizes = [len(c['content']) for c in self.chunks] sns.histplot(chunk_sizes, bins=20, ax=ax1, color='#4dabf5') # 淡青色 ax1.set_title("分块大小分布", color='#333333') ax1.set_xlabel("字符数", color='#333333') ax1.set_ylabel("数量", color='#333333') ax1.tick_params(axis='x', colors='#333333') ax1.tick_params(axis='y', colors='#333333') ax1.spines['bottom'].set_color('#333333') ax1.spines['left'].set_color('#333333') # 文档分块数量 ax2 = fig.add_subplot(222) ax2.set_facecolor('#e6f0ff') # 淡蓝色背景 doc_chunk_counts = {} for chunk in self.chunks: doc_chunk_counts[chunk['doc_name']] = doc_chunk_counts.get(chunk['doc_name'], 0) + 1 # 只显示前10个文档 doc_names = list(doc_chunk_counts.keys()) counts = list(doc_chunk_counts.values()) if len(doc_names) > 10: # 按分块数量排序,取前10 sorted_indices = np.argsort(counts)[::-1][:10] doc_names = [doc_names[i] for i in sorted_indices] counts = [counts[i] for i in sorted_indices] sns.barplot(x=counts, y=doc_names, hue=doc_names, ax=ax2, palette='Blues', orient='h', legend=False) ax2.set_title("各文档分块数量", color='#333333') ax2.set_xlabel("分块数", color='#333333') ax2.set_ylabel("") ax2.tick_params(axis='x', colors='#333333') ax2.tick_params(axis='y', colors='#333333') ax2.spines['bottom'].set_color('#333333') ax2.spines['left'].set_color('#333333') # 内容词云(模拟) ax3 = fig.add_subplot(223) ax3.set_facecolor('#e6f0ff') # 淡蓝色背景 ax3.set_title("内容关键词分布", color='#333333') ax3.text(0.5, 0.5, "关键词可视化区域", horizontalalignment='center', verticalalignment='center', color='#333333', fontsize=12) ax3.axis('off') # 处理进度 ax4 = fig.add_subplot(224) ax4.set_facecolor('#e6f0ff') # 淡蓝色背景 ax4.set_title("处理进度", color='#333333') # 模拟数据 stages = ['上传', '分块', '嵌入', '完成'] progress = [100, 100, 100, 100] # 假设都已完成 ax4.barh(stages, progress, color=['#4dabf5', '#20c997', '#9b59b6', '#ffc107']) # 淡色系 ax4.set_xlim(0, 100) ax4.set_xlabel("完成百分比", color='#333333') ax4.tick_params(axis='x', colors='#333333') ax4.tick_params(axis='y', colors='#333333') ax4.spines['bottom'].set_color('#333333') ax4.spines['left'].set_color('#333333') # 调整布局 fig.tight_layout(rect=[0, 0, 1, 0.95], pad=3.0) # 添加总标题 fig.suptitle("文档分析概览", fontsize=16, color='#333333') # 在Tkinter中显示图表 canvas = FigureCanvasTkAgg(fig, master=self.visual_frame) canvas.draw() canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True) def create_qa_tab(self): self.qa_tab = ttk.Frame(self.notebook) self.notebook.add(self.qa_tab, text="💬 问答交互") # 标题 title_frame = ttk.Frame(self.qa_tab) title_frame.pack(fill=tk.X, pady=(10, 20)) ttk.Label(title_frame, text="💬 问答交互", font=('Arial', 14, 'bold'), foreground="#4dabf5").pack(side=tk.LEFT) # 淡青色标题 # 主内容区域 main_frame = ttk.Frame(self.qa_tab) main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) # 左侧:问答区域 left_frame = ttk.Frame(main_frame) left_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5) # 问题输入 self.question_frame = ttk.LabelFrame(left_frame, text="❓ 输入问题") self.question_frame.pack(fill=tk.X, padx=5, pady=5) self.question_text = scrolledtext.ScrolledText(self.question_frame, height=8, wrap=tk.WORD, font=('Arial', 11)) self.question_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) self.question_text.focus_set() # 提交按钮 btn_frame = ttk.Frame(left_frame) btn_frame.pack(fill=tk.X, pady=10) submit_btn = ttk.Button(btn_frame, text="🚀 提交问题", command=self.submit_question, style='Accent.TButton') submit_btn.pack(side=tk.LEFT, padx=5) clear_btn = ttk.Button(btn_frame, text="🗑️ 清除问题", command=self.clear_question) clear_btn.pack(side=tk.LEFT, padx=5) # 回答显示 self.answer_frame = ttk.LabelFrame(left_frame, text="💡 回答") self.answer_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) self.answer_text = scrolledtext.ScrolledText(self.answer_frame, state=tk.DISABLED, wrap=tk.WORD, font=('Arial', 11)) self.answer_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) # 右侧:问答历史 right_frame = ttk.Frame(main_frame, width=400) # 设置宽度 right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=False, padx=5, pady=5) # expand=False self.history_frame = ttk.LabelFrame(right_frame, text="🕒 问答历史") self.history_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) # 创建带滚动条的树状视图 tree_frame = ttk.Frame(self.history_frame) tree_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) # 创建滚动条 tree_scroll = ttk.Scrollbar(tree_frame) tree_scroll.pack(side=tk.RIGHT, fill=tk.Y) # 创建树状视图 columns = ("question", "time") self.history_tree = ttk.Treeview( tree_frame, columns=columns, show="headings", yscrollcommand=tree_scroll.set, height=20 ) # 设置列标题 self.history_tree.heading("question", text="问题") self.history_tree.heading("time", text="时间") # 设置列宽 self.history_tree.column("question", width=250) self.history_tree.column("time", width=120) self.history_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) tree_scroll.config(command=self.history_tree.yview) # 历史操作按钮 history_btn_frame = ttk.Frame(right_frame) history_btn_frame.pack(fill=tk.X, pady=10) view_btn = ttk.Button(history_btn_frame, text="👁️ 查看详情", command=lambda: self.show_history_detail(None)) view_btn.pack(side=tk.LEFT, padx=5, fill=tk.X, expand=True) clear_history_btn = ttk.Button(history_btn_frame, text="🗑️ 清除历史", command=self.clear_history) clear_history_btn.pack(side=tk.LEFT, padx=5, fill=tk.X, expand=True) # 绑定双击事件查看历史详情 self.history_tree.bind("<Double-1>", self.show_history_detail) def clear_question(self): self.question_text.delete("1.0", tk.END) def clear_history(self): if not self.qa_history: return if messagebox.askyesno("确认", "确定要清除所有问答历史吗?"): self.qa_history = [] self.update_history_list() def submit_question(self): question = self.question_text.get("1.0", tk.END).strip() if not question: messagebox.showwarning("警告", "问题不能为空") return # 在新线程中处理问题 threading.Thread(target=self._submit_question_thread, args=(question,), daemon=True).start() def _submit_question_thread(self, question): try: # 显示进度窗口 self.progress_window = tk.Toplevel(self.root) self.progress_window.title("处理中...") self.progress_window.geometry("400x150") self.progress_window.resizable(False, False) self.progress_window.transient(self.root) self.progress_window.grab_set() self.progress_window.configure(bg="#f0f0f0") # 淡灰色背景 # 设置窗口居中 x = self.root.winfo_x() + (self.root.winfo_width() - 400) // 2 y = self.root.winfo_y() + (self.root.winfo_height() - 150) // 2 self.progress_window.geometry(f"+{x}+{y}") # 进度窗口内容 ttk.Label(self.progress_window, text="正在思考中...", font=('Arial', 11)).pack(pady=(20, 10)) progress_frame = ttk.Frame(self.progress_window) progress_frame.pack(fill=tk.X, padx=20, pady=10) self.progress_var = tk.DoubleVar() progress_bar = ttk.Progressbar(progress_frame, variable=self.progress_var, maximum=100, length=360) progress_bar.pack() self.progress_label = ttk.Label(progress_frame, text="0%") self.progress_label.pack(pady=5) self.status_label.config(text="● 正在处理问题...", foreground="#ffc107") # 黄色状态 self.progress_window.update() # 检索相关文档块 relevant_chunks = self.retrieve_relevant_chunks(question, self.params["num_context_docs"]) # 构建上下文 context = "\n\n".join([ f"文档: {c['doc_name']}\n内容: {c['content']}\n相关性: {c['similarity']:.4f}" for c in relevant_chunks ]) # 调用大模型生成回答 prompt = f"""基于以下上下文,回答问题。如果答案不在上下文中,请回答"我不知道"。 上下文: {context} 问题: {question} 回答:""" # 更新进度 self.progress_var.set(50) self.progress_label.config(text="50%") self.progress_window.update() # 流式输出或一次性输出 self.root.after(0, self.answer_text.config, {'state': tk.NORMAL}) self.root.after(0, self.answer_text.delete, "1.0", tk.END) if self.params["enable_stream"]: full_response = "" for chunk in ollama.generate( model=self.llm_model_var.get(), # 使用用户选择的模型 prompt=prompt, stream=True, options={ 'temperature': self.params["temperature"], 'top_p': self.params["top_p"], 'num_ctx': self.params["max_length"] } ): full_response += chunk['response'] self.root.after(0, self.answer_text.insert, tk.END, chunk['response']) self.root.after(0, self.answer_text.see, tk.END) self.root.after(0, self.answer_text.update) # 更新进度 if len(full_response) > 0: progress = min(50 + len(full_response) / 200, 99) self.progress_var.set(progress) self.progress_label.config(text=f"{int(progress)}%") self.progress_window.update() else: response = ollama.generate( model=self.llm_model_var.get(), # 使用用户选择的模型 prompt=prompt, options={ 'temperature': self.params["temperature"], 'top_p': self.params["top_p"], 'num_ctx': self.params["max_length"] } ) full_response = response['response'] self.root.after(0, self.answer_text.insert, tk.END, full_response) # 记录问答历史 self.qa_history.append({ "question": question, "answer": full_response, "context": context, "time": time.strftime("%Y-%m-%d %H:%M:%S") }) # 更新历史列表 self.root.after(0, self.update_history_list) # 完成 self.progress_var.set(100) self.progress_label.config(text="100%") self.status_label.config(text="● 问题处理完成", foreground="#28a745") # 绿色状态 self.root.after(1000, self.progress_window.destroy) except Exception as e: self.root.after(0, lambda: messagebox.showerror("错误", f"处理问题时出错: {str(e)}")) self.root.after(0, self.progress_window.destroy) self.status_label.config(text="● 处理出错", foreground="#dc3545") # 红色状态 def retrieve_relevant_chunks(self, query, k): """处理嵌入为None的情况""" # 生成查询的嵌入 query_embedding = ollama.embeddings( model=self.embedding_model_var.get(), # 使用用户选择的模型 prompt=query )['embedding'] # 注意:返回的是字典中的'embedding'字段 # 计算相似度 similarities = [] for emb in self.embeddings: # 跳过无效的嵌入 if emb['embedding'] is None: continue # 计算余弦相似度 similarity = np.dot(query_embedding, emb['embedding']) similarities.append({ 'chunk_id': emb['chunk_id'], 'similarity': similarity, 'doc_name': emb['doc_name'] }) # 按相似度排序并返回前k个 top_chunks = sorted(similarities, key=lambda x: x['similarity'], reverse=True)[:k] return [{ **self.chunks[c['chunk_id']], 'similarity': c['similarity'] } for c in top_chunks] def update_history_list(self): # 清空现有列表 for item in self.history_tree.get_children(): self.history_tree.delete(item) # 添加新历史记录 for i, qa in enumerate(reversed(self.qa_history)): # 截断长问题 question = qa["question"] if len(question) > 50: question = question[:47] + "..." self.history_tree.insert("", tk.END, values=(question, qa["time"])) def show_history_detail(self, event): selected_item = self.history_tree.selection() if not selected_item: return item = self.history_tree.item(selected_item) question = item['values'][0] # 查找对应的问答记录 for qa in reversed(self.qa_history): if qa["question"].startswith(question) or question.startswith(qa["question"][:50]): # 显示详情窗口 detail_window = tk.Toplevel(self.root) detail_window.title("问答详情") detail_window.geometry("900x700") detail_window.configure(bg='#f0f0f0') # 淡灰色背景 # 设置窗口居中 x = self.root.winfo_x() + (self.root.winfo_width() - 900) // 2 y = self.root.winfo_y() + (self.root.winfo_height() - 700) // 2 detail_window.geometry(f"+{x}+{y}") # 问题 ttk.Label(detail_window, text="问题:", font=('Arial', 12, 'bold'), foreground="#4dabf5").pack(pady=(15, 5), padx=20, anchor=tk.W) question_frame = ttk.Frame(detail_window) question_frame.pack(fill=tk.X, padx=20, pady=(0, 10)) question_text = scrolledtext.ScrolledText(question_frame, wrap=tk.WORD, height=3, font=('Arial', 11)) question_text.insert(tk.INSERT, qa["question"]) question_text.config(state=tk.DISABLED) question_text.pack(fill=tk.X) # 回答 ttk.Label(detail_window, text="回答:", font=('Arial', 12, 'bold'), foreground="#4dabf5").pack(pady=(15, 5), padx=20, anchor=tk.W) answer_frame = ttk.Frame(detail_window) answer_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=(0, 10)) answer_text = scrolledtext.ScrolledText(answer_frame, wrap=tk.WORD, font=('Arial', 11)) answer_text.insert(tk.INSERT, qa["answer"]) answer_text.config(state=tk.DISABLED) answer_text.pack(fill=tk.BOTH, expand=True) # 上下文 ttk.Label(detail_window, text="上下文:", font=('Arial', 12, 'bold'), foreground="#4dabf5").pack(pady=(15, 5), padx=20, anchor=tk.W) context_frame = ttk.Frame(detail_window) context_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=(0, 20)) context_text = scrolledtext.ScrolledText(context_frame, wrap=tk.WORD, font=('Arial', 10)) context_text.insert(tk.INSERT, qa["context"]) context_text.config(state=tk.DISABLED) context_text.pack(fill=tk.BOTH, expand=True) break def create_monitor_tab(self): self.monitor_tab = ttk.Frame(self.notebook) self.notebook.add(self.monitor_tab, text="📊 系统监控") # 标题 title_frame = ttk.Frame(self.monitor_tab) title_frame.pack(fill=tk.X, pady=(10, 20)) ttk.Label(title_frame, text="📊 系统监控", font=('Arial', 14, 'bold'), foreground="#4dabf5").pack(side=tk.LEFT) # 主内容区域 main_frame = ttk.Frame(self.monitor_tab) main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) # 左侧:资源监控 left_frame = ttk.Frame(main_frame) left_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5) # 资源使用 self.resource_frame = ttk.LabelFrame(left_frame, text="📈 资源使用") self.resource_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) # CPU使用 cpu_frame = ttk.Frame(self.resource_frame) cpu_frame.pack(fill=tk.X, padx=10, pady=10) ttk.Label(cpu_frame, text="CPU使用率:").pack(side=tk.LEFT) self.cpu_value = ttk.Label(cpu_frame, text="0%", width=5) self.cpu_value.pack(side=tk.RIGHT, padx=10) self.cpu_usage = ttk.Progressbar(self.resource_frame, length=400, mode='determinate') self.cpu_usage.pack(fill=tk.X, padx=10, pady=(0, 10)) # 内存使用 mem_frame = ttk.Frame(self.resource_frame) mem_frame.pack(fill=tk.X, padx=10, pady=10) ttk.Label(mem_frame, text="内存使用率:").pack(side=tk.LEFT) self.mem_value = ttk.Label(mem_frame, text="0%", width=5) self.mem_value.pack(side=tk.RIGHT, padx=10) self.mem_usage = ttk.Progressbar(self.resource_frame, length=400, mode='determinate') self.mem_usage.pack(fill=tk.X, padx=10, pady=(0, 10)) # 磁盘使用 disk_frame = ttk.Frame(self.resource_frame) disk_frame.pack(fill=tk.X, padx=10, pady=10) ttk.Label(disk_frame, text="磁盘使用率:").pack(side=tk.LEFT) self.disk_value = ttk.Label(disk_frame, text="0%", width=5) self.disk_value.pack(side=tk.RIGHT, padx=10) self.disk_usage = ttk.Progressbar(self.resource_frame, length=400, mode='determinate') self.disk_usage.pack(fill=tk.X, padx=10, pady=(0, 10)) # 右侧:模型状态 right_frame = ttk.Frame(main_frame) right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=5, pady=5) # 模型状态 self.model_frame = ttk.LabelFrame(right_frame, text="🤖 模型状态") self.model_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) btn_frame = ttk.Frame(self.model_frame) btn_frame.pack(fill=tk.X, padx=10, pady=10) ttk.Button(btn_frame, text="🔄 检查模型状态", command=self.check_model_status).pack() self.model_status_text = scrolledtext.ScrolledText(self.model_frame, height=15, state=tk.DISABLED, font=('Consolas', 10)) self.model_status_text.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10)) # 性能统计 self.perf_frame = ttk.LabelFrame(left_frame, text="⚡ 性能统计") self.perf_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) # 创建图表 fig = Figure(figsize=(8, 4), dpi=100) fig.set_facecolor('#f0f0f0') # 淡灰色背景 self.ax = fig.add_subplot(111) self.ax.set_facecolor('#e6f0ff') # 淡蓝色背景 self.ax.set_title("CPU使用率历史", color='#333333') self.ax.set_xlabel("时间", color='#333333') self.ax.set_ylabel("使用率(%)", color='#333333') self.ax.tick_params(axis='x', colors='#333333') self.ax.tick_params(axis='y', colors='#333333') self.ax.spines['bottom'].set_color('#333333') self.ax.spines['left'].set_color('#333333') self.cpu_history = [] self.line, = self.ax.plot([], [], color='#4dabf5', marker='o', markersize=4) # 淡青色线条 self.ax.set_ylim(0, 100) canvas = FigureCanvasTkAgg(fig, master=self.perf_frame) canvas.draw() canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True, padx=10, pady=10) # 开始更新资源使用情况 self.update_resource_usage() def update_resource_usage(self): # 获取真实资源数据 cpu_percent = psutil.cpu_percent() mem_percent = psutil.virtual_memory().percent disk_percent = psutil.disk_usage('/').percent # 更新进度条 self.cpu_usage['value'] = cpu_percent self.mem_usage['value'] = mem_percent self.disk_usage['value'] = disk_percent # 更新数值标签 self.cpu_value.config(text=f"{cpu_percent}%") self.mem_value.config(text=f"{mem_percent}%") self.disk_value.config(text=f"{disk_percent}%") # 更新CPU历史图表 self.cpu_history.append(cpu_percent) if len(self.cpu_history) > 20: self.cpu_history.pop(0) self.line.set_data(range(len(self.cpu_history)), self.cpu_history) self.ax.set_xlim(0, max(10, len(self.cpu_history))) self.ax.figure.canvas.draw() # 5秒后再次更新 self.root.after(5000, self.update_resource_usage) def check_model_status(self): try: self.model_status_text.config(state=tk.NORMAL) self.model_status_text.delete("1.0", tk.END) # 添加加载动画 self.model_status_text.insert(tk.INSERT, "正在检查模型状态...") self.model_status_text.update() # 模拟检查过程 time.sleep(1) # 清空并插入真实信息 self.model_status_text.delete("1.0", tk.END) llm_info = ollama.show(self.llm_model_var.get()) # 使用用户选择的模型 embed_info = ollama.show(self.embedding_model_var.get()) # 使用用户选择的模型 status_text = f"""✅ 大模型信息: 名称: {self.llm_model_var.get()} 参数大小: {llm_info.get('size', '未知')} 最后使用时间: {llm_info.get('modified_at', '未知')} 支持功能: {llm_info.get('capabilities', '未知')} ✅ 嵌入模型信息: 名称: {self.embedding_model_var.get()} 参数大小: {embed_info.get('size', '未知')} 最后使用时间: {embed_info.get('modified_at', '未知')} 支持功能: {embed_info.get('capabilities', '未知')} ⏱️ 最后检查时间: {time.strftime("%Y-%m-%d %H:%M:%S")} """ self.model_status_text.insert(tk.INSERT, status_text) self.model_status_text.config(state=tk.DISABLED) self.status_label.config(text="● 模型状态检查完成", foreground="#28a745") # 绿色状态 except Exception as e: self.model_status_text.config(state=tk.NORMAL) self.model_status_text.delete("1.0", tk.END) self.model_status_text.insert(tk.INSERT, f"❌ 检查模型状态时出错: {str(e)}") self.model_status_text.config(state=tk.DISABLED) self.status_label.config(text="● 模型检查出错", foreground="#dc3545") # 红色状态 def update_param(self, param, value): self.params[param] = value # 更新标签显示 if param == "temperature": self.temp_label.config(text=f"{value:.1f}") elif param == "top_p": self.top_p_label.config(text=f"{value:.2f}") elif param == "chunk_size": self.chunk_label.config(text=f"{value}") # 运行应用程序 if __name__ == "__main__": root = ThemedTk(theme="arc") # 使用现代主题 app = RAGApplication(root) root.mainloop()
08-07
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并直接给出全部修复后代码
最新发布
09-23
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值