|
|
|
|
@ -1,791 +0,0 @@
|
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
"""
|
|
|
|
|
单文件桌面应用:小初高数学练习(Tkinter)
|
|
|
|
|
主要修改(按用户要求):
|
|
|
|
|
- 每道题中出现的数字不超过 5 个(操作数数目为 1~5)
|
|
|
|
|
- 小学题不出现负数(操作数为正,生成表达式尽量避免负结果)
|
|
|
|
|
- 初中题:题目显示使用根号符号 √(...),幂显示为 x^2(内部仍用 sqrt()/** 计算)
|
|
|
|
|
- 高中题:题目中不出现 pi 字符串,三角函数以角度表示(如 sin(30°)),内部计算将角度转为弧度
|
|
|
|
|
其它功能保留:注册(邮箱验证码)、用户名/邮箱登录、修改密码、题库自动生成与保存、PBKDF2 密码哈希等。
|
|
|
|
|
注意:替换 SMTP 配置为可用的邮箱/授权码或改为测试模式。
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
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 # 至多 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():
|
|
|
|
|
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)
|
|
|
|
|
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):
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
# ----------------- 题目生成相关辅助 -----------------
|
|
|
|
|
def gen_id(prefix: str) -> str:
|
|
|
|
|
return f"{prefix}_{secrets.token_hex(4)}"
|
|
|
|
|
|
|
|
|
|
def mk_options_from_number(correct_val, kind="int"):
|
|
|
|
|
opts = []
|
|
|
|
|
if kind == "int":
|
|
|
|
|
corr = int(round(correct_val))
|
|
|
|
|
opts = [str(corr)]
|
|
|
|
|
attempts = 0
|
|
|
|
|
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 candidate >= -9999 and 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:
|
|
|
|
|
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 环境用于计算(内部使用 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):
|
|
|
|
|
# expr 为 Python 可 eval 的表达式(外层 display 与内部计算分开)
|
|
|
|
|
expr = expr.replace("^", "**")
|
|
|
|
|
return eval(expr, SAFE_EVAL_ENV, {})
|
|
|
|
|
|
|
|
|
|
# ----------------- 题目生成(符合新要求) -----------------
|
|
|
|
|
# 小学:1~5 个操作数,操作数为正,尽量避免负数结果
|
|
|
|
|
def generate_primary_question():
|
|
|
|
|
# 生成 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, 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:
|
|
|
|
|
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 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")
|
|
|
|
|
# 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])
|
|
|
|
|
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:
|
|
|
|
|
# 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:
|
|
|
|
|
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):
|
|
|
|
|
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_ans = mk_options_from_number(val, kind="float")
|
|
|
|
|
else:
|
|
|
|
|
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}
|
|
|
|
|
|
|
|
|
|
# 高中:1~5 个操作数,三角函数以角度表示(如 sin(30°)),题目显示不含 pi
|
|
|
|
|
def generate_high_question():
|
|
|
|
|
func = random.choice(["sin", "cos", "tan"])
|
|
|
|
|
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])
|
|
|
|
|
# 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):
|
|
|
|
|
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
|
|
|
|
|
# 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}
|
|
|
|
|
|
|
|
|
|
# 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)
|
|
|
|
|
new = []
|
|
|
|
|
for _ in range(to_gen):
|
|
|
|
|
if grade == "小学":
|
|
|
|
|
new.append(generate_primary_question())
|
|
|
|
|
elif grade == "初中":
|
|
|
|
|
new.append(generate_middle_question())
|
|
|
|
|
else:
|
|
|
|
|
new.append(generate_high_question())
|
|
|
|
|
qs.extend(new)
|
|
|
|
|
qstore.save(grade, qs)
|
|
|
|
|
log(f"Auto-generated {len(new)} 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, "用户名不能为空"
|
|
|
|
|
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):
|
|
|
|
|
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
|
|
|
|
|
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
|
|
|
|
|
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)
|
|
|
|
|
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
|
|
|
|
|
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="请选择学段并输入题目数量(数量最高为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()
|
|
|
|
|
|
|
|
|
|
# ----------------- main -----------------
|
|
|
|
|
def main():
|
|
|
|
|
ensure_files_exist()
|
|
|
|
|
root = tk.Tk()
|
|
|
|
|
app = MathApp(root)
|
|
|
|
|
root.mainloop()
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|