diff --git a/README.md b/README.md deleted file mode 100644 index 02cbfde..0000000 --- a/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# double_program - diff --git a/doc/run_environment.md b/doc/run_environment.md new file mode 100644 index 0000000..c0edbf4 --- /dev/null +++ b/doc/run_environment.md @@ -0,0 +1,6 @@ +**结对编程项目** + +操作系统: Windows11 22H4 +编码语言: python3.8 +中文编码格式: UTF-8 +个人项目参考: 张勇进的个人项目 \ No newline at end of file diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..f8c1d40 --- /dev/null +++ b/src/main.py @@ -0,0 +1,790 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +单文件桌面应用:小初高数学练习(含注册/邮箱验证码、密码规则、题目自动生成) +修改点: +- 每道题操作数数量为 1-5(随机),操作数取值 1-100 +- 登录界面支持 使用 用户名 或 邮箱 登录 +- 其它功能保留:注册(邮箱验证码)、激活并设置密码、修改密码、选择学段与题量、自动生成题目、逐题答题、评分 +注意: +- 请替换 SMTP 配置为有效账户或在无法发送邮件时调整 send_verification_email 为测试模式。 +- Python 3.8+ +""" + +import tkinter as tk +from tkinter import messagebox, simpledialog, scrolledtext +import json +import os +import secrets +import hashlib +import binascii +import random +import re +import math +import threading +from datetime import datetime, timedelta +import smtplib +from email.mime.text import MIMEText + +# ---------------- 配置 ---------------- +USERS_FILE = "users.json" +QUESTION_FILES = { + "小学": "questions_primary.json", + "初中": "questions_middle.json", + "高中": "questions_high.json", +} +LOG_FILE = "app_log.txt" + +# SMTP 配置 —— 请替换为可用的 SMTP 服务账号/密码/端口 +SMTP_SERVER = "smtp.126.com" +SMTP_PORT = 25 +SENDER_EMAIL = "hape233@126.com" # <-- 请替换 +SENDER_PASSWORD = "ZKcbV37TdRwgnsZC" # <-- 请替换或使用授权码 + +CODE_TTL_SECONDS = 300 # 验证码有效期 5 分钟 +SEND_COOLDOWN = 60 # 发送按钮冷却 60 秒 +MAX_QUESTIONS = 20 # 题目上限 +DEFAULT_QUESTIONS = 3 # 默认题目数 + +WINDOW_W = 820 +WINDOW_H = 620 + +# 题目生成参数 +MIN_OPERANDS = 1 +MAX_OPERANDS = 5 +OPERAND_MIN = 1 +OPERAND_MAX = 100 +# -------------------------------------- + +# ----------------- 辅助与存储 ----------------- +def log(msg: str): + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(f"{datetime.utcnow().isoformat()} {msg}\n") + +def ensure_files_exist(): + # users file + if not os.path.exists(USERS_FILE): + with open(USERS_FILE, "w", encoding="utf-8") as f: + json.dump([], f, ensure_ascii=False, indent=2) + # question files + for k, p in QUESTION_FILES.items(): + if not os.path.exists(p): + with open(p, "w", encoding="utf-8") as f: + json.dump([], f, ensure_ascii=False, indent=2) + +# User store +class UserStore: + def __init__(self, path=USERS_FILE): + self.path = path + self._load() + + def _load(self): + try: + with open(self.path, "r", encoding="utf-8") as f: + self.users = json.load(f) + except Exception: + self.users = [] + + def _save(self): + with open(self.path, "w", encoding="utf-8") as f: + json.dump(self.users, f, ensure_ascii=False, indent=2) + + def find_by_email(self, email: str): + for u in self.users: + if u.get("email") == email: + return u + return None + + def find_by_username(self, username: str): + for u in self.users: + if u.get("username") == username: + return u + return None + + def find(self, key: str): + # try email first, then username + u = self.find_by_email(key) + if u: + return u + return self.find_by_username(key) + + def add_or_update(self, user: dict): + existing = self.find_by_email(user["email"]) + if existing: + existing.update(user) + else: + self.users.append(user) + self._save() + +# Question store +class QuestionStore: + def __init__(self, files_map=QUESTION_FILES): + self.files_map = files_map + + def load(self, grade: str): + p = self.files_map.get(grade) + try: + with open(p, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return [] + + def save(self, grade: str, qs): + p = self.files_map.get(grade) + with open(p, "w", encoding="utf-8") as f: + json.dump(qs, f, ensure_ascii=False, indent=2) + +# ----------------- 验证码管理 & 密码哈希 ----------------- +EMAIL_RE = re.compile(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$") +def is_valid_email(e: str) -> bool: + return bool(EMAIL_RE.match(e or "")) + +class VerificationManager: + def __init__(self): + self.store = {} # email -> (code, expiry) + + def gen(self, email): + code = f"{random.randint(100000, 999999)}" + expiry = datetime.utcnow() + timedelta(seconds=CODE_TTL_SECONDS) + self.store[email] = (code, expiry) + return code + + def verify(self, email, code): + rec = self.store.get(email) + if not rec: + return False, "未发送验证码" + real, expiry = rec + if datetime.utcnow() > expiry: + return False, "验证码已过期" + if not secrets.compare_digest(real, code): + return False, "验证码不匹配" + # 删除以防二次使用 + del self.store[email] + return True, "验证成功" + +verif_mgr = VerificationManager() + +def hash_password(password: str, salt: bytes = None): + if salt is None: + salt = secrets.token_bytes(16) + dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 100000) + return binascii.hexlify(dk).decode("utf-8"), binascii.hexlify(salt).decode("utf-8") + +def verify_password_hash(stored_hash, stored_salt, attempt): + try: + salt = binascii.unhexlify(stored_salt) + at_hash, _ = hash_password(attempt, salt) + return secrets.compare_digest(at_hash, stored_hash) + except Exception: + return False + +# ----------------- SMTP 发送 ----------------- +def send_verification_email(target_email: str, code: str): + """同步发送;上层在线程中调用""" + msg = MIMEText(f"您的注册码(6位):{code}\n有效期 {CODE_TTL_SECONDS//60} 分钟。") + msg['Subject'] = "注册码" + msg['From'] = SENDER_EMAIL + msg['To'] = target_email + try: + server = smtplib.SMTP(SMTP_SERVER, SMTP_PORT, timeout=30) + try: + server.starttls() + except Exception: + pass + server.login(SENDER_EMAIL, SENDER_PASSWORD) + server.sendmail(SENDER_EMAIL, [target_email], msg.as_string()) + try: + server.quit() + except Exception: + server.close() + log(f"Sent code {code} to {target_email}") + return True, "发送成功" + except Exception as e: + try: + server.close() + except: + pass + log(f"Failed send code to {target_email}: {e}") + return False, str(e) + +# ----------------- 题目生成(1~5 个操作数,操作数范围 1~100) ----------------- +def gen_id(prefix: str) -> str: + return f"{prefix}_{secrets.token_hex(4)}" + +def mk_options_from_number(correct_val, kind="int"): + # produce 4 unique options including correct + opts = [] + if kind == "int": + corr = int(round(correct_val)) + opts = [str(corr)] + attempts = 0 + while len(opts) < 4 and attempts < 100: + delta = random.choice([-10, -6, -3, -2, -1, 1, 2, 3, 5, 8, 10]) + candidate = corr + delta + if str(candidate) not in opts: + opts.append(str(candidate)) + attempts += 1 + while len(opts) < 4: + opts.append(str(corr + random.randint(11, 50))) + random.shuffle(opts) + return opts, opts.index(str(corr)) + else: + # float + corr = float(correct_val) + corr_s = f"{corr:.3f}" + opts = [corr_s] + attempts = 0 + while len(opts) < 4 and attempts < 200: + delta = random.choice([-0.8, -0.5, -0.3, -0.2, -0.1, 0.1, 0.2, 0.3, 0.5, 0.8]) + cand = corr + delta + s = f"{cand:.3f}" + if s not in opts: + opts.append(s) + attempts += 1 + while len(opts) < 4: + opts.append(f"{(corr + random.uniform(1.0,3.0)):.3f}") + random.shuffle(opts) + return opts, opts.index(corr_s) + +# 安全评估:表达式由程序生成,使用受限 eval 环境评估 +SAFE_EVAL_ENV = {"__builtins__": None, "sqrt": math.sqrt, "sin": math.sin, "cos": math.cos, "tan": math.tan, "pi": math.pi} + +def safe_eval(expr: str): + # 只包含数字、运算符、sqrt, sin, cos, tan, (, ), ^ 替换为 ** + expr = expr.replace("^", "**") + return eval(expr, SAFE_EVAL_ENV, {}) + +# 小学题:1~5 个操作数,运算符 + - * /,可能带一对括号 +def generate_primary_question(): + n_ops = random.randint(MIN_OPERANDS, MAX_OPERANDS) # number of operands + nums = [random.randint(OPERAND_MIN, OPERAND_MAX) for _ in range(n_ops)] + ops_list = [random.choice(['+', '-', '*', '/']) for _ in range(max(0, n_ops - 1))] + # Build expression, maybe with parentheses + parts = [] + for i in range(n_ops): + parts.append(str(nums[i])) + if i < len(ops_list): + parts.append(ops_list[i]) + expr = "".join(parts) + if n_ops >= 2 and random.choice([True, False]): + idx = random.randint(0, n_ops - 2) + parts = [] + for i in range(n_ops): + p = str(nums[i]) + if i == idx: + p = "(" + p + if i == idx + 1: + p = p + ")" + parts.append(p) + if i < len(ops_list): + parts.append(ops_list[i]) + expr = "".join(parts) + # evaluate and prefer integer + val = None + for _ in range(60): + try: + v = safe_eval(expr) + if v is None or isinstance(v, complex): + raise ValueError + if isinstance(v, float): + v = int(round(v)) + val = v + break + except Exception: + # regenerate simpler + n_ops = random.randint(MIN_OPERANDS, MAX_OPERANDS) + nums = [random.randint(OPERAND_MIN, OPERAND_MAX) for _ in range(n_ops)] + ops_list = [random.choice(['+', '-', '*', '/']) for _ in range(max(0, n_ops - 1))] + parts = [] + for i in range(n_ops): + parts.append(str(nums[i])) + if i < len(ops_list): + parts.append(ops_list[i]) + expr = "".join(parts) + if val is None: + val = 0 + opts, idx = mk_options_from_number(val, kind="int") + return {"id": gen_id("pri"), "grade": "小学", "stem": f"{expr} = ?", "options": opts, "answer_index": idx} + +# 初中题:至少包含平方或开根号 sqrt(...) +def generate_middle_question(): + use_sqrt = random.choice([True, False]) + use_square = not use_sqrt if random.random() < 0.3 else random.choice([True, False]) + n_ops = random.randint(MIN_OPERANDS, MAX_OPERANDS) + nums = [random.randint(OPERAND_MIN, OPERAND_MAX) for _ in range(n_ops)] + ops_list = [random.choice(['+', '-', '*', '/']) for _ in range(max(0, n_ops - 1))] + idx = random.randint(0, n_ops - 1) + if use_sqrt: + # try to use perfect square inside sometimes + inside = random.randint(1, 50) + if random.choice([True, False]): + nums[idx] = f"sqrt({inside*inside})" + else: + nums[idx] = f"sqrt({nums[idx]})" + else: + nums[idx] = f"({nums[idx]}**2)" + parts = [] + for i in range(n_ops): + parts.append(str(nums[i])) + if i < len(ops_list): + parts.append(ops_list[i]) + expr = "".join(parts) + val = None + for _ in range(60): + try: + v = safe_eval(expr) + if v is None or isinstance(v, complex): + raise ValueError + val = v + break + except Exception: + n_ops = random.randint(MIN_OPERANDS, MAX_OPERANDS) + nums = [random.randint(OPERAND_MIN, OPERAND_MAX) for _ in range(n_ops)] + ops_list = [random.choice(['+', '-', '*', '/']) for _ in range(max(0, n_ops - 1))] + idx = random.randint(0, n_ops - 1) + if random.choice([True, False]): + nums[idx] = f"sqrt({nums[idx]*nums[idx]})" + else: + nums[idx] = f"({nums[idx]}**2)" + parts = [] + for i in range(n_ops): + parts.append(str(nums[i])) + if i < len(ops_list): + parts.append(ops_list[i]) + expr = "".join(parts) + if val is None: + val = 0 + if isinstance(val, float) and abs(val - round(val)) > 1e-9: + opts, idx = mk_options_from_number(val, kind="float") + else: + opts, idx = mk_options_from_number(val, kind="int") + return {"id": gen_id("mid"), "grade": "初中", "stem": f"{expr} = ?", "options": opts, "answer_index": idx} + +# 高中题:至少包含 sin/cos/tan(使用角度),并可包含 1~5 个操作数 +def generate_high_question(): + func = random.choice(["sin", "cos", "tan"]) + n_ops = random.randint(MIN_OPERANDS, MAX_OPERANDS) + nums = [random.randint(OPERAND_MIN, OPERAND_MAX) for _ in range(n_ops)] + ops_list = [random.choice(['+', '-', '*', '/']) for _ in range(max(0, n_ops - 1))] + idx = random.randint(0, n_ops - 1) + angle = random.choice([0, 30, 45, 60, 90, 120, 135, 150, 180, 210, 225, 240, 270, 300, 315, 330]) + nums[idx] = f"{func}(pi*{angle}/180)" + parts = [] + for i in range(n_ops): + parts.append(str(nums[i])) + if i < len(ops_list): + parts.append(ops_list[i]) + expr = "".join(parts) + val = None + for _ in range(60): + try: + v = safe_eval(expr) + if v is None or isinstance(v, complex): + raise ValueError + val = v + break + except Exception: + n_ops = random.randint(MIN_OPERANDS, MAX_OPERANDS) + nums = [random.randint(OPERAND_MIN, OPERAND_MAX) for _ in range(n_ops)] + ops_list = [random.choice(['+', '-', '*', '/']) for _ in range(max(0, n_ops - 1))] + idx = random.randint(0, n_ops - 1) + angle = random.choice([30,45,60,90,120]) + func = random.choice(["sin", "cos", "tan"]) + nums[idx] = f"{func}(pi*{angle}/180)" + parts = [] + for i in range(n_ops): + parts.append(str(nums[i])) + if i < len(ops_list): + parts.append(ops_list[i]) + expr = "".join(parts) + if val is None: + val = 0.0 + opts, idx = mk_options_from_number(val, kind="float") + stem = f"{expr} = ? (保留3位小数)" + return {"id": gen_id("high"), "grade": "高中", "stem": stem, "options": opts, "answer_index": idx} + +# Ensure enough questions in file; auto-generate and append if insufficient +def ensure_questions_for_grade(grade: str, needed: int, qstore: QuestionStore): + qs = qstore.load(grade) + if len(qs) >= needed: + return qs + to_gen = needed - len(qs) + gen = [] + for _ in range(to_gen): + if grade == "小学": + gen.append(generate_primary_question()) + elif grade == "初中": + gen.append(generate_middle_question()) + else: + gen.append(generate_high_question()) + qs.extend(gen) + qstore.save(grade, qs) + log(f"Auto-generated {len(gen)} questions for {grade}") + return qs + +# ----------------- 服务逻辑(注册/激活/登录/改密) ----------------- +class AuthService: + def __init__(self, store: UserStore): + self.store = store + + def send_code(self, email: str): + if not is_valid_email(email): + return False, "邮箱格式不正确" + u = self.store.find_by_email(email) + if u and u.get("activated"): + return False, "该邮箱已注册并激活" + code = verif_mgr.gen(email) + ok, msg = send_verification_email(email, code) + return ok, msg + + def register_with_code(self, email: str, code: str, username: str, password: str): + if not is_valid_email(email): + return False, "邮箱格式不正确" + if not username: + return False, "用户名不能为空" + # check username uniqueness + existing_user = self.store.find_by_username(username) + if existing_user and existing_user.get("email") != email: + return False, "用户名已被占用" + if not (6 <= len(password) <= 10 and any(c.isupper() for c in password) and any(c.islower() for c in password) and any(c.isdigit() for c in password)): + return False, "密码规则不满足(6-10位,含大小写字母和数字)" + ok, msg = verif_mgr.verify(email, code) + if not ok: + return False, msg + ph, salt = hash_password(password) + rec = { + "email": email, + "username": username, + "password_hash": ph, + "salt": salt, + "activated": True, + "created_at": datetime.utcnow().isoformat() + } + self.store.add_or_update(rec) + log(f"user registered: {email} / {username}") + return True, "注册并设置密码成功" + + def login(self, key: str, password: str): + # key can be email or username + u = self.store.find(key) + if not u: + return False, "未找到该用户" + if not u.get("activated"): + return False, "该账号未激活" + if not verify_password_hash(u.get("password_hash"), u.get("salt"), password): + return False, "密码错误" + return True, u + + def change_password(self, email: str, old_pw: str, new_pw: str): + u = self.store.find_by_email(email) + if not u: + return False, "未找到用户" + if not verify_password_hash(u.get("password_hash"), u.get("salt"), old_pw): + return False, "原密码错误" + if not (6 <= len(new_pw) <= 10 and any(c.isupper() for c in new_pw) and any(c.islower() for c in new_pw) and any(c.isdigit() for c in new_pw)): + return False, "新密码不符合规则" + ph, salt = hash_password(new_pw) + u.update({"password_hash": ph, "salt": salt}) + self.store._save() + log(f"user changed password: {email}") + return True, "修改成功" + +# ----------------- GUI ----------------- +class MathApp: + def __init__(self, root): + self.root = root + self.root.title("小初高数学练习") + self.root.geometry(f"{WINDOW_W}x{WINDOW_H}") + ensure_files_exist() + self.user_store = UserStore() + self.qstore = QuestionStore() + self.auth = AuthService(self.user_store) + self.current_user = None # user dict + self.current_exam = None + self.build_main() + + def clear(self): + for w in self.root.winfo_children(): + w.destroy() + + def build_main(self): + self.clear() + frm = tk.Frame(self.root, padx=20, pady=20) + frm.pack(fill="both", expand=True) + tk.Label(frm, text="数学学习应用", font=("Arial", 18)).pack(pady=12) + tk.Button(frm, text="注册", width=40, command=self.ui_register_flow).pack(pady=6) + tk.Button(frm, text="登录", width=40, command=self.ui_login).pack(pady=6) + tk.Button(frm, text="退出", width=40, command=self.root.quit).pack(pady=6) + + # ----- 注册流程 ----- + def ui_register_flow(self): + self.clear() + fr = tk.Frame(self.root, padx=12, pady=12) + fr.pack(fill="both", expand=True) + + tk.Label(fr, text="邮箱(注册邮箱):").grid(row=0, column=0, sticky="e") + email_var = tk.StringVar() + email_entry = tk.Entry(fr, textvariable=email_var, width=36) + email_entry.grid(row=0, column=1, padx=6) + send_btn = tk.Button(fr, text="发送验证码") + send_btn.grid(row=0, column=2, padx=6) + + tk.Label(fr, text="验证码:").grid(row=1, column=0, sticky="e") + code_var = tk.StringVar() + code_entry = tk.Entry(fr, textvariable=code_var, width=20) + code_entry.grid(row=1, column=1, padx=6) + + tk.Label(fr, text="用户名:").grid(row=2, column=0, sticky="e") + username_var = tk.StringVar() + username_entry = tk.Entry(fr, textvariable=username_var, width=30) + username_entry.grid(row=2, column=1, padx=6) + + tk.Label(fr, text="密码:").grid(row=3, column=0, sticky="e") + pw_var = tk.StringVar() + pw_entry = tk.Entry(fr, textvariable=pw_var, show="*", width=30) + pw_entry.grid(row=3, column=1, padx=6) + + tk.Label(fr, text="确认密码:").grid(row=4, column=0, sticky="e") + pw2_var = tk.StringVar() + pw2_entry = tk.Entry(fr, textvariable=pw2_var, show="*", width=30) + pw2_entry.grid(row=4, column=1, padx=6) + + btn_fr = tk.Frame(fr) + btn_fr.grid(row=6, column=0, columnspan=3, pady=18) + tk.Button(btn_fr, text="确定", width=12, command=lambda: on_confirm()).pack(side="left", padx=8) + tk.Button(btn_fr, text="返回", width=12, command=self.build_main).pack(side="left", padx=8) + + fr.grid_columnconfigure(1, weight=1) + + cooldown = {"left": 0, "timer": None} + + def update_send_btn(): + if cooldown["left"] > 0: + send_btn.config(text=f"重新发送({cooldown['left']})", state="disabled") + else: + send_btn.config(text="发送验证码", state="normal") + + def countdown_tick(): + if cooldown["left"] <= 0: + update_send_btn() + return + cooldown["left"] -= 1 + update_send_btn() + cooldown["timer"] = self.root.after(1000, countdown_tick) + + def on_send(): + email = email_var.get().strip() + if not is_valid_email(email): + messagebox.showwarning("邮箱错误", "请输入正确的邮箱地址", parent=self.root) + return + # generate and send code asynchronously + def worker(): + send_btn.config(state="disabled", text="发送中...") + ok, msg = self.auth.send_code(email) + if ok: + messagebox.showinfo("发送成功", f"验证码已发送到 {email}(若未收到, 请检查垃圾邮件)。", parent=self.root) + cooldown["left"] = SEND_COOLDOWN + countdown_tick() + else: + messagebox.showerror("发送失败", f"发送失败: {msg}", parent=self.root) + update_send_btn() + threading.Thread(target=worker, daemon=True).start() + + send_btn.config(command=on_send) + + def on_confirm(): + email = email_var.get().strip() + code = code_var.get().strip() + username = username_var.get().strip() + pw = pw_var.get() + pw2 = pw2_var.get() + if not is_valid_email(email): + messagebox.showwarning("错误", "请输入合法邮箱", parent=self.root); return + if not code: + messagebox.showwarning("错误", "请输入验证码", parent=self.root); return + if not username: + messagebox.showwarning("错误", "请输入用户名", parent=self.root); return + if not pw or not pw2: + messagebox.showwarning("错误", "请输入密码并确认", parent=self.root); return + if pw != pw2: + messagebox.showwarning("错误", "两次密码不一致", parent=self.root); return + if not (6 <= len(pw) <= 10 and any(c.isupper() for c in pw) and any(c.islower() for c in pw) and any(c.isdigit() for c in pw)): + messagebox.showwarning("错误", "密码需6-10位,且包含大写、小写字母与数字", parent=self.root); return + ok, msg = self.auth.register_with_code(email, code, username, pw) + if not ok: + messagebox.showerror("失败", msg, parent=self.root); return + messagebox.showinfo("成功", "注册并设置密码成功,跳转到选择界面", parent=self.root) + # login and go to choice screen + self.current_user = self.user_store.find_by_email(email) + self.build_choice_screen() + + # ----- 登录界面(用户名/邮箱登录) ----- + def ui_login(self): + self.clear() + fr = tk.Frame(self.root, padx=12, pady=12) + fr.pack(fill="both", expand=True) + tk.Label(fr, text="登录", font=("Arial", 16)).pack(pady=8) + tk.Label(fr, text="用户名/邮箱:").pack(anchor="w") + key_var = tk.StringVar() + tk.Entry(fr, textvariable=key_var, width=50).pack(pady=4) + tk.Label(fr, text="密码:").pack(anchor="w") + pw_var = tk.StringVar() + tk.Entry(fr, textvariable=pw_var, show="*", width=50).pack(pady=4) + btn_fr = tk.Frame(fr); btn_fr.pack(pady=12) + def do_login(): + key = key_var.get().strip() + pw = pw_var.get() + if not key: + messagebox.showwarning("输入错误", "请输入用户名或邮箱", parent=self.root); return + ok, res = self.auth.login(key, pw) + if not ok: + messagebox.showerror("登录失败", res, parent=self.root); return + # res is user dict + self.current_user = res + messagebox.showinfo("登录成功", f"欢迎,{self.current_user.get('username')}", parent=self.root) + self.build_choice_screen() + tk.Button(btn_fr, text="登录", width=12, command=do_login).pack(side="left", padx=6) + tk.Button(btn_fr, text="返回", width=12, command=self.build_main).pack(side="left", padx=6) + + # ----- 选择学段与题量 ----- + def build_choice_screen(self): + self.clear() + fr = tk.Frame(self.root, padx=12, pady=12) + fr.pack(fill="both", expand=True) + uname = self.current_user.get("username") if self.current_user else "" + email = self.current_user.get("email") if self.current_user else "" + tk.Label(fr, text=f"已登录: {uname} ({email})", font=("Arial", 12)).pack(anchor="w") + tk.Button(fr, text="修改密码", command=self.ui_change_password).pack(anchor="w", pady=4) + tk.Button(fr, text="注销", command=self.logout).pack(anchor="w", pady=4) + tk.Label(fr, text="请选择学段并输入题目数量(默认3,上限20):", font=("Arial", 12)).pack(pady=8, anchor="w") + grade_var = tk.StringVar(value="小学") + for g in QUESTION_FILES.keys(): + tk.Radiobutton(fr, text=g, variable=grade_var, value=g).pack(anchor="w") + num_entry = tk.Entry(fr, width=8) + num_entry.pack(pady=6, anchor="w") + num_entry.delete(0, "end"); num_entry.insert(0, str(DEFAULT_QUESTIONS)) + def start(): + try: + n = int(num_entry.get()) + if n <= 0: + raise ValueError() + if n > MAX_QUESTIONS: + messagebox.showwarning("超过上限", f"题目数量上限为 {MAX_QUESTIONS}", parent=self.root); return + except Exception: + messagebox.showwarning("输入错误", "请输入正整数题目数量", parent=self.root); return + grade = grade_var.get() + qs = self.qstore.load(grade) + if len(qs) < n: + qs = ensure_questions_for_grade(grade, n, self.qstore) + chosen = random.sample(qs, n) + self.current_exam = {"grade": grade, "questions": chosen, "index": 0, "answers": [None]*n} + self.build_question_screen() + tk.Button(fr, text="开始做题", command=start).pack(pady=8) + + def logout(self): + self.current_user = None + self.current_exam = None + self.build_main() + + def ui_change_password(self): + if not self.current_user: + messagebox.showwarning("未登录", "请先登录", parent=self.root); return + old = simpledialog.askstring("修改密码", "请输入原密码:", show="*", parent=self.root) + if old is None: + return + new1 = simpledialog.askstring("新密码", "请输入新密码(6-10位,含大小写字母与数字):", show="*", parent=self.root) + if new1 is None: + return + new2 = simpledialog.askstring("确认新密码", "请再次输入新密码:", show="*", parent=self.root) + if new2 is None: + return + if new1 != new2: + messagebox.showwarning("不一致", "两次新密码不一致", parent=self.root); return + ok, msg = self.auth.change_password(self.current_user.get("email"), old, new1) + if not ok: + messagebox.showerror("失败", msg, parent=self.root); return + messagebox.showinfo("成功", "密码修改成功", parent=self.root) + + # ----- 题目界面 ----- + def build_question_screen(self): + self.clear() + exam = self.current_exam + idx = exam["index"] + q = exam["questions"][idx] + fr = tk.Frame(self.root, padx=12, pady=12) + fr.pack(fill="both", expand=True) + header = tk.Label(fr, text=f"学段:{exam['grade']} 题目 {idx+1}/{len(exam['questions'])}", font=("Arial", 12)) + header.pack(anchor="w") + stem = tk.Label(fr, text=q["stem"], font=("Arial", 14), wraplength=WINDOW_W-80, justify="left") + stem.pack(pady=8, anchor="w") + chosen_var = tk.IntVar(value=-1 if exam["answers"][idx] is None else exam["answers"][idx]) + for i, opt in enumerate(q["options"]): + tk.Radiobutton(fr, text=opt, variable=chosen_var, value=i).pack(anchor="w") + nav = tk.Frame(fr); nav.pack(pady=10, anchor="w") + def save_choice(): + v = chosen_var.get() + exam["answers"][idx] = None if v == -1 else int(v) + return True + def prev_q(): + if idx == 0: + messagebox.showinfo("提示", "已经是第一题", parent=self.root); return + save_choice(); exam["index"] -= 1; self.build_question_screen() + def next_or_finish(): + save_choice() + if idx + 1 < len(exam["questions"]): + exam["index"] += 1 + self.build_question_screen() + else: + self.show_result_screen() + tk.Button(nav, text="上一题", command=prev_q).pack(side="left", padx=6) + tk.Button(nav, text="下一题/提交", command=next_or_finish).pack(side="left", padx=6) + tk.Button(nav, text="放弃并返回", command=self.build_choice_screen).pack(side="left", padx=6) + + # ----- 结果界面 ----- + def show_result_screen(self): + exam = self.current_exam + total = len(exam["questions"]) + correct = 0 + for i, q in enumerate(exam["questions"]): + ans = exam["answers"][i] + if ans is not None and ans == q["answer_index"]: + correct += 1 + percent = round(correct / total * 100, 2) if total > 0 else 0.0 + self.clear() + fr = tk.Frame(self.root, padx=12, pady=12) + fr.pack(fill="both", expand=True) + tk.Label(fr, text=f"考试结束 - 得分:{percent}%", font=("Arial", 14)).pack(pady=6) + tk.Label(fr, text=f"答对:{correct} / {total}").pack(pady=4) + def show_details(): + win = tk.Toplevel(self.root); win.title("答题详情") + txt = scrolledtext.ScrolledText(win, width=100, height=30) + txt.pack(fill="both", expand=True) + for i, q in enumerate(exam["questions"]): + chosen = exam["answers"][i] + chosen_text = q["options"][chosen] if chosen is not None and 0 <= chosen < len(q["options"]) else "未作答" + correct_text = q["options"][q["answer_index"]] + status = "正确" if chosen == q["answer_index"] else "错误" + txt.insert("end", f"题 {i+1}: {q['stem']}\n你的答案: {chosen_text}\n正确答案: {correct_text} [{status}]\n\n") + txt.config(state="disabled") + tk.Button(fr, text="查看答题详情", command=show_details).pack(pady=6) + tk.Button(fr, text="继续做题", command=self._continue_again).pack(pady=6) + tk.Button(fr, text="退出登录并返回主界面", command=self._logout_and_return).pack(pady=6) + + def _continue_again(self): + self.current_exam = None + self.build_choice_screen() + + def _logout_and_return(self): + self.current_user = None + self.current_exam = None + self.build_main() + +# ----------------- 初始化 ----------------- +def main(): + ensure_files_exist() + root = tk.Tk() + app = MathApp(root) + root.mainloop() + +if __name__ == "__main__": + main() \ No newline at end of file