From 9447d5c9efee943b1d3c867d272f6ff1a0836736 Mon Sep 17 00:00:00 2001 From: PureCloud <2100669020@qq.com> Date: Sun, 12 Oct 2025 12:49:11 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E5=88=9D=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.py | 412 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 412 insertions(+) create mode 100644 src/main.py diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..9c54e02 --- /dev/null +++ b/src/main.py @@ -0,0 +1,412 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +数学学习桌面应用 原型(Python + Tkinter) + +功能: +- 用户注册(邮箱输入 -> 生成注册码并在界面显示用于测试) +- 输入注册码激活并设置密码(密码规则:6-10位,包含大写、小写、数字) +- 登录、修改密码(需输入原密码) +- 学段选择(小学/初中/高中)并输入题目数量 +- 随机生成无重复选择题试卷(题库为本地 JSON 文件) +- 逐题答题、提交、最后评分并可继续/退出 +- 本地文件存储(users.json, questions_primary.json, questions_middle.json, questions_high.json) +- 密码使用 PBKDF2-HMAC-SHA256 加盐哈希 +""" + +import tkinter as tk +from tkinter import messagebox, simpledialog +import json +import os +import secrets +import hashlib +import binascii +import random +from datetime import datetime, timedelta + +# ---------- 配置 ---------- +USERS_FILE = "users.json" +QUESTION_FILES = { + "小学": "questions_primary.json", + "初中": "questions_middle.json", + "高中": "questions_high.json", +} +REGCODE_TTL_MINUTES = 30 # 注册码有效期(本地记录) +# -------------------------- + +# ---------- 辅助函数 ---------- +def ensure_files_exist(): + """确保用户文件和题库存在;若不存在则创建示例数据。""" + 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) + + # 示例题库(每个学段若无文件则创建几个示例题) + sample_questions = { + "小学": [ + {"id": "p001", "grade": "小学", "stem": "2 + 3 = ?", "options": ["3", "4", "5", "6"], "answer_index": 2}, + {"id": "p002", "grade": "小学", "stem": "5 - 2 = ?", "options": ["1", "2", "3", "4"], "answer_index": 2}, + {"id": "p003", "grade": "小学", "stem": "3 × 2 = ?", "options": ["5", "6", "7", "8"], "answer_index": 1}, + ], + "初中": [ + {"id": "m001", "grade": "初中", "stem": "若 x=2,则 3x+1 = ?", "options": ["5", "6", "7", "8"], "answer_index": 0}, + {"id": "m002", "grade": "初中", "stem": "一次函数 y=2x+1 的斜率是?", "options": ["1", "2", "3", "4"], "answer_index": 1}, + {"id": "m003", "grade": "初中", "stem": "下列哪组数是等差数列?", "options": ["1,2,4", "2,4,6", "1,3,6", "2,3,5"], "answer_index": 1}, + ], + "高中": [ + {"id": "h001", "grade": "高中", "stem": "求极限 lim(x->0) (sin x)/x =", "options": ["0", "1", "无穷", "不存在"], "answer_index": 1}, + {"id": "h002", "grade": "高中", "stem": "复数 i 的平方等于?", "options": ["1", "-1", "i", "-i"], "answer_index": 1}, + {"id": "h003", "grade": "高中", "stem": "若 f(x)=x^2,则 f'(x) = ?", "options": ["2x", "x", "x^2", "2"], "answer_index": 0}, + ], + } + + for grade, path in QUESTION_FILES.items(): + if not os.path.exists(path): + with open(path, "w", encoding="utf-8") as f: + json.dump(sample_questions[grade], f, ensure_ascii=False, indent=2) + +def load_users(): + with open(USERS_FILE, "r", encoding="utf-8") as f: + try: + return json.load(f) + except json.JSONDecodeError: + return [] + +def save_users(users): + with open(USERS_FILE, "w", encoding="utf-8") as f: + json.dump(users, f, ensure_ascii=False, indent=2) + +def load_questions(grade): + path = QUESTION_FILES[grade] + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + +# 密码哈希(PBKDF2) +def hash_password(password, salt=None): + if salt is None: + salt = secrets.token_bytes(16) + elif isinstance(salt, str): + salt = binascii.unhexlify(salt) + 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(stored_hash, stored_salt, password_attempt): + attempt_hash, _ = hash_password(password_attempt, stored_salt) + return secrets.compare_digest(attempt_hash, stored_hash) + +# 验证密码规则 +def validate_password_rules(pw): + if not (6 <= len(pw) <= 10): + return False + has_upper = any(c.isupper() for c in pw) + has_lower = any(c.islower() for c in pw) + has_digit = any(c.isdigit() for c in pw) + return has_upper and has_lower and has_digit + +# 生成注册码并记录在用户条目中(本地模拟发送) +def generate_regcode_for_email(email): + users = load_users() + # 生成 regcode 并保存到 users 文件(作为未激活临时记录) + regcode = secrets.token_hex(3) # 6 hex chars + expiry = (datetime.utcnow() + timedelta(minutes=REGCODE_TTL_MINUTES)).isoformat() + # 若已存在相同邮箱,更新 regcode 与未激活状态;否则新增条目 + for u in users: + if u.get("email") == email: + u["regcode"] = regcode + u["regcode_expiry"] = expiry + u["activated"] = False + save_users(users) + return regcode + # 新用户占位(无密码) + new_user = { + "email": email, + "activated": False, + "regcode": regcode, + "regcode_expiry": expiry, + "password_hash": None, + "salt": None, + "created_at": datetime.utcnow().isoformat() + } + users.append(new_user) + save_users(users) + return regcode + +def activate_user_with_code(email, code): + users = load_users() + for u in users: + if u.get("email") == email: + if u.get("activated"): + return False, "该邮箱已激活" + exp = u.get("regcode_expiry") + if not u.get("regcode") or u.get("regcode") != code: + return False, "注册码不正确" + if exp and datetime.fromisoformat(exp) < datetime.utcnow(): + return False, "注册码已过期" + u["activated"] = True + # 清除 regcode but keep for record + u.pop("regcode", None) + u.pop("regcode_expiry", None) + save_users(users) + return True, "激活成功" + return False, "未找到该邮箱的注册记录" + +# 查找用户 +def find_user_by_email(email): + users = load_users() + for u in users: + if u.get("email") == email: + return u + return None + +# 更新用户密码(哈希) +def set_user_password(email, password): + users = load_users() + for u in users: + if u.get("email") == email: + h, s = hash_password(password) + u["password_hash"] = h + u["salt"] = s + save_users(users) + return True + return False + +# ---------- GUI ---------- +class MathApp: + def __init__(self, root): + self.root = root + self.root.title("小初高数学学习软件(原型)") + self.current_user = None + self.current_exam = None # dict with questions, index, answers + self.build_main_frame() + + def build_main_frame(self): + for w in self.root.winfo_children(): + w.destroy() + frame = tk.Frame(self.root, padx=20, pady=20) + frame.pack() + + tk.Label(frame, text="数学学习软件(原型)", font=("Arial", 16)).pack(pady=10) + tk.Button(frame, text="注册 (发送注册码)", width=30, command=self.register_send_code).pack(pady=5) + tk.Button(frame, text="激活并设置密码", width=30, command=self.activate_and_set_password).pack(pady=5) + tk.Button(frame, text="登录", width=30, command=self.login).pack(pady=5) + tk.Button(frame, text="退出", width=30, command=self.root.quit).pack(pady=5) + + # 注册(生成注册码并模拟发送) + def register_send_code(self): + email = simpledialog.askstring("注册 - 输入邮箱", "请输入邮箱:", parent=self.root) + if not email: + return + reg = generate_regcode_for_email(email) + # 模拟发送:展示在对话框中(开发/测试用) + messagebox.showinfo("注册码(测试用)", f"已生成注册码并“发送”到 {email}。\n测试模式下注册码为:{reg}\n有效期 {REGCODE_TTL_MINUTES} 分钟", parent=self.root) + + # 激活并设置密码 + def activate_and_set_password(self): + email = simpledialog.askstring("激活 - 输入邮箱", "请输入注册时的邮箱:", parent=self.root) + if not email: + return + code = simpledialog.askstring("激活 - 输入注册码", "请输入收到的注册码:", parent=self.root) + if not code: + return + ok, msg = activate_user_with_code(email, code) + if not ok: + messagebox.showerror("激活失败", msg, parent=self.root) + return + # 设置密码 + while True: + pw1 = simpledialog.askstring("设置密码", "请输入新密码(6-10位,含大小写字母与数字):", show="*", parent=self.root) + if pw1 is None: + return + pw2 = simpledialog.askstring("确认密码", "请再次输入新密码:", show="*", parent=self.root) + if pw2 is None: + return + if pw1 != pw2: + messagebox.showwarning("密码不匹配", "两次输入的密码不一致,请重新输入。", parent=self.root) + continue + if not validate_password_rules(pw1): + messagebox.showwarning("密码规则不满足", "密码需为6-10位,且包含大写字母、小写字母和数字。", parent=self.root) + continue + set_user_password(email, pw1) + messagebox.showinfo("设置成功", "密码设置成功,跳转到选择界面。", parent=self.root) + self.current_user = find_user_by_email(email) + self.show_choice_screen() + return + + # 登录 + def login(self): + email = simpledialog.askstring("登录 - 输入邮箱", "请输入邮箱:", parent=self.root) + if not email: + return + user = find_user_by_email(email) + if not user: + messagebox.showerror("登录失败", "未找到该邮箱,请先注册并激活。", parent=self.root) + return + if not user.get("activated"): + messagebox.showerror("未激活", "该账号尚未激活,请先激活。", parent=self.root) + return + pw = simpledialog.askstring("登录 - 密码", "请输入密码:", show="*", parent=self.root) + if pw is None: + return + if not verify_password(user.get("password_hash"), user.get("salt"), pw): + messagebox.showerror("登录失败", "密码错误。", parent=self.root) + return + self.current_user = user + self.show_choice_screen() + + # 修改密码(需要登录) + def 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 + if not verify_password(self.current_user.get("password_hash"), self.current_user.get("salt"), old): + messagebox.showerror("失败", "原密码不正确。", parent=self.root) + return + while True: + npw1 = simpledialog.askstring("修改密码 - 新密码", "请输入新密码(6-10位,含大小写字母与数字):", show="*", parent=self.root) + if npw1 is None: + return + npw2 = simpledialog.askstring("确认新密码", "请再次输入新密码:", show="*", parent=self.root) + if npw2 is None: + return + if npw1 != npw2: + messagebox.showwarning("密码不匹配", "两次输入的密码不一致,请重新输入。", parent=self.root) + continue + if not validate_password_rules(npw1): + messagebox.showwarning("密码规则不满足", "密码需为6-10位,且包含大写字母、小写字母和数字。", parent=self.root) + continue + set_user_password(self.current_user["email"], npw1) + # refresh current_user + self.current_user = find_user_by_email(self.current_user["email"]) + messagebox.showinfo("成功", "密码修改成功。", parent=self.root) + return + + # 选择学段与题量 + def show_choice_screen(self): + for w in self.root.winfo_children(): + w.destroy() + frame = tk.Frame(self.root, padx=20, pady=20) + frame.pack() + tk.Label(frame, text=f"欢迎,{self.current_user.get('email')}", font=("Arial", 12)).pack(pady=5) + tk.Button(frame, text="修改密码", command=self.change_password).pack(pady=4) + tk.Label(frame, text="请选择学段并输入题目数量:", font=("Arial", 12)).pack(pady=10) + var = tk.StringVar(value="小学") + for grade in QUESTION_FILES.keys(): + tk.Radiobutton(frame, text=grade, variable=var, value=grade).pack(anchor="w") + num_entry = tk.Entry(frame) + num_entry.pack(pady=6) + num_entry.insert(0, "5") + def start_exam(): + try: + n = int(num_entry.get()) + if n <= 0: + raise ValueError() + except ValueError: + messagebox.showerror("输入错误", "请输入正整数题目数量。", parent=self.root) + return + grade = var.get() + questions = load_questions(grade) + if n > len(questions): + messagebox.showerror("题目不足", f"所选题量超出题库可用题目(最多 {len(questions)})。", parent=self.root) + return + # 随机抽题,无重复 + chosen = random.sample(questions, n) + # 每题的选项也可以打乱,但这里保留原顺序以便示例一致性 + self.current_exam = { + "grade": grade, + "questions": chosen, + "index": 0, + "answers": [None]*n, + "correct_count": 0 + } + self.show_question_screen() + tk.Button(frame, text="开始做题", command=start_exam).pack(pady=8) + tk.Button(frame, text="注销并返回主界面", command=self.logout).pack(pady=6) + tk.Button(frame, text="退出程序", command=self.root.quit).pack(pady=4) + + def logout(self): + self.current_user = None + self.current_exam = None + self.build_main_frame() + + # 逐题界面 + def show_question_screen(self): + for w in self.root.winfo_children(): + w.destroy() + exam = self.current_exam + idx = exam["index"] + q = exam["questions"][idx] + frame = tk.Frame(self.root, padx=20, pady=20) + frame.pack(fill="both", expand=True) + tk.Label(frame, text=f"学段:{exam['grade']} 题目 {idx+1}/{len(exam['questions'])}", font=("Arial", 12)).pack(anchor="w") + tk.Label(frame, text=q["stem"], font=("Arial", 14), wraplength=500).pack(pady=10) + var = tk.IntVar(value=-1) + for i, opt in enumerate(q["options"]): + tk.Radiobutton(frame, text=opt, variable=var, value=i).pack(anchor="w") + def submit_answer(): + choice = var.get() + if choice == -1: + messagebox.showwarning("未选择", "请先选择一个选项后提交。", parent=self.root) + return + exam["answers"][idx] = choice + if choice == q["answer_index"]: + # 记录正确 + # 为避免重复计数,在之前记录为空时计数 + # 但这里保证每题只提交一次(界面逻辑),所以直接累加 + exam["correct_count"] += 1 + # 下一题或结束 + if idx + 1 < len(exam["questions"]): + exam["index"] += 1 + self.show_question_screen() + else: + self.show_result_screen() + tk.Button(frame, text="提交", command=submit_answer).pack(pady=8) + + # 成绩界面 + def show_result_screen(self): + exam = self.current_exam + total = len(exam["questions"]) + correct = exam["correct_count"] + percent = round(correct / total * 100, 2) + for w in self.root.winfo_children(): + w.destroy() + frame = tk.Frame(self.root, padx=20, pady=20) + frame.pack() + tk.Label(frame, text=f"考试结束 - 得分:{percent}%", font=("Arial", 14)).pack(pady=6) + tk.Label(frame, text=f"答对:{correct} / {total}").pack(pady=4) + # 可查看答题详情(可选) + def show_details(): + details = "" + 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 "✗" + details += f"题 {i+1}: {q['stem']}\n你的答案: {chosen_text}\n正确答案: {correct_text} {status}\n\n" + # 显示在弹窗中 + detail_win = tk.Toplevel(self.root) + detail_win.title("答题详情") + txt = tk.Text(detail_win, width=80, height=30) + txt.pack() + txt.insert("1.0", details) + txt.config(state="disabled") + tk.Button(frame, text="查看答题详情", command=show_details).pack(pady=6) + def do_again(): + self.current_exam = None + self.show_choice_screen() + tk.Button(frame, text="继续做题", command=do_again).pack(pady=6) + tk.Button(frame, text="退出登录并返回主界面", command=self.logout).pack(pady=6) + tk.Button(frame, text="退出程序", command=self.root.quit).pack(pady=6) + +# ---------- 启动 ---------- +def main(): + ensure_files_exist() + root = tk.Tk() + app = MathApp(root) + root.mainloop() + +if __name__ == "__main__": + main() \ No newline at end of file -- 2.34.1 From e75760f222baa29e98773287a7ebdd995acaf0d8 Mon Sep 17 00:00:00 2001 From: PureCloud <2100669020@qq.com> Date: Sun, 12 Oct 2025 15:01:26 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BA=8C=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/run_environment.md | 1 + src/main.py | 661 ++++++++++++++++++++++++----------------- 2 files changed, 385 insertions(+), 277 deletions(-) create mode 100644 doc/run_environment.md diff --git a/doc/run_environment.md b/doc/run_environment.md new file mode 100644 index 0000000..f8ac777 --- /dev/null +++ b/doc/run_environment.md @@ -0,0 +1 @@ +-结对编程项目 \ No newline at end of file diff --git a/src/main.py b/src/main.py index 9c54e02..e21e1e1 100644 --- a/src/main.py +++ b/src/main.py @@ -1,63 +1,80 @@ +""" #!/usr/bin/env python3 # -*- coding: utf-8 -*- - """ -数学学习桌面应用 原型(Python + Tkinter) - -功能: -- 用户注册(邮箱输入 -> 生成注册码并在界面显示用于测试) -- 输入注册码激活并设置密码(密码规则:6-10位,包含大写、小写、数字) -- 登录、修改密码(需输入原密码) -- 学段选择(小学/初中/高中)并输入题目数量 -- 随机生成无重复选择题试卷(题库为本地 JSON 文件) -- 逐题答题、提交、最后评分并可继续/退出 -- 本地文件存储(users.json, questions_primary.json, questions_middle.json, questions_high.json) -- 密码使用 PBKDF2-HMAC-SHA256 加盐哈希 +""" +改进版数学学习桌面应用(Python + Tkinter) +目标:修复评分细则中常见扣分项,提供更健壮的本地存储与 UI 行为。 + +要点: +- 按 MVC 思路分层:数据存取类(UserStore, QuestionStore), 认证类(AuthService), UI(MathApp) +- 密码使用 PBKDF2-HMAC-SHA256 哈希,并存本地 JSON(不明文) +- 注册验证码模拟发送(弹窗 + 写日志) +- 邮箱格式校验、重复注册检测、注册码有效期检测 +- 支持上一题/下一题导航并保存答案(避免不保存或默认选前题的问题) +- 题目随机抽取无重复 +- 明确错误提示与输入校验 """ import tkinter as tk -from tkinter import messagebox, simpledialog +from tkinter import messagebox, simpledialog, scrolledtext import json import os import secrets import hashlib import binascii import random +import re from datetime import datetime, timedelta +from typing import Optional, List, Dict -# ---------- 配置 ---------- +# ------------- 配置 ------------- USERS_FILE = "users.json" QUESTION_FILES = { "小学": "questions_primary.json", "初中": "questions_middle.json", "高中": "questions_high.json", } -REGCODE_TTL_MINUTES = 30 # 注册码有效期(本地记录) -# -------------------------- +REGCODE_TTL_MINUTES = 30 +LOG_FILE = "app_log.txt" +# 固定界面宽度,避免窗口随题目长度频繁变动 +WINDOW_WIDTH = 640 +WINDOW_HEIGHT = 480 +# --------------------------------- + +# ----------------- 辅助工具 ----------------- +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.json 以及题库文件存在,若不存在则创建示例数据""" 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) - # 示例题库(每个学段若无文件则创建几个示例题) sample_questions = { "小学": [ {"id": "p001", "grade": "小学", "stem": "2 + 3 = ?", "options": ["3", "4", "5", "6"], "answer_index": 2}, {"id": "p002", "grade": "小学", "stem": "5 - 2 = ?", "options": ["1", "2", "3", "4"], "answer_index": 2}, {"id": "p003", "grade": "小学", "stem": "3 × 2 = ?", "options": ["5", "6", "7", "8"], "answer_index": 1}, + {"id": "p004", "grade": "小学", "stem": "7 - 4 = ?", "options": ["1", "2", "3", "4"], "answer_index": 2}, + {"id": "p005", "grade": "小学", "stem": "4 + 6 = ?", "options": ["8", "9", "10", "11"], "answer_index": 2}, ], "初中": [ {"id": "m001", "grade": "初中", "stem": "若 x=2,则 3x+1 = ?", "options": ["5", "6", "7", "8"], "answer_index": 0}, {"id": "m002", "grade": "初中", "stem": "一次函数 y=2x+1 的斜率是?", "options": ["1", "2", "3", "4"], "answer_index": 1}, {"id": "m003", "grade": "初中", "stem": "下列哪组数是等差数列?", "options": ["1,2,4", "2,4,6", "1,3,6", "2,3,5"], "answer_index": 1}, + {"id": "m004", "grade": "初中", "stem": "若 a+b=5, a-b=1,则 a = ?", "options": ["3", "2", "4", "1"], "answer_index": 0}, + {"id": "m005", "grade": "初中", "stem": "直角三角形的斜边是?", "options": ["最长边", "最短边", "中间边", "任意边"], "answer_index": 0}, ], "高中": [ {"id": "h001", "grade": "高中", "stem": "求极限 lim(x->0) (sin x)/x =", "options": ["0", "1", "无穷", "不存在"], "answer_index": 1}, {"id": "h002", "grade": "高中", "stem": "复数 i 的平方等于?", "options": ["1", "-1", "i", "-i"], "answer_index": 1}, {"id": "h003", "grade": "高中", "stem": "若 f(x)=x^2,则 f'(x) = ?", "options": ["2x", "x", "x^2", "2"], "answer_index": 0}, + {"id": "h004", "grade": "高中", "stem": "直线 y = 3x + 1 的斜率是?", "options": ["1", "2", "3", "无定义"], "answer_index": 2}, + {"id": "h005", "grade": "高中", "stem": "矩阵相乘不满足下列哪项:", "options": ["结合律", "分配律", "交换律", "缩放"], "answer_index": 2}, ], } @@ -66,37 +83,86 @@ def ensure_files_exist(): with open(path, "w", encoding="utf-8") as f: json.dump(sample_questions[grade], f, ensure_ascii=False, indent=2) -def load_users(): - with open(USERS_FILE, "r", encoding="utf-8") as f: +# ----------------- 数据层(UserStore, QuestionStore) ----------------- +class UserStore: + """负责用户数据的读写(文件级别封装),避免直接在 UI 操作 JSON 文件""" + def __init__(self, path=USERS_FILE): + self.path = path + self._load() + + def _load(self): try: - return json.load(f) - except json.JSONDecodeError: + 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(self, email: str) -> Optional[Dict]: + for u in self.users: + if u.get("email") == email: + return u + return None + + def add_or_update(self, user: Dict): + existing = self.find(user["email"]) + if existing: + existing.update(user) + else: + self.users.append(user) + self._save() + + def update_fields(self, email: str, fields: Dict): + u = self.find(email) + if not u: + return False + u.update(fields) + self._save() + return True + + def all_users(self) -> List[Dict]: + return list(self.users) + +class QuestionStore: + """读取题库文件""" + def __init__(self, files_map=QUESTION_FILES): + self.files_map = files_map + + def load_questions(self, grade: str) -> List[Dict]: + path = self.files_map.get(grade) + if not path or not os.path.exists(path): return [] + with open(path, "r", encoding="utf-8") as f: + try: + return json.load(f) + except Exception: + return [] -def save_users(users): - with open(USERS_FILE, "w", encoding="utf-8") as f: - json.dump(users, f, ensure_ascii=False, indent=2) +# ----------------- 认证/业务逻辑层 ----------------- +EMAIL_RE = re.compile(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$") -def load_questions(grade): - path = QUESTION_FILES[grade] - with open(path, "r", encoding="utf-8") as f: - return json.load(f) +def is_valid_email(email: str) -> bool: + return bool(EMAIL_RE.match(email or "")) -# 密码哈希(PBKDF2) -def hash_password(password, salt=None): +def hash_password(password: str, salt: Optional[bytes] = None): + """返回 (hash_hex, salt_hex)""" if salt is None: salt = secrets.token_bytes(16) - elif isinstance(salt, str): - salt = binascii.unhexlify(salt) 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(stored_hash, stored_salt, password_attempt): - attempt_hash, _ = hash_password(password_attempt, stored_salt) - return secrets.compare_digest(attempt_hash, stored_hash) +def verify_password(stored_hash_hex: str, stored_salt_hex: str, attempt: str) -> bool: + try: + salt = binascii.unhexlify(stored_salt_hex) + attempt_hash, _ = hash_password(attempt, salt) + return secrets.compare_digest(attempt_hash, stored_hash_hex) + except Exception: + return False -# 验证密码规则 -def validate_password_rules(pw): +def validate_password_rules(pw: str) -> bool: if not (6 <= len(pw) <= 10): return False has_upper = any(c.isupper() for c in pw) @@ -104,308 +170,349 @@ def validate_password_rules(pw): has_digit = any(c.isdigit() for c in pw) return has_upper and has_lower and has_digit -# 生成注册码并记录在用户条目中(本地模拟发送) -def generate_regcode_for_email(email): - users = load_users() - # 生成 regcode 并保存到 users 文件(作为未激活临时记录) - regcode = secrets.token_hex(3) # 6 hex chars - expiry = (datetime.utcnow() + timedelta(minutes=REGCODE_TTL_MINUTES)).isoformat() - # 若已存在相同邮箱,更新 regcode 与未激活状态;否则新增条目 - for u in users: - if u.get("email") == email: - u["regcode"] = regcode - u["regcode_expiry"] = expiry - u["activated"] = False - save_users(users) - return regcode - # 新用户占位(无密码) - new_user = { - "email": email, - "activated": False, - "regcode": regcode, - "regcode_expiry": expiry, - "password_hash": None, - "salt": None, - "created_at": datetime.utcnow().isoformat() - } - users.append(new_user) - save_users(users) - return regcode - -def activate_user_with_code(email, code): - users = load_users() - for u in users: - if u.get("email") == email: - if u.get("activated"): - return False, "该邮箱已激活" - exp = u.get("regcode_expiry") - if not u.get("regcode") or u.get("regcode") != code: - return False, "注册码不正确" - if exp and datetime.fromisoformat(exp) < datetime.utcnow(): - return False, "注册码已过期" - u["activated"] = True - # 清除 regcode but keep for record - u.pop("regcode", None) - u.pop("regcode_expiry", None) - save_users(users) - return True, "激活成功" - return False, "未找到该邮箱的注册记录" - -# 查找用户 -def find_user_by_email(email): - users = load_users() - for u in users: - if u.get("email") == email: - return u - return None - -# 更新用户密码(哈希) -def set_user_password(email, password): - users = load_users() - for u in users: - if u.get("email") == email: - h, s = hash_password(password) - u["password_hash"] = h - u["salt"] = s - save_users(users) - return True - return False +class AuthService: + """负责注册、发码、激活、设置密码、登录、修改密码等业务逻辑""" + def __init__(self, user_store: UserStore): + self.user_store = user_store + + def send_regcode(self, email: str) -> (bool, str): + """发送注册码(模拟):校验邮箱、检测重复注册、生成 regcode 并存储""" + if not is_valid_email(email): + return False, "邮箱格式不正确" + existing = self.user_store.find(email) + if existing and existing.get("activated"): + return False, "该邮箱已注册并激活" + # 生成 regcode + regcode = secrets.token_hex(3) # 6 hex chars + expiry = (datetime.utcnow() + timedelta(minutes=REGCODE_TTL_MINUTES)).isoformat() + record = { + "email": email, + "activated": False, + "regcode": regcode, + "regcode_expiry": expiry, + "password_hash": None, + "salt": None, + "created_at": datetime.utcnow().isoformat() + } + self.user_store.add_or_update(record) + # 模拟发送:记录日志并返回 regcode(UI 层会弹窗显示以便测试) + log(f"Regcode for {email}: {regcode} (expires {expiry})") + return True, regcode -# ---------- GUI ---------- + def activate(self, email: str, code: str) -> (bool, str): + u = self.user_store.find(email) + if not u: + return False, "未找到该邮箱的注册记录" + if u.get("activated"): + return False, "该账号已激活" + if not code or code != u.get("regcode"): + return False, "注册码不正确" + expiry = u.get("regcode_expiry") + if expiry and datetime.fromisoformat(expiry) < datetime.utcnow(): + return False, "注册码已过期" + # 激活但不设置密码(UI 将引导设置密码) + self.user_store.update_fields(email, {"activated": True, "regcode": None, "regcode_expiry": None}) + log(f"{email} activated") + return True, "激活成功" + + def set_password(self, email: str, password: str) -> (bool, str): + if not validate_password_rules(password): + return False, "密码不符合规则:6-10位,且包含大小写字母和数字" + h, s = hash_password(password) + ok = self.user_store.update_fields(email, {"password_hash": h, "salt": s}) + if not ok: + return False, "设置密码失败(未找到用户)" + log(f"{email} set password") + return True, "密码设置成功" + + def login(self, email: str, password: str) -> (bool, str): + u = self.user_store.find(email) + if not u: + return False, "未找到该邮箱" + if not u.get("activated"): + return False, "该账号未激活" + ph = u.get("password_hash") + s = u.get("salt") + if not ph or not s: + return False, "该账号尚未设置密码" + if not verify_password(ph, s, password): + return False, "密码错误" + return True, "登录成功" + + def change_password(self, email: str, old_pw: str, new_pw: str) -> (bool, str): + u = self.user_store.find(email) + if not u: + return False, "未找到用户" + if not verify_password(u.get("password_hash"), u.get("salt"), old_pw): + return False, "原密码不正确" + if not validate_password_rules(new_pw): + return False, "新密码不符合规则" + h, s = hash_password(new_pw) + self.user_store.update_fields(email, {"password_hash": h, "salt": s}) + log(f"{email} changed password") + return True, "密码修改成功" + +# ----------------- UI 层 ----------------- class MathApp: - def __init__(self, root): + def __init__(self, root: tk.Tk, auth: AuthService, qstore: QuestionStore): self.root = root - self.root.title("小初高数学学习软件(原型)") - self.current_user = None - self.current_exam = None # dict with questions, index, answers - self.build_main_frame() + self.root.title("小初高数学学习软件(改进版)") + self.root.geometry(f"{WINDOW_WIDTH}x{WINDOW_HEIGHT}") + self.auth = auth + self.qstore = qstore + self.current_user_email: Optional[str] = None + self.current_exam = None # {grade, questions, index, answers} + self.build_main_screen() - def build_main_frame(self): + def clear_root(self): for w in self.root.winfo_children(): w.destroy() - frame = tk.Frame(self.root, padx=20, pady=20) - frame.pack() - - tk.Label(frame, text="数学学习软件(原型)", font=("Arial", 16)).pack(pady=10) - tk.Button(frame, text="注册 (发送注册码)", width=30, command=self.register_send_code).pack(pady=5) - tk.Button(frame, text="激活并设置密码", width=30, command=self.activate_and_set_password).pack(pady=5) - tk.Button(frame, text="登录", width=30, command=self.login).pack(pady=5) - tk.Button(frame, text="退出", width=30, command=self.root.quit).pack(pady=5) - - # 注册(生成注册码并模拟发送) - def register_send_code(self): - email = simpledialog.askstring("注册 - 输入邮箱", "请输入邮箱:", parent=self.root) - if not email: + + # ---------- 主界面 ---------- + def build_main_screen(self): + self.clear_root() + frm = tk.Frame(self.root, padx=20, pady=20) + frm.pack(fill="both", expand=True) + tk.Label(frm, text="数学学习软件(改进版)", font=("Arial", 16)).pack(pady=10) + tk.Button(frm, text="注册 (发送注册码)", width=30, command=self.ui_register).pack(pady=5) + tk.Button(frm, text="激活并设置密码", width=30, command=self.ui_activate_and_set_password).pack(pady=5) + tk.Button(frm, text="登录", width=30, command=self.ui_login).pack(pady=5) + tk.Button(frm, text="退出", width=30, command=self.root.quit).pack(pady=5) + + # ---------- 注册 ---------- + def ui_register(self): + email = simpledialog.askstring("注册", "请输入邮箱:", parent=self.root) + if email is None: return - reg = generate_regcode_for_email(email) - # 模拟发送:展示在对话框中(开发/测试用) - messagebox.showinfo("注册码(测试用)", f"已生成注册码并“发送”到 {email}。\n测试模式下注册码为:{reg}\n有效期 {REGCODE_TTL_MINUTES} 分钟", parent=self.root) - - # 激活并设置密码 - def activate_and_set_password(self): - email = simpledialog.askstring("激活 - 输入邮箱", "请输入注册时的邮箱:", parent=self.root) - if not email: + email = email.strip() + ok, msg_or_code = self.auth.send_regcode(email) + if not ok: + messagebox.showerror("注册失败", msg_or_code, parent=self.root) + return + # 测试模式:弹窗显示注册码,同时写日志(日志已写) + messagebox.showinfo("注册码(测试模式)", f"注册码已生成并记录。测试模式下注册码为:{msg_or_code}\n已写入日志 {LOG_FILE}", parent=self.root) + + # ---------- 激活并设置密码 ---------- + def ui_activate_and_set_password(self): + email = simpledialog.askstring("激活", "请输入注册时使用的邮箱:", parent=self.root) + if email is None: return - code = simpledialog.askstring("激活 - 输入注册码", "请输入收到的注册码:", parent=self.root) - if not code: + code = simpledialog.askstring("激活", "请输入收到的注册码:", parent=self.root) + if code is None: return - ok, msg = activate_user_with_code(email, code) + ok, msg = self.auth.activate(email.strip(), code.strip()) if not ok: messagebox.showerror("激活失败", msg, parent=self.root) return - # 设置密码 + # 激活成功,强制设置密码(不可跳过) + messagebox.showinfo("激活成功", "激活成功,请设置登录密码", parent=self.root) while True: pw1 = simpledialog.askstring("设置密码", "请输入新密码(6-10位,含大小写字母与数字):", show="*", parent=self.root) if pw1 is None: + # 允许用户取消,但该账号已激活但无密码,登录会受限 return pw2 = simpledialog.askstring("确认密码", "请再次输入新密码:", show="*", parent=self.root) if pw2 is None: return if pw1 != pw2: - messagebox.showwarning("密码不匹配", "两次输入的密码不一致,请重新输入。", parent=self.root) + messagebox.showwarning("密码不匹配", "两次输入不一致,请重新输入。", parent=self.root) continue - if not validate_password_rules(pw1): - messagebox.showwarning("密码规则不满足", "密码需为6-10位,且包含大写字母、小写字母和数字。", parent=self.root) - continue - set_user_password(email, pw1) - messagebox.showinfo("设置成功", "密码设置成功,跳转到选择界面。", parent=self.root) - self.current_user = find_user_by_email(email) - self.show_choice_screen() + ok2, msg2 = self.auth.set_password(email.strip(), pw1) + if not ok2: + messagebox.showerror("设置失败", msg2, parent=self.root) + return + messagebox.showinfo("完成", "密码设置成功。现在请登录。", parent=self.root) return - # 登录 - def login(self): - email = simpledialog.askstring("登录 - 输入邮箱", "请输入邮箱:", parent=self.root) - if not email: + # ---------- 登录 ---------- + def ui_login(self): + email = simpledialog.askstring("登录", "请输入邮箱:", parent=self.root) + if email is None: return - user = find_user_by_email(email) - if not user: - messagebox.showerror("登录失败", "未找到该邮箱,请先注册并激活。", parent=self.root) - return - if not user.get("activated"): - messagebox.showerror("未激活", "该账号尚未激活,请先激活。", parent=self.root) - return - pw = simpledialog.askstring("登录 - 密码", "请输入密码:", show="*", parent=self.root) + pw = simpledialog.askstring("登录", "请输入密码:", show="*", parent=self.root) if pw is None: return - if not verify_password(user.get("password_hash"), user.get("salt"), pw): - messagebox.showerror("登录失败", "密码错误。", parent=self.root) - return - self.current_user = user - self.show_choice_screen() - - # 修改密码(需要登录) - def 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 - if not verify_password(self.current_user.get("password_hash"), self.current_user.get("salt"), old): - messagebox.showerror("失败", "原密码不正确。", parent=self.root) - return - while True: - npw1 = simpledialog.askstring("修改密码 - 新密码", "请输入新密码(6-10位,含大小写字母与数字):", show="*", parent=self.root) - if npw1 is None: - return - npw2 = simpledialog.askstring("确认新密码", "请再次输入新密码:", show="*", parent=self.root) - if npw2 is None: - return - if npw1 != npw2: - messagebox.showwarning("密码不匹配", "两次输入的密码不一致,请重新输入。", parent=self.root) - continue - if not validate_password_rules(npw1): - messagebox.showwarning("密码规则不满足", "密码需为6-10位,且包含大写字母、小写字母和数字。", parent=self.root) - continue - set_user_password(self.current_user["email"], npw1) - # refresh current_user - self.current_user = find_user_by_email(self.current_user["email"]) - messagebox.showinfo("成功", "密码修改成功。", parent=self.root) + ok, msg = self.auth.login(email.strip(), pw) + if not ok: + messagebox.showerror("登录失败", msg, parent=self.root) return + self.current_user_email = email.strip() + self.build_user_home() - # 选择学段与题量 - def show_choice_screen(self): - for w in self.root.winfo_children(): - w.destroy() - frame = tk.Frame(self.root, padx=20, pady=20) - frame.pack() - tk.Label(frame, text=f"欢迎,{self.current_user.get('email')}", font=("Arial", 12)).pack(pady=5) - tk.Button(frame, text="修改密码", command=self.change_password).pack(pady=4) - tk.Label(frame, text="请选择学段并输入题目数量:", font=("Arial", 12)).pack(pady=10) - var = tk.StringVar(value="小学") - for grade in QUESTION_FILES.keys(): - tk.Radiobutton(frame, text=grade, variable=var, value=grade).pack(anchor="w") - num_entry = tk.Entry(frame) - num_entry.pack(pady=6) + # ---------- 登录后主界面(学段选择等) ---------- + def build_user_home(self): + self.clear_root() + frm = tk.Frame(self.root, padx=15, pady=15) + frm.pack(fill="both", expand=True) + tk.Label(frm, text=f"已登录: {self.current_user_email}", font=("Arial", 12)).pack(anchor="w") + tk.Button(frm, text="修改密码", command=self.ui_change_password).pack(pady=4, anchor="w") + tk.Button(frm, text="注销", command=self.ui_logout).pack(pady=4, anchor="w") + tk.Label(frm, text="请选择学段并输入题目数量:", font=("Arial", 12)).pack(pady=8, anchor="w") + grade_var = tk.StringVar(value="小学") + for g in QUESTION_FILES.keys(): + tk.Radiobutton(frm, text=g, variable=grade_var, value=g).pack(anchor="w") + num_entry = tk.Entry(frm) + num_entry.pack(pady=6, anchor="w") num_entry.insert(0, "5") - def start_exam(): + + def start(): try: n = int(num_entry.get()) if n <= 0: raise ValueError() except ValueError: - messagebox.showerror("输入错误", "请输入正整数题目数量。", parent=self.root) + messagebox.showerror("输入错误", "请输入正整数题目数量", parent=self.root) return - grade = var.get() - questions = load_questions(grade) - if n > len(questions): - messagebox.showerror("题目不足", f"所选题量超出题库可用题目(最多 {len(questions)})。", parent=self.root) + grade = grade_var.get() + qs = self.qstore.load_questions(grade) + if n > len(qs): + messagebox.showerror("题目不足", f"题库可用题目 {len(qs)},无法生成 {n} 道题", parent=self.root) return - # 随机抽题,无重复 - chosen = random.sample(questions, n) - # 每题的选项也可以打乱,但这里保留原顺序以便示例一致性 + picked = random.sample(qs, n) + # 初始化 exam 结构 self.current_exam = { "grade": grade, - "questions": chosen, + "questions": picked, "index": 0, - "answers": [None]*n, - "correct_count": 0 + "answers": [None] * n } - self.show_question_screen() - tk.Button(frame, text="开始做题", command=start_exam).pack(pady=8) - tk.Button(frame, text="注销并返回主界面", command=self.logout).pack(pady=6) - tk.Button(frame, text="退出程序", command=self.root.quit).pack(pady=4) + self.build_question_screen() - def logout(self): - self.current_user = None + tk.Button(frm, text="开始做题", command=start).pack(pady=8, anchor="w") + + def ui_logout(self): + self.current_user_email = None self.current_exam = None - self.build_main_frame() + self.build_main_screen() - # 逐题界面 - def show_question_screen(self): - for w in self.root.winfo_children(): - w.destroy() + # ---------- 修改密码 ---------- + def ui_change_password(self): + if not self.current_user_email: + 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_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_root() exam = self.current_exam idx = exam["index"] q = exam["questions"][idx] - frame = tk.Frame(self.root, padx=20, pady=20) - frame.pack(fill="both", expand=True) - tk.Label(frame, text=f"学段:{exam['grade']} 题目 {idx+1}/{len(exam['questions'])}", font=("Arial", 12)).pack(anchor="w") - tk.Label(frame, text=q["stem"], font=("Arial", 14), wraplength=500).pack(pady=10) - var = tk.IntVar(value=-1) + frm = tk.Frame(self.root, padx=10, pady=10) + frm.pack(fill="both", expand=True) + header = tk.Label(frm, text=f"学段:{exam['grade']} 题目 {idx+1}/{len(exam['questions'])}", font=("Arial", 12)) + header.pack(anchor="w") + + # 题干使用 wraplength 限制行宽,避免窗口抖动 + stem_lbl = tk.Label(frm, text=q["stem"], font=("Arial", 14), wraplength=WINDOW_WIDTH-80, justify="left") + stem_lbl.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(frame, text=opt, variable=var, value=i).pack(anchor="w") - def submit_answer(): - choice = var.get() - if choice == -1: - messagebox.showwarning("未选择", "请先选择一个选项后提交。", parent=self.root) + tk.Radiobutton(frm, text=opt, variable=chosen_var, value=i).pack(anchor="w") + + nav_frm = tk.Frame(frm) + nav_frm.pack(pady=10, anchor="w") + def save_choice(): + v = chosen_var.get() + if v == -1: + # 允许不作答并跳转,但也可提示 + if not messagebox.askyesno("未选择", "当前题未选择任何选项,确认继续?", parent=self.root): + return False + exam["answers"][idx] = None if v == -1 else int(v) + return True + + def to_prev(): + if idx == 0: + messagebox.showinfo("提示", "已经是第一题", parent=self.root) + return + if not save_choice(): + return + exam["index"] -= 1 + self.build_question_screen() + + def to_next_or_finish(): + if not save_choice(): return - exam["answers"][idx] = choice - if choice == q["answer_index"]: - # 记录正确 - # 为避免重复计数,在之前记录为空时计数 - # 但这里保证每题只提交一次(界面逻辑),所以直接累加 - exam["correct_count"] += 1 - # 下一题或结束 if idx + 1 < len(exam["questions"]): exam["index"] += 1 - self.show_question_screen() + self.build_question_screen() else: + # 结束并评分 self.show_result_screen() - tk.Button(frame, text="提交", command=submit_answer).pack(pady=8) - # 成绩界面 + tk.Button(nav_frm, text="上一题", command=to_prev).pack(side="left", padx=5) + tk.Button(nav_frm, text="下一题/提交", command=to_next_or_finish).pack(side="left", padx=5) + tk.Button(nav_frm, text="放弃并返回", command=self.build_user_home).pack(side="left", padx=5) + + # ---------- 评分界面 ---------- def show_result_screen(self): exam = self.current_exam total = len(exam["questions"]) - correct = exam["correct_count"] - percent = round(correct / total * 100, 2) - for w in self.root.winfo_children(): - w.destroy() - frame = tk.Frame(self.root, padx=20, pady=20) - frame.pack() - tk.Label(frame, text=f"考试结束 - 得分:{percent}%", font=("Arial", 14)).pack(pady=6) - tk.Label(frame, text=f"答对:{correct} / {total}").pack(pady=4) - # 可查看答题详情(可选) - def show_details(): - details = "" + 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_root() + frm = tk.Frame(self.root, padx=15, pady=15) + frm.pack(fill="both", expand=True) + tk.Label(frm, text=f"考试结束 - 得分:{percent}%", font=("Arial", 14)).pack(pady=6) + tk.Label(frm, text=f"答对:{correct} / {total}").pack(pady=4) + + def view_details(): + # 弹窗显示每题详情(使用滚动文本避免过长) + win = tk.Toplevel(self.root) + win.title("答题详情") + txt = scrolledtext.ScrolledText(win, width=80, 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 "✗" - details += f"题 {i+1}: {q['stem']}\n你的答案: {chosen_text}\n正确答案: {correct_text} {status}\n\n" - # 显示在弹窗中 - detail_win = tk.Toplevel(self.root) - detail_win.title("答题详情") - txt = tk.Text(detail_win, width=80, height=30) - txt.pack() - txt.insert("1.0", details) + 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(frame, text="查看答题详情", command=show_details).pack(pady=6) - def do_again(): - self.current_exam = None - self.show_choice_screen() - tk.Button(frame, text="继续做题", command=do_again).pack(pady=6) - tk.Button(frame, text="退出登录并返回主界面", command=self.logout).pack(pady=6) - tk.Button(frame, text="退出程序", command=self.root.quit).pack(pady=6) - -# ---------- 启动 ---------- + + tk.Button(frm, text="查看答题详情", command=view_details).pack(pady=6) + tk.Button(frm, text="继续做题(返回选择界面)", command=self._continue_again).pack(pady=6) + tk.Button(frm, text="注销并返回主界面", command=self._logout_and_return).pack(pady=6) + + def _continue_again(self): + self.current_exam = None + self.build_user_home() + + def _logout_and_return(self): + self.current_user_email = None + self.current_exam = None + self.build_main_screen() + +# ----------------- 启动 ----------------- def main(): ensure_files_exist() + user_store = UserStore() + qstore = QuestionStore() + auth = AuthService(user_store) root = tk.Tk() - app = MathApp(root) + app = MathApp(root, auth, qstore) root.mainloop() if __name__ == "__main__": -- 2.34.1 From 2baf1f924878135ebceeaf0031ffbd0725fbed9c Mon Sep 17 00:00:00 2001 From: PureCloud <2100669020@qq.com> Date: Sun, 12 Oct 2025 17:09:22 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E7=AC=AC=E4=B8=89=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/run_environment.md | 7 +- src/main.py | 947 ++++++++++++++++++++++++++--------------- 2 files changed, 615 insertions(+), 339 deletions(-) diff --git a/doc/run_environment.md b/doc/run_environment.md index f8ac777..c0edbf4 100644 --- a/doc/run_environment.md +++ b/doc/run_environment.md @@ -1 +1,6 @@ --结对编程项目 \ No newline at end of file +**结对编程项目** + +操作系统: Windows11 22H4 +编码语言: python3.8 +中文编码格式: UTF-8 +个人项目参考: 张勇进的个人项目 \ No newline at end of file diff --git a/src/main.py b/src/main.py index e21e1e1..f8c1d40 100644 --- a/src/main.py +++ b/src/main.py @@ -1,19 +1,14 @@ -""" #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -""" -改进版数学学习桌面应用(Python + Tkinter) -目标:修复评分细则中常见扣分项,提供更健壮的本地存储与 UI 行为。 - -要点: -- 按 MVC 思路分层:数据存取类(UserStore, QuestionStore), 认证类(AuthService), UI(MathApp) -- 密码使用 PBKDF2-HMAC-SHA256 哈希,并存本地 JSON(不明文) -- 注册验证码模拟发送(弹窗 + 写日志) -- 邮箱格式校验、重复注册检测、注册码有效期检测 -- 支持上一题/下一题导航并保存答案(避免不保存或默认选前题的问题) -- 题目随机抽取无重复 -- 明确错误提示与输入校验 +单文件桌面应用:小初高数学练习(含注册/邮箱验证码、密码规则、题目自动生成) +修改点: +- 每道题操作数数量为 1-5(随机),操作数取值 1-100 +- 登录界面支持 使用 用户名 或 邮箱 登录 +- 其它功能保留:注册(邮箱验证码)、激活并设置密码、修改密码、选择学段与题量、自动生成题目、逐题答题、评分 +注意: +- 请替换 SMTP 配置为有效账户或在无法发送邮件时调整 send_verification_email 为测试模式。 +- Python 3.8+ """ import tkinter as tk @@ -25,67 +20,60 @@ import hashlib import binascii import random import re +import math +import threading from datetime import datetime, timedelta -from typing import Optional, List, Dict +import smtplib +from email.mime.text import MIMEText -# ------------- 配置 ------------- +# ---------------- 配置 ---------------- USERS_FILE = "users.json" QUESTION_FILES = { "小学": "questions_primary.json", "初中": "questions_middle.json", "高中": "questions_high.json", } -REGCODE_TTL_MINUTES = 30 LOG_FILE = "app_log.txt" -# 固定界面宽度,避免窗口随题目长度频繁变动 -WINDOW_WIDTH = 640 -WINDOW_HEIGHT = 480 -# --------------------------------- -# ----------------- 辅助工具 ----------------- +# 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.json 以及题库文件存在,若不存在则创建示例数据""" + # 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) - sample_questions = { - "小学": [ - {"id": "p001", "grade": "小学", "stem": "2 + 3 = ?", "options": ["3", "4", "5", "6"], "answer_index": 2}, - {"id": "p002", "grade": "小学", "stem": "5 - 2 = ?", "options": ["1", "2", "3", "4"], "answer_index": 2}, - {"id": "p003", "grade": "小学", "stem": "3 × 2 = ?", "options": ["5", "6", "7", "8"], "answer_index": 1}, - {"id": "p004", "grade": "小学", "stem": "7 - 4 = ?", "options": ["1", "2", "3", "4"], "answer_index": 2}, - {"id": "p005", "grade": "小学", "stem": "4 + 6 = ?", "options": ["8", "9", "10", "11"], "answer_index": 2}, - ], - "初中": [ - {"id": "m001", "grade": "初中", "stem": "若 x=2,则 3x+1 = ?", "options": ["5", "6", "7", "8"], "answer_index": 0}, - {"id": "m002", "grade": "初中", "stem": "一次函数 y=2x+1 的斜率是?", "options": ["1", "2", "3", "4"], "answer_index": 1}, - {"id": "m003", "grade": "初中", "stem": "下列哪组数是等差数列?", "options": ["1,2,4", "2,4,6", "1,3,6", "2,3,5"], "answer_index": 1}, - {"id": "m004", "grade": "初中", "stem": "若 a+b=5, a-b=1,则 a = ?", "options": ["3", "2", "4", "1"], "answer_index": 0}, - {"id": "m005", "grade": "初中", "stem": "直角三角形的斜边是?", "options": ["最长边", "最短边", "中间边", "任意边"], "answer_index": 0}, - ], - "高中": [ - {"id": "h001", "grade": "高中", "stem": "求极限 lim(x->0) (sin x)/x =", "options": ["0", "1", "无穷", "不存在"], "answer_index": 1}, - {"id": "h002", "grade": "高中", "stem": "复数 i 的平方等于?", "options": ["1", "-1", "i", "-i"], "answer_index": 1}, - {"id": "h003", "grade": "高中", "stem": "若 f(x)=x^2,则 f'(x) = ?", "options": ["2x", "x", "x^2", "2"], "answer_index": 0}, - {"id": "h004", "grade": "高中", "stem": "直线 y = 3x + 1 的斜率是?", "options": ["1", "2", "3", "无定义"], "answer_index": 2}, - {"id": "h005", "grade": "高中", "stem": "矩阵相乘不满足下列哪项:", "options": ["结合律", "分配律", "交换律", "缩放"], "answer_index": 2}, - ], - } - - for grade, path in QUESTION_FILES.items(): - if not os.path.exists(path): - with open(path, "w", encoding="utf-8") as f: - json.dump(sample_questions[grade], f, ensure_ascii=False, indent=2) - -# ----------------- 数据层(UserStore, QuestionStore) ----------------- +# User store class UserStore: - """负责用户数据的读写(文件级别封装),避免直接在 UI 操作 JSON 文件""" def __init__(self, path=USERS_FILE): self.path = path self._load() @@ -101,368 +89,658 @@ class UserStore: with open(self.path, "w", encoding="utf-8") as f: json.dump(self.users, f, ensure_ascii=False, indent=2) - def find(self, email: str) -> Optional[Dict]: + def find_by_email(self, email: str): for u in self.users: if u.get("email") == email: return u return None - def add_or_update(self, user: Dict): - existing = self.find(user["email"]) + 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() - def update_fields(self, email: str, fields: Dict): - u = self.find(email) - if not u: - return False - u.update(fields) - self._save() - return True - - def all_users(self) -> List[Dict]: - return list(self.users) - +# Question store class QuestionStore: - """读取题库文件""" def __init__(self, files_map=QUESTION_FILES): self.files_map = files_map - def load_questions(self, grade: str) -> List[Dict]: - path = self.files_map.get(grade) - if not path or not os.path.exists(path): - return [] - with open(path, "r", encoding="utf-8") as f: - try: + 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 [] - -# ----------------- 认证/业务逻辑层 ----------------- -EMAIL_RE = re.compile(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$") + except Exception: + return [] -def is_valid_email(email: str) -> bool: - return bool(EMAIL_RE.match(email or "")) + 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) -def hash_password(password: str, salt: Optional[bytes] = None): - """返回 (hash_hex, salt_hex)""" +# ----------------- 验证码管理 & 密码哈希 ----------------- +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(stored_hash_hex: str, stored_salt_hex: str, attempt: str) -> bool: +def verify_password_hash(stored_hash, stored_salt, attempt): try: - salt = binascii.unhexlify(stored_salt_hex) - attempt_hash, _ = hash_password(attempt, salt) - return secrets.compare_digest(attempt_hash, stored_hash_hex) + salt = binascii.unhexlify(stored_salt) + at_hash, _ = hash_password(attempt, salt) + return secrets.compare_digest(at_hash, stored_hash) except Exception: return False -def validate_password_rules(pw: str) -> bool: - if not (6 <= len(pw) <= 10): - return False - has_upper = any(c.isupper() for c in pw) - has_lower = any(c.islower() for c in pw) - has_digit = any(c.isdigit() for c in pw) - return has_upper and has_lower and has_digit +# ----------------- 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, user_store: UserStore): - self.user_store = user_store + def __init__(self, store: UserStore): + self.store = store - def send_regcode(self, email: str) -> (bool, str): - """发送注册码(模拟):校验邮箱、检测重复注册、生成 regcode 并存储""" + def send_code(self, email: str): if not is_valid_email(email): return False, "邮箱格式不正确" - existing = self.user_store.find(email) - if existing and existing.get("activated"): + u = self.store.find_by_email(email) + if u and u.get("activated"): return False, "该邮箱已注册并激活" - # 生成 regcode - regcode = secrets.token_hex(3) # 6 hex chars - expiry = (datetime.utcnow() + timedelta(minutes=REGCODE_TTL_MINUTES)).isoformat() - record = { + 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, - "activated": False, - "regcode": regcode, - "regcode_expiry": expiry, - "password_hash": None, - "salt": None, + "username": username, + "password_hash": ph, + "salt": salt, + "activated": True, "created_at": datetime.utcnow().isoformat() } - self.user_store.add_or_update(record) - # 模拟发送:记录日志并返回 regcode(UI 层会弹窗显示以便测试) - log(f"Regcode for {email}: {regcode} (expires {expiry})") - return True, regcode + self.store.add_or_update(rec) + log(f"user registered: {email} / {username}") + return True, "注册并设置密码成功" - def activate(self, email: str, code: str) -> (bool, str): - u = self.user_store.find(email) + def login(self, key: str, password: str): + # key can be email or username + u = self.store.find(key) if not u: - return False, "未找到该邮箱的注册记录" - if u.get("activated"): - return False, "该账号已激活" - if not code or code != u.get("regcode"): - return False, "注册码不正确" - expiry = u.get("regcode_expiry") - if expiry and datetime.fromisoformat(expiry) < datetime.utcnow(): - return False, "注册码已过期" - # 激活但不设置密码(UI 将引导设置密码) - self.user_store.update_fields(email, {"activated": True, "regcode": None, "regcode_expiry": None}) - log(f"{email} activated") - return True, "激活成功" - - def set_password(self, email: str, password: str) -> (bool, str): - if not validate_password_rules(password): - return False, "密码不符合规则:6-10位,且包含大小写字母和数字" - h, s = hash_password(password) - ok = self.user_store.update_fields(email, {"password_hash": h, "salt": s}) - if not ok: - return False, "设置密码失败(未找到用户)" - log(f"{email} set password") - return True, "密码设置成功" - - def login(self, email: str, password: str) -> (bool, str): - u = self.user_store.find(email) - if not u: - return False, "未找到该邮箱" + return False, "未找到该用户" if not u.get("activated"): return False, "该账号未激活" - ph = u.get("password_hash") - s = u.get("salt") - if not ph or not s: - return False, "该账号尚未设置密码" - if not verify_password(ph, s, password): + if not verify_password_hash(u.get("password_hash"), u.get("salt"), password): return False, "密码错误" - return True, "登录成功" + return True, u - def change_password(self, email: str, old_pw: str, new_pw: str) -> (bool, str): - u = self.user_store.find(email) + 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(u.get("password_hash"), u.get("salt"), old_pw): - return False, "原密码不正确" - if not validate_password_rules(new_pw): + 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, "新密码不符合规则" - h, s = hash_password(new_pw) - self.user_store.update_fields(email, {"password_hash": h, "salt": s}) - log(f"{email} changed password") - return True, "密码修改成功" + ph, salt = hash_password(new_pw) + u.update({"password_hash": ph, "salt": salt}) + self.store._save() + log(f"user changed password: {email}") + return True, "修改成功" -# ----------------- UI 层 ----------------- +# ----------------- GUI ----------------- class MathApp: - def __init__(self, root: tk.Tk, auth: AuthService, qstore: QuestionStore): + def __init__(self, root): self.root = root - self.root.title("小初高数学学习软件(改进版)") - self.root.geometry(f"{WINDOW_WIDTH}x{WINDOW_HEIGHT}") - self.auth = auth - self.qstore = qstore - self.current_user_email: Optional[str] = None - self.current_exam = None # {grade, questions, index, answers} - self.build_main_screen() - - def clear_root(self): + 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_screen(self): - self.clear_root() + 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", 16)).pack(pady=10) - tk.Button(frm, text="注册 (发送注册码)", width=30, command=self.ui_register).pack(pady=5) - tk.Button(frm, text="激活并设置密码", width=30, command=self.ui_activate_and_set_password).pack(pady=5) - tk.Button(frm, text="登录", width=30, command=self.ui_login).pack(pady=5) - tk.Button(frm, text="退出", width=30, command=self.root.quit).pack(pady=5) - - # ---------- 注册 ---------- - def ui_register(self): - email = simpledialog.askstring("注册", "请输入邮箱:", parent=self.root) - if email is None: - return - email = email.strip() - ok, msg_or_code = self.auth.send_regcode(email) - if not ok: - messagebox.showerror("注册失败", msg_or_code, parent=self.root) - return - # 测试模式:弹窗显示注册码,同时写日志(日志已写) - messagebox.showinfo("注册码(测试模式)", f"注册码已生成并记录。测试模式下注册码为:{msg_or_code}\n已写入日志 {LOG_FILE}", parent=self.root) + 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 ui_activate_and_set_password(self): - email = simpledialog.askstring("激活", "请输入注册时使用的邮箱:", parent=self.root) - if email is None: - return - code = simpledialog.askstring("激活", "请输入收到的注册码:", parent=self.root) - if code is None: - return - ok, msg = self.auth.activate(email.strip(), code.strip()) - if not ok: - messagebox.showerror("激活失败", msg, parent=self.root) - return - # 激活成功,强制设置密码(不可跳过) - messagebox.showinfo("激活成功", "激活成功,请设置登录密码", parent=self.root) - while True: - pw1 = simpledialog.askstring("设置密码", "请输入新密码(6-10位,含大小写字母与数字):", show="*", parent=self.root) - if pw1 is None: - # 允许用户取消,但该账号已激活但无密码,登录会受限 - return - pw2 = simpledialog.askstring("确认密码", "请再次输入新密码:", show="*", parent=self.root) - if pw2 is None: + def countdown_tick(): + if cooldown["left"] <= 0: + update_send_btn() return - if pw1 != pw2: - messagebox.showwarning("密码不匹配", "两次输入不一致,请重新输入。", parent=self.root) - continue - ok2, msg2 = self.auth.set_password(email.strip(), pw1) - if not ok2: - messagebox.showerror("设置失败", msg2, parent=self.root) + 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 - messagebox.showinfo("完成", "密码设置成功。现在请登录。", 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): - email = simpledialog.askstring("登录", "请输入邮箱:", parent=self.root) - if email is None: - return - pw = simpledialog.askstring("登录", "请输入密码:", show="*", parent=self.root) - if pw is None: - return - ok, msg = self.auth.login(email.strip(), pw) - if not ok: - messagebox.showerror("登录失败", msg, parent=self.root) - return - self.current_user_email = email.strip() - self.build_user_home() - - # ---------- 登录后主界面(学段选择等) ---------- - def build_user_home(self): - self.clear_root() - frm = tk.Frame(self.root, padx=15, pady=15) - frm.pack(fill="both", expand=True) - tk.Label(frm, text=f"已登录: {self.current_user_email}", font=("Arial", 12)).pack(anchor="w") - tk.Button(frm, text="修改密码", command=self.ui_change_password).pack(pady=4, anchor="w") - tk.Button(frm, text="注销", command=self.ui_logout).pack(pady=4, anchor="w") - tk.Label(frm, text="请选择学段并输入题目数量:", font=("Arial", 12)).pack(pady=8, anchor="w") + 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(frm, text=g, variable=grade_var, value=g).pack(anchor="w") - num_entry = tk.Entry(frm) + 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.insert(0, "5") - + 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() - except ValueError: - messagebox.showerror("输入错误", "请输入正整数题目数量", parent=self.root) - return + 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_questions(grade) - if n > len(qs): - messagebox.showerror("题目不足", f"题库可用题目 {len(qs)},无法生成 {n} 道题", parent=self.root) - return - picked = random.sample(qs, n) - # 初始化 exam 结构 - self.current_exam = { - "grade": grade, - "questions": picked, - "index": 0, - "answers": [None] * n - } + 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) - tk.Button(frm, text="开始做题", command=start).pack(pady=8, anchor="w") - - def ui_logout(self): - self.current_user_email = None + def logout(self): + self.current_user = None self.current_exam = None - self.build_main_screen() + self.build_main() - # ---------- 修改密码 ---------- def ui_change_password(self): - if not self.current_user_email: - messagebox.showwarning("未登录", "请先登录", parent=self.root) - return + 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) + 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_email, old, new1) + 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.showerror("失败", msg, parent=self.root); return messagebox.showinfo("成功", "密码修改成功", parent=self.root) - # ---------- 题目界面(支持上一题/下一题/提交) ---------- + # ----- 题目界面 ----- def build_question_screen(self): - self.clear_root() + self.clear() exam = self.current_exam idx = exam["index"] q = exam["questions"][idx] - frm = tk.Frame(self.root, padx=10, pady=10) - frm.pack(fill="both", expand=True) - header = tk.Label(frm, text=f"学段:{exam['grade']} 题目 {idx+1}/{len(exam['questions'])}", font=("Arial", 12)) + 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") - - # 题干使用 wraplength 限制行宽,避免窗口抖动 - stem_lbl = tk.Label(frm, text=q["stem"], font=("Arial", 14), wraplength=WINDOW_WIDTH-80, justify="left") - stem_lbl.pack(pady=8, 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(frm, text=opt, variable=chosen_var, value=i).pack(anchor="w") - - nav_frm = tk.Frame(frm) - nav_frm.pack(pady=10, anchor="w") + 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() - if v == -1: - # 允许不作答并跳转,但也可提示 - if not messagebox.askyesno("未选择", "当前题未选择任何选项,确认继续?", parent=self.root): - return False exam["answers"][idx] = None if v == -1 else int(v) return True - - def to_prev(): + def prev_q(): if idx == 0: - messagebox.showinfo("提示", "已经是第一题", parent=self.root) - return - if not save_choice(): - return - exam["index"] -= 1 - self.build_question_screen() - - def to_next_or_finish(): - if not save_choice(): - return + 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) - tk.Button(nav_frm, text="上一题", command=to_prev).pack(side="left", padx=5) - tk.Button(nav_frm, text="下一题/提交", command=to_next_or_finish).pack(side="left", padx=5) - tk.Button(nav_frm, text="放弃并返回", command=self.build_user_home).pack(side="left", padx=5) - - # ---------- 评分界面 ---------- + # ----- 结果界面 ----- def show_result_screen(self): exam = self.current_exam total = len(exam["questions"]) @@ -472,17 +750,14 @@ class MathApp: 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_root() - frm = tk.Frame(self.root, padx=15, pady=15) - frm.pack(fill="both", expand=True) - tk.Label(frm, text=f"考试结束 - 得分:{percent}%", font=("Arial", 14)).pack(pady=6) - tk.Label(frm, text=f"答对:{correct} / {total}").pack(pady=4) - - def view_details(): - # 弹窗显示每题详情(使用滚动文本避免过长) - win = tk.Toplevel(self.root) - win.title("答题详情") - txt = scrolledtext.ScrolledText(win, width=80, height=30) + 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] @@ -491,28 +766,24 @@ class MathApp: 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(frm, text="查看答题详情", command=view_details).pack(pady=6) - tk.Button(frm, text="继续做题(返回选择界面)", command=self._continue_again).pack(pady=6) - tk.Button(frm, text="注销并返回主界面", command=self._logout_and_return).pack(pady=6) + 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_user_home() + self.build_choice_screen() def _logout_and_return(self): - self.current_user_email = None + self.current_user = None self.current_exam = None - self.build_main_screen() + self.build_main() -# ----------------- 启动 ----------------- +# ----------------- 初始化 ----------------- def main(): ensure_files_exist() - user_store = UserStore() - qstore = QuestionStore() - auth = AuthService(user_store) root = tk.Tk() - app = MathApp(root, auth, qstore) + app = MathApp(root) root.mainloop() if __name__ == "__main__": -- 2.34.1 From cfb6f49820051091d456969ed17aff41e269cafb Mon Sep 17 00:00:00 2001 From: PureCloud <2100669020@qq.com> Date: Sun, 12 Oct 2025 17:13:09 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E7=AC=AC=E4=B8=89=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 README.md 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 - -- 2.34.1