import json
import os
import tkinter as tk
from tkinter import messagebox, Toplevel, Listbox, Entry, Label, Button, END
import sys
# ==================== 路径处理函数(关键改进)====================
def resource_path(relative_path):
""" 获取打包资源路径(用于只读资源,如 output.jsonl) """
try:
base_path = sys._MEIPASS
except Exception:
base_path = os.path.abspath(".")
return os.path.join(base_path, relative_path)
def data_path(relative_path):
""" 获取用户数据路径(用于可写文件:错题本、别名等)"""
base_dir = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.abspath(".")
return os.path.join(base_dir, relative_path)
# ✅ 使用 resource_path 加载原始题库(打包进 exe)
OUTPUT_FILE = resource_path('output.jsonl')
# ✅ 使用 data_path 存储用户生成的文件(不会被打包,运行时创建)
ALIAS_FILE = data_path('mistakebook_aliases.json')
# 数字转中文
def number_to_chinese(n):
mapping = ["零", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十",
"十一", "十二", "十三", "十四", "十五", "十六", "十七", "十八", "十九", "二十"]
return mapping[n] if n <= 20 else str(n)
class FastQuizApp:
def __init__(self, root):
self.root = root
self.root.title("📚 Python 刷题系统")
self.root.geometry("800x600")
self.root.resizable(False, False)
self.BASE_MISTAKE_FILE = "mistakes_{}.jsonl"
self.MAX_WIDTH = 60
self.load_aliases() # 加载别名
self.show_main_menu()
def load_aliases(self):
"""加载错题本别名(从 data_path 加载)"""
if os.path.exists(ALIAS_FILE):
try:
with open(ALIAS_FILE, 'r', encoding='utf-8') as f:
self.aliases = json.load(f)
except Exception as e:
print(f"⚠️ 加载别名失败:{e}")
self.aliases = {}
else:
self.aliases = {}
def save_aliases(self):
"""保存别名到 data_path"""
try:
with open(ALIAS_FILE, 'w', encoding='utf-8') as f:
json.dump(self.aliases, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"⚠️ 无法保存别名:{e}")
def get_display_name(self, filename):
"""获取错题本显示名(优先使用别名)"""
if filename in self.aliases:
return self.aliases[filename]
try:
num = int(filename.split('_')[1].split('.')[0])
return f"错题本{number_to_chinese(num)}"
except:
return filename
def show_main_menu(self):
self.clear_window()
title = tk.Label(
self.root,
text="风山刷题",
font=("Arial", 18, "bold"),
pady=50
)
title.pack()
btn_style = {
"font": ("Arial", 14),
"width": 20,
"height": 2,
"bg": "#4CAF50",
"fg": "white",
"relief": "flat",
"bd": 0
}
tk.Button(
self.root,
text="🔢 顺序答题",
command=lambda: self.start_quiz(shuffle=False),
**btn_style
).pack(pady=15)
tk.Button(
self.root,
text="🔀 乱序答题",
command=lambda: self.start_quiz(shuffle=True),
**btn_style
).pack(pady=15)
tk.Button(
self.root,
text="📕 错题本",
command=self.select_mistake_file,
**btn_style
).pack(pady=15)
# 🔔 添加右下角签名
signature = tk.Label(
self.root,
text="Powered by Python • 风山赋词",
font=("Arial", 9),
fg="gray",
bg="lightgray" if self.root.cget('bg') == 'systemButtonFace' else self.root.cget('bg'),
padx=10,
pady=2
)
signature.place(relx=1.0, rely=1.0, anchor='se', x=-10, y=-10)
def get_next_mistake_filename(self):
i = 1
while True:
filename = data_path(self.BASE_MISTAKE_FILE.format(i))
if not os.path.exists(filename):
return filename
i += 1
def compute_question_hash(self, question_data):
key = f"{question_data['question']}|" \
f"{sorted(question_data['options'].items())}|" \
f"{question_data['answer']}"
return hash(key)
def ensure_mistake_file(self):
"""懒加载错题本文件,保存到 data_path"""
if not hasattr(self, '_current_mistake_file'):
filename = self.get_next_mistake_filename()
try:
with open(filename, 'w', encoding='utf-8') as f:
pass
self._current_练习_答题脚本_mistake_file = filename
print(f"📝 新建错题本:{filename}")
except Exception as e:
messagebox.showerror("错误", f"无法创建错题本文件:\n{e}")
raise
return self._current_mistake_file
def save_mistake_unique(self, question):
q_hash = self.compute_question_hash(question)
existing_hashes = set()
current_file = self.ensure_mistake_file()
try:
with open(current_file, 'r', encoding='utf-8') as f:
for line in f:
if line.strip():
q = json.loads(line.strip())
existing_hashes.add(self.compute_question_hash(q))
except Exception as e:
print(f"⚠️ 读取错题文件失败:{e}")
if q_hash not in existing_hashes:
try:
with open(current_file, 'a', encoding='utf-8') as f:
f.write(json.dumps(question, ensure_ascii=False) + '\n')
except Exception as e:
print(f"⚠️ 无法保存错题:{e}")
def select_mistake_file(self):
files = [f for f in os.listdir(data_path(".")) if f.startswith('mistakes_') and f.endswith('.jsonl')]
if not files:
messagebox.showinfo("提示", "暂无任何错题本可供复习。")
return
sel_win = Toplevel(self.root)
sel_win.title("选择错题本")
sel_win.geometry("450x400")
sel_win.resizable(False, False)
main_frame = tk.Frame(sel_win)
main_frame.pack(fill="both", expand=True, padx=10, pady=10)
main_frame.grid_rowconfigure(1, weight=1)
main_frame.grid_columnconfigure(0, weight=1)
top_bar = tk.Frame(main_frame)
top_bar.grid(row=0, column=0, sticky="ew", pady=(0, 10))
top_bar.grid_columnconfigure(0, weight=1)
top_bar.grid_columnconfigure(1, weight=0)
tk.Label(top_bar, text="请选择要复习的错题本:", font=("Arial", 12, "bold")).grid(row=0, column=0, sticky="w")
manage_btn = tk.Button(
top_bar,
text="⚙️ 管理",
font=("Arial", 10),
bg="gray",
fg="white",
width=8,
height=1
)
manage_btn.grid(row=0, column=1, sticky="e")
listbox = Listbox(
main_frame,
width=50,
height=12,
font=("Arial", 11),
bd=0,
highlightthickness=0
)
listbox.grid(row=1, column=0, sticky="nsew", pady=(0, 10))
sorted_files = sorted(files, key=lambda x: int(x.split('_')[1].split('.')[0]) if '_' in x else 0)
display_map = {}
current_row = listbox.grid_info()['row'] + 1
for i, f in enumerate(sorted_files):
display_name = self.get_display_name(f)
listbox.insert(END, display_name)
display_map[display_name] = f
if i < len(sorted_files) - 1:
sep = tk.Frame(main_frame, height=1, bg="lightgray")
sep.grid(row=current_row, column=0, sticky="ew", padx=30, pady=2)
current_row += 1
def refresh_listbox():
listbox.delete(0, END)
display_map.clear()
for slave in main_frame.grid_slaves():
if isinstance(slave, tk.Frame) and slave.cget('height') == 1:
slave.destroy()
current_files = [f for f in os.listdir(data_path(".")) if f.startswith('mistakes_') and f.endswith('.jsonl')]
if not current_files:
messagebox.showinfo("提示", "所有错题本已被删除。")
sel_win.destroy()
return
sorted_current = sorted(current_files, key=lambda x: int(x.split('_')[1].split('.')[0]))
new_row = listbox.grid_info()['row'] + 1
for i, f in enumerate(sorted_current):
display_name = self.get_display_name(f)
listbox.insert(END, display_name)
display_map[display_name] = f
if i < len(sorted_current) - 1:
sep = tk.Frame(main_frame, height=1, bg="lightgray")
sep.grid(row=new_row, column=0, sticky="ew", padx=30, pady=2)
new_row += 1
manage_btn.config(command=lambda: self.manage_mistakebooks(refresh_listbox, sel_win))
btn_frame = tk.Frame(main_frame)
btn_frame.grid(row=999, column=0, pady=10)
def on_confirm():
selection = listbox.curselection()
if not selection:
messagebox.showwarning("提示", "请先选择一个错题本")
return
selected_name = listbox.get(selection[0])
filename = display_map[selected_name]
full_path = data_path(filename)
sel_win.destroy()
self.review_mistakes_from_file(full_path)
def on_cancel():
sel_win.destroy()
Button(btn_frame, text="✅ 确定", font=("Arial", 10), bg="blue", fg="white", width=10, command=on_confirm).pack(
side="left", padx=10)
Button(btn_frame, text="❌ 取消", font=("Arial", 10), bg="gray", fg="white", width=10, command=on_cancel).pack(
side="left", padx=10)
sel_win.protocol("WM_DELETE_WINDOW", on_cancel)
def manage_mistakebooks(self, refresh_parent, parent_window=None):
for w in self.root.winfo_children():
if isinstance(w, Toplevel) and w.title() == "管理错题本":
w.lift()
return
manage_win = Toplevel(self.root)
manage_win.title("管理错题本")
manage_win.geometry("500x400")
manage_win.resizable(False, False)
tk.Label(manage_win, text="管理错题本(可重命名或删除)", font=("Arial", 12, "bold")).pack(pady=10)
container = tk.Frame(manage_win)
container.pack(fill="both", expand=True, padx=20, pady=10)
canvas = tk.Canvas(container)
scrollbar = tk.Scrollbar(container, orient="vertical", command=canvas.yview)
scrollable_frame = tk.Frame(canvas)
scrollable_frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
items = []
def refresh_local():
nonlocal items
for item in items:
for widget in item.values():
if isinstance(widget, (list, tuple)):
for w in widget:
try:
w.destroy()
except tk.TclError:
pass
else:
try:
widget.destroy()
except tk.TclError:
pass
items.clear()
current_files = [f for f in os.listdir(data_path(".")) if f.startswith('mistakes_') and f.endswith('.jsonl')]
sorted_current = sorted(current_files, key=lambda x: int(x.split('_')[1].split('.')[0]))
if not sorted_current:
tk.Label(scrollable_frame, text="暂无错题本", fg="gray").pack(pady=20)
return
for f in sorted_current:
frame = tk.Frame(scrollable_frame, relief="solid", bd=1, padx=10, pady=8)
frame.pack(fill="x", pady=5)
alias_var = tk.StringVar(value=self.aliases.get(f, self.get_display_name(f)))
label = Label(frame, text=f"📁 {f}", font=("Courier", 9), fg="green")
label.grid(row=0, column=0, sticky="w", padx=5)
entry = Entry(frame, textvariable=alias_var, width=20, font=("Arial", 10))
entry.grid(row=0, column=1, padx=5)
def save_name(fn=f, var=alias_var):
new_name = var.get().strip()
if new_name:
self.aliases[fn] = new_name
self.save_aliases()
messagebox.showinfo("成功", f"已将 {fn} 重命名为:{new_name}")
else:
messagebox.showwarning("无效", "名称不能为空")
save_btn = Button(frame, text="💾 保存", font=("Arial", 8), bg="orange", fg="white", command=save_name)
save_btn.grid(row=0, column=2, padx=5)
def delete_this_file(filename):
if messagebox.askyesno("确认删除", f"确定要删除错题本 '{filename}' 吗?\n此操作不可恢复!"):
try:
os.remove(data_path(filename))
if filename in self.aliases:
del self.aliases[filename]
self.save_aliases()
refresh_parent()
refresh_local()
messagebox.showinfo("删除成功", f"已删除 {filename}")
except Exception as e:
messagebox.showerror("错误", f"删除失败:{str(e)}")
del_btn = Button(frame, text="🗑️ 删除", font=("Arial", 8), bg="red", fg="white",
command=lambda fn=f: delete_this_file(fn))
del_btn.grid(row=0, column=3, padx=5)
items.append({
'frame': frame,
'label': label,
'entry': entry,
'save_btn': save_btn,
'del_btn': del_btn,
'var': alias_var
})
refresh_local()
canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
close_btn = Button(manage_win, text="关闭", bg="blue", fg="white", command=manage_win.destroy)
close_btn.pack(pady=10)
def on_close():
nonlocal items
items.clear()
try:
manage_win.destroy()
except:
pass
manage_win.protocol("WM_DELETE_WINDOW", on_close)
def review_mistakes_from_file(self, file_path):
questions = self.load_questions(file_path)
if not questions:
messagebox.showinfo("提示", f"该错题本中没有有效题目。")
return
basename = os.path.basename(file_path)
self.current_mistake_file = file_path
self.run_practice_mode(questions, title=f"【错题本】{self.get_display_name(basename)}", mode="review")
def start_quiz(self, shuffle=False):
if hasattr(self, '_current_mistake_file'):
delattr(self, '_current_mistake_file')
questions = self.load_questions(OUTPUT_FILE)
if not questions:
messagebox.showwarning("提示", "未找到任何题目,请检查 output.jsonl 文件。")
return
if shuffle:
import random
random.shuffle(questions)
self.run_practice_mode(questions, title="【随机刷题】" if shuffle else "【顺序练习】", mode="quiz")
def load_questions(self, file_path):
if not os.path.exists(file_path):
return []
questions = []
try:
with open(file_path, 'r', encoding='utf-8') as f:
for line in f:
if line.strip():
q = json.loads(line.strip())
if q.get('question') and q.get('options') and len(q.get('answer', '')) == 1:
questions.append(q)
except Exception as e:
messagebox.showerror("错误", f"读取题目失败:{e}")
return questions
def wrap_text(self, text, max_len):
lines = []
while len(text) > max_len:
split_point = text.rfind(' ', 0, max_len)
if split_point == -1:
split_point = max_len
lines.append(text[:split_point])
text = text[split_point:].strip()
lines.append(text)
return lines
def format_option(self, key, value):
first_line = f"{key}. {value}"
if len(first_line) <= self.MAX_WIDTH:
return first_line
lines = self.wrap_text(first_line, self.MAX_WIDTH)
indent = f" {' ' * len(key)} "
wrapped_rest = "\n".join(indent + part for part in self.wrap_text(lines[1], self.MAX_WIDTH - len(indent)))
return lines[0] + "\n" + wrapped_rest
def run_practice_mode(self, questions, title, mode):
self.clear_window()
self.mode = mode
self.current_index = 0
self.questions = questions
self.title_text = title
tk.Label(self.root, text=title, font=("Arial", 16, "bold"), fg="blue").pack(pady=10)
self.question_label = tk.Label(
self.root, text="", wraplength=700, justify="left", anchor="w",
font=("Arial", 14, "bold"), padx=20
)
self.question_label.pack(pady=20, padx=20, fill="x")
self.buttons_frame = tk.Frame(self.root)
self.buttons_frame.pack(pady=10, padx=20, fill="both", expand=True)
self.progress_label = tk.Label(
self.root, text="", fg="gray", font=("Arial", 10)
)
self.progress_label.pack(side="bottom", anchor="e", padx=20, pady=30)
self.submit_button = tk.Button(
self.root, text="📤 交卷", font=("Arial", 10), bg="orange", fg="white", command=self.submit_exam
)
self.submit_button.place(relx=0.9, rely=0.9, anchor="center")
self.show_question()
def submit_exam(self):
if messagebox.askyesno("确认交卷?", "确定要提前结束并返回主菜单吗?"):
self.show_main_menu()
def normalize_answer(self, answer: str) -> str:
return answer.strip().upper()[0]
def show_question(self):
self.progress_label.config(text=f"第 {self.current_index + 1} / {len(self.questions)} 题")
q = self.questions[self.current_index]
correct_answer = self.normalize_answer(q['answer'])
wrapped_question = "\n".join(self.wrap_text(q['question'], self.MAX_WIDTH + 10))
self.question_label.config(text=wrapped_question)
for widget in self.buttons_frame.winfo_children():
widget.destroy()
for opt_key in sorted(q['options'].keys()):
full_text = self.format_option(opt_key, q['options'][opt_key])
btn = tk.Button(
self.buttons_frame, text=full_text, font=("Arial", 12), width=70,
anchor="w", justify="left", padx=10, pady=8, bg="#f9f9f9",
relief="solid", bd=1, wraplength=750
)
btn.pack(fill="x", pady=4)
def on_click(opt=opt_key, button=btn, q=q):
self.on_option_click(opt, button, correct_answer, q)
btn.bind("<Button-1>", lambda e, fn=on_click: fn())
def on_option_click(self, selected, button, correct_answer, question):
is_correct = self.normalize_answer(selected) == correct_answer
if is_correct:
button.config(bg="lightgreen", fg="black")
self.question_label.config(text="✅ 正确!")
else:
button.config(bg="lightcoral", fg="black")
self.question_label.config(text=f"❌ 错误!正确答案:{correct_answer}")
for widget in self.buttons_frame.winfo_children():
widget.unbind("<Button-1>")
if self.mode == "quiz" and not is_correct:
self.save_mistake_unique(question)
elif self.mode == "review" and is_correct:
self.remove_question_from_file(question)
self.root.after(500, self.next_question)
def remove_question_from_file(self, question_to_remove):
filename = getattr(self, 'current_mistake_file', None)
if not filename or not os.path.exists(filename):
return
remaining = []
q_hash = self.compute_question_hash(question_to_remove)
try:
with open(filename, 'r', encoding='utf-8') as f:
for line in f:
if line.strip():
q = json.loads(line.strip())
if self.compute_question_hash(q) != q_hash:
remaining.append(q)
with open(filename, 'w', encoding='utf-8') as f:
for q in remaining:
f.write(json.dumps(q, ensure_ascii=False) + '\n')
except Exception as e:
print(f"⚠️ 删除错题失败:{e}")
def next_question(self):
self.current_index += 1
if self.current_index < len(self.questions):
self.show_question()
else:
msg = f"🎉 已完成本轮 {len(self.questions)} 道题目!"
messagebox.showinfo("完成", msg)
self.show_main_menu()
def clear_window(self):
for widget in self.root.winfo_children():
widget.destroy()
if __name__ == '__main__':
root = tk.Tk()
app = FastQuizApp(root)
root.mainloop()
这个脚本只能运行单选题,对多选题没有反应,帮我修改一下。要求:1.在ui页面显示题型(type有“单选题”和“多选题”) 2.要求多选题所有选项都选上才能判断为本题正确 3.其他保持原样