|
|
|
|
@ -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()
|