|
|
|
|
@ -1,14 +1,14 @@
|
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
"""
|
|
|
|
|
单文件桌面应用:小初高数学练习(含注册/邮箱验证码、密码规则、题目自动生成)
|
|
|
|
|
修改点:
|
|
|
|
|
- 每道题操作数数量为 1-5(随机),操作数取值 1-100
|
|
|
|
|
- 登录界面支持 使用 用户名 或 邮箱 登录
|
|
|
|
|
- 其它功能保留:注册(邮箱验证码)、激活并设置密码、修改密码、选择学段与题量、自动生成题目、逐题答题、评分
|
|
|
|
|
注意:
|
|
|
|
|
- 请替换 SMTP 配置为有效账户或在无法发送邮件时调整 send_verification_email 为测试模式。
|
|
|
|
|
- Python 3.8+
|
|
|
|
|
单文件桌面应用:小初高数学练习(Tkinter)
|
|
|
|
|
主要修改(按用户要求):
|
|
|
|
|
- 每道题中出现的数字不超过 5 个(操作数数目为 1~5)
|
|
|
|
|
- 小学题不出现负数(操作数为正,生成表达式尽量避免负结果)
|
|
|
|
|
- 初中题:题目显示使用根号符号 √(...),幂显示为 x^2(内部仍用 sqrt()/** 计算)
|
|
|
|
|
- 高中题:题目中不出现 pi 字符串,三角函数以角度表示(如 sin(30°)),内部计算将角度转为弧度
|
|
|
|
|
其它功能保留:注册(邮箱验证码)、用户名/邮箱登录、修改密码、题库自动生成与保存、PBKDF2 密码哈希等。
|
|
|
|
|
注意:替换 SMTP 配置为可用的邮箱/授权码或改为测试模式。
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import tkinter as tk
|
|
|
|
|
@ -51,7 +51,7 @@ WINDOW_H = 620
|
|
|
|
|
|
|
|
|
|
# 题目生成参数
|
|
|
|
|
MIN_OPERANDS = 1
|
|
|
|
|
MAX_OPERANDS = 5
|
|
|
|
|
MAX_OPERANDS = 5 # 至多 5 个操作数(数字)
|
|
|
|
|
OPERAND_MIN = 1
|
|
|
|
|
OPERAND_MAX = 100
|
|
|
|
|
# --------------------------------------
|
|
|
|
|
@ -62,11 +62,9 @@ def log(msg: str):
|
|
|
|
|
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:
|
|
|
|
|
@ -102,7 +100,6 @@ class UserStore:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def find(self, key: str):
|
|
|
|
|
# try email first, then username
|
|
|
|
|
u = self.find_by_email(key)
|
|
|
|
|
if u:
|
|
|
|
|
return u
|
|
|
|
|
@ -158,7 +155,6 @@ class VerificationManager:
|
|
|
|
|
return False, "验证码已过期"
|
|
|
|
|
if not secrets.compare_digest(real, code):
|
|
|
|
|
return False, "验证码不匹配"
|
|
|
|
|
# 删除以防二次使用
|
|
|
|
|
del self.store[email]
|
|
|
|
|
return True, "验证成功"
|
|
|
|
|
|
|
|
|
|
@ -180,7 +176,6 @@ def verify_password_hash(stored_hash, stored_salt, attempt):
|
|
|
|
|
|
|
|
|
|
# ----------------- 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
|
|
|
|
|
@ -207,21 +202,20 @@ def send_verification_email(target_email: str, code: str):
|
|
|
|
|
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])
|
|
|
|
|
while len(opts) < 4 and attempts < 200:
|
|
|
|
|
delta = random.choice([-10, -6, -4, -3, -2, -1, 1, 2, 3, 4, 5, 8, 10])
|
|
|
|
|
candidate = corr + delta
|
|
|
|
|
if str(candidate) not in opts:
|
|
|
|
|
if candidate >= -9999 and str(candidate) not in opts:
|
|
|
|
|
opts.append(str(candidate))
|
|
|
|
|
attempts += 1
|
|
|
|
|
while len(opts) < 4:
|
|
|
|
|
@ -229,7 +223,6 @@ def mk_options_from_number(correct_val, kind="int"):
|
|
|
|
|
random.shuffle(opts)
|
|
|
|
|
return opts, opts.index(str(corr))
|
|
|
|
|
else:
|
|
|
|
|
# float
|
|
|
|
|
corr = float(correct_val)
|
|
|
|
|
corr_s = f"{corr:.3f}"
|
|
|
|
|
opts = [corr_s]
|
|
|
|
|
@ -246,180 +239,206 @@ def mk_options_from_number(correct_val, kind="int"):
|
|
|
|
|
random.shuffle(opts)
|
|
|
|
|
return opts, opts.index(corr_s)
|
|
|
|
|
|
|
|
|
|
# 安全评估:表达式由程序生成,使用受限 eval 环境评估
|
|
|
|
|
# 受限 eval 环境用于计算(内部使用 math 的函数)
|
|
|
|
|
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 为 Python 可 eval 的表达式(外层 display 与内部计算分开)
|
|
|
|
|
expr = expr.replace("^", "**")
|
|
|
|
|
return eval(expr, SAFE_EVAL_ENV, {})
|
|
|
|
|
|
|
|
|
|
# 小学题:1~5 个操作数,运算符 + - * /,可能带一对括号
|
|
|
|
|
# ----------------- 题目生成(符合新要求) -----------------
|
|
|
|
|
# 小学: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)
|
|
|
|
|
# 生成 1~5 个正整数操作数
|
|
|
|
|
n = random.randint(MIN_OPERANDS, MAX_OPERANDS)
|
|
|
|
|
nums = [random.randint(OPERAND_MIN, OPERAND_MAX) for _ in range(n)]
|
|
|
|
|
# 选择运算符(n-1 个)
|
|
|
|
|
ops_list = []
|
|
|
|
|
for _ in range(max(0, n - 1)):
|
|
|
|
|
ops_list.append(random.choice(['+', '-', '*', '/']))
|
|
|
|
|
# 为避免出现负数结果,策略:
|
|
|
|
|
# - 对于减法,确保左边操作数大于或等于右边(在构建顺序时调整)
|
|
|
|
|
# - 对于除法,尽量设置可整除关系(将右边替换为因数)
|
|
|
|
|
# 构造表达式逐步调整
|
|
|
|
|
def build_expr(nums, ops):
|
|
|
|
|
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):
|
|
|
|
|
for i, a in enumerate(nums):
|
|
|
|
|
parts.append(str(a))
|
|
|
|
|
if i < len(ops):
|
|
|
|
|
parts.append(ops[i])
|
|
|
|
|
return "".join(parts)
|
|
|
|
|
|
|
|
|
|
# 调整减法与除法以尽量得到非负整数
|
|
|
|
|
for _ in range(100):
|
|
|
|
|
nums_copy = nums[:]
|
|
|
|
|
ops_copy = ops_list[:]
|
|
|
|
|
# adjust for division: make divisor divide dividend when possible
|
|
|
|
|
for i, op in enumerate(ops_copy):
|
|
|
|
|
if op == '/':
|
|
|
|
|
a = nums_copy[i]
|
|
|
|
|
b = nums_copy[i+1]
|
|
|
|
|
if b == 0:
|
|
|
|
|
nums_copy[i+1] = random.randint(OPERAND_MIN, OPERAND_MAX)
|
|
|
|
|
# try to set b as a divisor of a by replacing b
|
|
|
|
|
divisors = [d for d in range(1, OPERAND_MAX+1) if a % d == 0]
|
|
|
|
|
if divisors:
|
|
|
|
|
nums_copy[i+1] = random.choice(divisors)
|
|
|
|
|
else:
|
|
|
|
|
# fallback: change op to + to avoid fractions
|
|
|
|
|
ops_copy[i] = random.choice(['+', '-','*'])
|
|
|
|
|
# adjust for subtraction to avoid negatives: ensure left >= right when directly subtracting
|
|
|
|
|
for i, op in enumerate(ops_copy):
|
|
|
|
|
if op == '-':
|
|
|
|
|
if nums_copy[i] < nums_copy[i+1]:
|
|
|
|
|
# swap operands to attempt non-negative
|
|
|
|
|
nums_copy[i], nums_copy[i+1] = nums_copy[i+1], nums_copy[i]
|
|
|
|
|
expr = build_expr(nums_copy, ops_copy)
|
|
|
|
|
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
|
|
|
|
|
val = safe_eval(expr)
|
|
|
|
|
# if float, round to int
|
|
|
|
|
if isinstance(val, float):
|
|
|
|
|
if abs(val - round(val)) < 1e-9:
|
|
|
|
|
val = int(round(val))
|
|
|
|
|
else:
|
|
|
|
|
val = int(round(val))
|
|
|
|
|
# ensure non-negative (小学要求)
|
|
|
|
|
if isinstance(val, (int, float)) and val >= 0:
|
|
|
|
|
nums = nums_copy
|
|
|
|
|
ops_list = ops_copy
|
|
|
|
|
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:
|
|
|
|
|
# regenerate small changes and retry
|
|
|
|
|
nums = [random.randint(OPERAND_MIN, OPERAND_MAX) for _ in range(n)]
|
|
|
|
|
ops_list = [random.choice(['+', '-', '*', '/']) for _ in range(max(0, n - 1))]
|
|
|
|
|
continue
|
|
|
|
|
# final expression
|
|
|
|
|
expr_display = build_expr(nums, ops_list)
|
|
|
|
|
# Evaluate final value
|
|
|
|
|
try:
|
|
|
|
|
val = safe_eval(expr_display)
|
|
|
|
|
if isinstance(val, float):
|
|
|
|
|
if abs(val - round(val)) < 1e-9:
|
|
|
|
|
val = int(round(val))
|
|
|
|
|
else:
|
|
|
|
|
val = int(round(val))
|
|
|
|
|
except Exception:
|
|
|
|
|
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(...)
|
|
|
|
|
# Ensure no negative options for primary (replace negatives)
|
|
|
|
|
opts = [o if not (o.startswith("-")) else str(abs(int(o))) for o in opts]
|
|
|
|
|
# ensure answer_index updated if changed due to replacement
|
|
|
|
|
if str(val) in opts:
|
|
|
|
|
answer_index = opts.index(str(val))
|
|
|
|
|
else:
|
|
|
|
|
# if val changed by making negative->positive, recompute index
|
|
|
|
|
answer_index = 0
|
|
|
|
|
for i,o in enumerate(opts):
|
|
|
|
|
if int(o) == int(val):
|
|
|
|
|
answer_index = i
|
|
|
|
|
break
|
|
|
|
|
return {"id": gen_id("pri"), "grade": "小学", "stem": f"{expr_display} = ?", "options": opts, "answer_index": answer_index}
|
|
|
|
|
|
|
|
|
|
# 初中:1~5 个操作数,题目显示中用 √(...)表示根号,幂显示为 x^2
|
|
|
|
|
def generate_middle_question():
|
|
|
|
|
n = random.randint(MIN_OPERANDS, MAX_OPERANDS)
|
|
|
|
|
nums = [random.randint(OPERAND_MIN, OPERAND_MAX) for _ in range(n)]
|
|
|
|
|
ops_list = [random.choice(['+', '-', '*', '/']) for _ in range(max(0, n - 1))]
|
|
|
|
|
# ensure at least one sqrt or square
|
|
|
|
|
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)
|
|
|
|
|
idx = random.randint(0, n - 1)
|
|
|
|
|
expr_parts = []
|
|
|
|
|
# prepare a copy for computation (python expression)
|
|
|
|
|
comp_nums = [str(x) for x in nums]
|
|
|
|
|
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]})"
|
|
|
|
|
# prefer perfect square inside sometimes
|
|
|
|
|
inner_base = random.randint(1, 50)
|
|
|
|
|
inner = inner_base * inner_base if random.choice([True, False]) else nums[idx]*nums[idx]
|
|
|
|
|
comp_nums[idx] = f"sqrt({inner})"
|
|
|
|
|
display_num = f"√({inner})"
|
|
|
|
|
else:
|
|
|
|
|
nums[idx] = f"({nums[idx]}**2)"
|
|
|
|
|
parts = []
|
|
|
|
|
for i in range(n_ops):
|
|
|
|
|
parts.append(str(nums[i]))
|
|
|
|
|
comp_nums[idx] = f"({nums[idx]}**2)"
|
|
|
|
|
display_num = f"{nums[idx]}^2"
|
|
|
|
|
# build display expression and compute expression separately
|
|
|
|
|
display_parts = []
|
|
|
|
|
comp_parts = []
|
|
|
|
|
for i in range(n):
|
|
|
|
|
display_parts.append(display_num if i == idx else str(nums[i]))
|
|
|
|
|
comp_parts.append(comp_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
|
|
|
|
|
display_parts.append(ops_list[i])
|
|
|
|
|
comp_parts.append(ops_list[i])
|
|
|
|
|
display_expr = "".join(display_parts)
|
|
|
|
|
comp_expr = "".join(comp_parts)
|
|
|
|
|
# evaluate
|
|
|
|
|
try:
|
|
|
|
|
val = safe_eval(comp_expr)
|
|
|
|
|
except Exception:
|
|
|
|
|
# fallback to simpler expression
|
|
|
|
|
comp_expr = "1+1"
|
|
|
|
|
display_expr = "1+1"
|
|
|
|
|
val = 2
|
|
|
|
|
# format options
|
|
|
|
|
if isinstance(val, float) and abs(val - round(val)) > 1e-9:
|
|
|
|
|
opts, idx = mk_options_from_number(val, kind="float")
|
|
|
|
|
opts, idx_ans = 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}
|
|
|
|
|
opts, idx_ans = mk_options_from_number(val, kind="int")
|
|
|
|
|
return {"id": gen_id("mid"), "grade": "初中", "stem": f"{display_expr} = ?", "options": opts, "answer_index": idx_ans}
|
|
|
|
|
|
|
|
|
|
# 高中题:至少包含 sin/cos/tan(使用角度),并可包含 1~5 个操作数
|
|
|
|
|
# 高中:1~5 个操作数,三角函数以角度表示(如 sin(30°)),题目显示不含 pi
|
|
|
|
|
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)
|
|
|
|
|
n = random.randint(MIN_OPERANDS, MAX_OPERANDS)
|
|
|
|
|
nums = [random.randint(OPERAND_MIN, OPERAND_MAX) for _ in range(n)]
|
|
|
|
|
ops_list = [random.choice(['+', '-', '*', '/']) for _ in range(max(0, n - 1))]
|
|
|
|
|
idx = random.randint(0, n - 1)
|
|
|
|
|
# choose angle in degrees (常见角优先)
|
|
|
|
|
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]))
|
|
|
|
|
# comp expression uses radians: func(pi*angle/180)
|
|
|
|
|
comp_nums = [str(x) for x in nums]
|
|
|
|
|
comp_nums[idx] = f"{func}(pi*{angle}/180)"
|
|
|
|
|
display_nums = [str(x) for x in nums]
|
|
|
|
|
display_nums[idx] = f"{func}({angle}°)" # show degrees, no pi
|
|
|
|
|
# build expressions
|
|
|
|
|
comp_parts = []
|
|
|
|
|
display_parts = []
|
|
|
|
|
for i in range(n):
|
|
|
|
|
comp_parts.append(comp_nums[i])
|
|
|
|
|
display_parts.append(display_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:
|
|
|
|
|
comp_parts.append(ops_list[i])
|
|
|
|
|
display_parts.append(ops_list[i])
|
|
|
|
|
comp_expr = "".join(comp_parts)
|
|
|
|
|
display_expr = "".join(display_parts)
|
|
|
|
|
# evaluate
|
|
|
|
|
try:
|
|
|
|
|
val = safe_eval(comp_expr)
|
|
|
|
|
except Exception:
|
|
|
|
|
# fallback
|
|
|
|
|
comp_expr = "0.0"
|
|
|
|
|
display_expr = f"{func}({angle}°)"
|
|
|
|
|
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}
|
|
|
|
|
# options as float (3 decimals)
|
|
|
|
|
opts, idx_ans = mk_options_from_number(val, kind="float")
|
|
|
|
|
return {"id": gen_id("high"), "grade": "高中", "stem": f"{display_expr} = ? (保留3位小数)", "options": opts, "answer_index": idx_ans}
|
|
|
|
|
|
|
|
|
|
# Ensure enough questions in file; auto-generate and append if insufficient
|
|
|
|
|
# auto-generate to ensure enough questions
|
|
|
|
|
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 = []
|
|
|
|
|
new = []
|
|
|
|
|
for _ in range(to_gen):
|
|
|
|
|
if grade == "小学":
|
|
|
|
|
gen.append(generate_primary_question())
|
|
|
|
|
new.append(generate_primary_question())
|
|
|
|
|
elif grade == "初中":
|
|
|
|
|
gen.append(generate_middle_question())
|
|
|
|
|
new.append(generate_middle_question())
|
|
|
|
|
else:
|
|
|
|
|
gen.append(generate_high_question())
|
|
|
|
|
qs.extend(gen)
|
|
|
|
|
new.append(generate_high_question())
|
|
|
|
|
qs.extend(new)
|
|
|
|
|
qstore.save(grade, qs)
|
|
|
|
|
log(f"Auto-generated {len(gen)} questions for {grade}")
|
|
|
|
|
log(f"Auto-generated {len(new)} questions for {grade}")
|
|
|
|
|
return qs
|
|
|
|
|
|
|
|
|
|
# ----------------- 服务逻辑(注册/激活/登录/改密) -----------------
|
|
|
|
|
@ -442,7 +461,6 @@ class AuthService:
|
|
|
|
|
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, "用户名已被占用"
|
|
|
|
|
@ -465,7 +483,6 @@ class AuthService:
|
|
|
|
|
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, "未找到该用户"
|
|
|
|
|
@ -499,7 +516,7 @@ class MathApp:
|
|
|
|
|
self.user_store = UserStore()
|
|
|
|
|
self.qstore = QuestionStore()
|
|
|
|
|
self.auth = AuthService(self.user_store)
|
|
|
|
|
self.current_user = None # user dict
|
|
|
|
|
self.current_user = None
|
|
|
|
|
self.current_exam = None
|
|
|
|
|
self.build_main()
|
|
|
|
|
|
|
|
|
|
@ -516,54 +533,44 @@ class MathApp:
|
|
|
|
|
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()
|
|
|
|
|
@ -571,13 +578,11 @@ class MathApp:
|
|
|
|
|
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)
|
|
|
|
|
@ -589,9 +594,7 @@ class MathApp:
|
|
|
|
|
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()
|
|
|
|
|
@ -614,11 +617,10 @@ class MathApp:
|
|
|
|
|
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)
|
|
|
|
|
@ -639,14 +641,13 @@ class MathApp:
|
|
|
|
|
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)
|
|
|
|
|
@ -656,7 +657,7 @@ class MathApp:
|
|
|
|
|
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")
|
|
|
|
|
tk.Label(fr, text="请选择学段并输入题目数量(数量最高为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")
|
|
|
|
|
@ -705,7 +706,7 @@ class MathApp:
|
|
|
|
|
messagebox.showerror("失败", msg, parent=self.root); return
|
|
|
|
|
messagebox.showinfo("成功", "密码修改成功", parent=self.root)
|
|
|
|
|
|
|
|
|
|
# ----- 题目界面 -----
|
|
|
|
|
# 题目界面
|
|
|
|
|
def build_question_screen(self):
|
|
|
|
|
self.clear()
|
|
|
|
|
exam = self.current_exam
|
|
|
|
|
@ -740,7 +741,7 @@ class MathApp:
|
|
|
|
|
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"])
|
|
|
|
|
@ -779,7 +780,7 @@ class MathApp:
|
|
|
|
|
self.current_exam = None
|
|
|
|
|
self.build_main()
|
|
|
|
|
|
|
|
|
|
# ----------------- 初始化 -----------------
|
|
|
|
|
# ----------------- main -----------------
|
|
|
|
|
def main():
|
|
|
|
|
ensure_files_exist()
|
|
|
|
|
root = tk.Tk()
|
|
|
|
|
|