litao_branch
litao 4 months ago
commit 06981ee7ed

@ -0,0 +1 @@
PyQt6==6.7.0

@ -0,0 +1,14 @@
from PyQt6.QtWidgets import QApplication
from ui.main_window import MainWindow
def main() -> None:
"""应用程序入口:创建 QApplication显示主窗口并进入事件循环。"""
app = QApplication([])
window = MainWindow()
window.show()
app.exec()
if __name__ == "__main__":
main()

@ -0,0 +1,66 @@
"""
邮件服务模块基于 SMTP 发送验证码邮件支持 TLS/SSL
"""
import smtplib
import ssl
from email.message import EmailMessage
from typing import Dict
from .storage_service import StorageService
class EmailService:
"""负责发送邮件验证码的服务类。"""
def __init__(self, storage: StorageService) -> None:
"""初始化邮件服务,依赖存储服务获取 SMTP 配置。"""
self.storage = storage
def send_verification_code(self, to_email: str, code: str) -> bool:
"""发送验证码到指定邮箱。
参数:
to_email: 收件人邮箱
code: 验证码字符串
返回:
True 表示发送成功False 表示失败
"""
config = self.storage.load_config().get('smtp', {})
server = config.get('server', '')
port = int(config.get('port', 587))
username = config.get('username', '')
password = config.get('password', '')
use_tls = bool(config.get('use_tls', True))
use_ssl = bool(config.get('use_ssl', False))
sender_name = config.get('sender_name', 'Math Study App')
if not server or not username or not password:
return False
msg = EmailMessage()
msg['Subject'] = '数学学习软件注册验证码'
msg['From'] = f"{sender_name} <{username}>"
msg['To'] = to_email
msg.set_content(f"您的注册验证码为:{code}\n该验证码10分钟内有效。")
try:
if use_ssl:
context = ssl.create_default_context()
with smtplib.SMTP_SSL(server, port, context=context) as smtp:
smtp.login(username, password)
smtp.send_message(msg)
else:
with smtplib.SMTP(server, port) as smtp:
if use_tls:
smtp.starttls(context=ssl.create_default_context())
smtp.login(username, password)
smtp.send_message(msg)
return True
except Exception:
return False
def update_smtp_config(self, config: Dict) -> None:
"""更新并保存 SMTP 配置。"""
data = self.storage.load_config()
data['smtp'] = config
self.storage.save_config(data)

@ -0,0 +1,329 @@
"""
试题服务模块按小学/初中/高中生成选择题试卷并保证同一试卷题目不重复
"""
import random
import math
from typing import List, Dict, Callable, Tuple, Set, Optional, Union
from enum import Enum
class Level(Enum):
"""题目难度级别枚举"""
PRIMARY = 'primary'
MIDDLE = 'middle'
HIGH = 'high'
class QuestionService:
"""负责生成不同年级难度的选择题。"""
# 特殊角度和完全平方数常量
SPECIAL_ANGLES = [0, 30, 45, 60, 90, 120, 135, 150, 180]
PERFECT_SQUARES = [4, 9, 16, 25, 36, 49, 64, 81, 100, 121, 144, 169, 196, 225]
def __init__(self) -> None:
"""初始化题目服务。"""
random.seed()
# 映射难度级别到对应的生成函数
self._generators = {
Level.PRIMARY.value: self._gen_primary,
Level.MIDDLE.value: self._gen_middle,
Level.HIGH.value: self._gen_high,
}
def generate_questions(self, level: str, count: int) -> List[Dict]:
"""生成指定年级与数量的题目列表。
参数:
level: 'primary'|'middle'|'high' 三选一
count: 题目数量
返回:
题目字典列表每个字典包含 stem, options, answer_index
"""
if count <= 0:
raise ValueError('题目数量必须为正整数')
gen = self._generators.get(level)
if not gen:
raise ValueError(f'无效的年级选项: {level},可选值为 primary, middle, high')
seen: Set[str] = set()
questions: List[Dict] = []
# 使用集合去重,避免重复题目
while len(questions) < count:
q = gen()
if q['stem'] in seen:
continue
seen.add(q['stem'])
questions.append(q)
return questions
def _gen_primary(self) -> Dict:
"""生成一题小学难度的四则运算选择题(可带括号)。"""
# 随机选择运算类型:简单四则运算或带括号的复合运算
if random.choice([True, False]):
stem, ans = self._gen_simple_arithmetic()
else:
stem, ans = self._gen_bracket_arithmetic()
options = self._make_options(ans)
return {'stem': stem, 'options': options, 'answer_index': options.index(str(ans))}
def _gen_simple_arithmetic(self) -> Tuple[str, int]:
"""生成简单四则运算题目"""
a = random.randint(1, 50)
b = random.randint(1, 50)
op = random.choice(['+', '-', '*', '/'])
if op == '+':
ans = a + b
stem = f"计算:{a} + {b} = ?"
elif op == '-':
ans = a - b
stem = f"计算:{a} - {b} = ?"
elif op == '*':
ans = a * b
stem = f"计算:{a} × {b} = ?"
else: # 除法,确保整除
ans = a
b = random.randint(1, 10)
a = ans * b # 确保整除
stem = f"计算:{a} ÷ {b} = ?"
return stem, ans
def _gen_bracket_arithmetic(self) -> Tuple[str, int]:
"""生成带括号的复合运算题目"""
a = random.randint(1, 20)
b = random.randint(1, 20)
c = random.randint(1, 20)
# 随机选择括号运算类型
bracket_type = random.choice(['add_mul', 'sub_mul', 'mul_add', 'mul_sub'])
if bracket_type == 'add_mul':
ans = (a + b) * c
stem = f"计算:({a} + {b}) × {c} = ?"
elif bracket_type == 'sub_mul':
ans = (a - b) * c
stem = f"计算:({a} - {b}) × {c} = ?"
elif bracket_type == 'mul_add':
ans = a * (b + c)
stem = f"计算:{a} × ({b} + {c}) = ?"
else: # mul_sub
ans = a * (b - c)
stem = f"计算:{a} × ({b} - {c}) = ?"
return stem, ans
def _gen_middle(self) -> Dict:
"""生成一题初中难度的题目,至少包含一个平方或开根号运算。"""
# 随机选择题目类型:平方运算、开根号运算或混合运算
question_type = random.choice(['square', 'sqrt', 'mixed'])
if question_type == 'square':
stem, ans = self._gen_square_question()
elif question_type == 'sqrt':
stem, ans = self._gen_sqrt_question()
else: # mixed
stem, ans = self._gen_mixed_middle_question()
options = self._make_options(ans)
return {'stem': stem, 'options': options, 'answer_index': options.index(str(ans))}
def _gen_square_question(self) -> Tuple[str, int]:
"""生成平方运算题目"""
a = random.randint(2, 15)
b = random.randint(1, 10)
if random.choice([True, False]):
# (a + b)²
ans = (a + b) ** 2
stem = f"计算:({a} + {b})² = ?"
else:
# a² + b²
ans = a ** 2 + b ** 2
stem = f"计算:{a}² + {b}² = ?"
return stem, ans
def _gen_sqrt_question(self) -> Tuple[str, int]:
"""生成开根号运算题目"""
# 选择完全平方数确保结果为整数
a = random.choice(self.PERFECT_SQUARES)
b = random.randint(1, 10)
if random.choice([True, False]):
# √a + b
ans = int(math.sqrt(a)) + b
stem = f"计算:√{a} + {b} = ?"
else:
# √a × b
ans = int(math.sqrt(a)) * b
stem = f"计算:√{a} × {b} = ?"
return stem, ans
def _gen_mixed_middle_question(self) -> Tuple[str, int]:
"""生成混合运算:既有平方又有开根号"""
perfect_square = random.choice(self.PERFECT_SQUARES[:9]) # 使用较小的完全平方数
a = random.randint(2, 8)
# √perfect_square + a²
ans = int(math.sqrt(perfect_square)) + a ** 2
stem = f"计算:√{perfect_square} + {a}² = ?"
return stem, ans
def _gen_high(self) -> Dict:
"""生成一题高中难度的题目至少包含一个sin、cos或tan运算符。"""
# 随机选择题目类型:基础三角函数、三角函数运算或复合运算
question_type = random.choice(['basic_trig', 'trig_calc', 'mixed_trig'])
if question_type == 'basic_trig':
stem, ans = self._gen_basic_trig_question()
elif question_type == 'trig_calc':
stem, ans = self._gen_trig_calc_question()
else: # mixed_trig
stem, ans = self._gen_mixed_trig_question()
options = self._make_options(ans, float_mode=True)
correct = f"{ans:.2f}"
return {'stem': stem, 'options': options, 'answer_index': options.index(correct)}
def _gen_basic_trig_question(self) -> Tuple[str, float]:
"""生成基础三角函数值计算题目"""
# 使用特殊角度确保结果为常见值
angle = random.choice(self.SPECIAL_ANGLES)
func = random.choice(['sin', 'cos', 'tan'])
# 计算三角函数值(转换为弧度)
rad = math.radians(angle)
if func == 'sin':
ans = round(math.sin(rad), 2)
stem = f"计算sin({angle}°) = ?(保留两位小数)"
elif func == 'cos':
ans = round(math.cos(rad), 2)
stem = f"计算cos({angle}°) = ?(保留两位小数)"
else: # tan
if angle in [90, 270]: # tan在这些角度未定义
angle = 45
rad = math.radians(angle)
ans = round(math.tan(rad), 2)
stem = f"计算tan({angle}°) = ?(保留两位小数)"
return stem, ans
def _gen_trig_calc_question(self) -> Tuple[str, float]:
"""生成三角函数运算题目"""
angle1 = random.choice([30, 45, 60])
angle2 = random.choice([30, 45, 60])
func1 = random.choice(['sin', 'cos'])
func2 = random.choice(['sin', 'cos'])
rad1 = math.radians(angle1)
rad2 = math.radians(angle2)
val1 = math.sin(rad1) if func1 == 'sin' else math.cos(rad1)
val2 = math.sin(rad2) if func2 == 'sin' else math.cos(rad2)
# 随机选择运算符
if random.choice([True, False]):
ans = round(val1 + val2, 2)
stem = f"计算:{func1}({angle1}°) + {func2}({angle2}°) = ?(保留两位小数)"
else:
ans = round(val1 * val2, 2)
stem = f"计算:{func1}({angle1}°) × {func2}({angle2}°) = ?(保留两位小数)"
return stem, ans
def _gen_mixed_trig_question(self) -> Tuple[str, float]:
"""生成混合运算:三角函数与代数运算"""
angle = random.choice([30, 45, 60])
a = random.randint(2, 5)
func = random.choice(['sin', 'cos', 'tan'])
rad = math.radians(angle)
if func == 'sin':
trig_val = math.sin(rad)
elif func == 'cos':
trig_val = math.cos(rad)
else:
trig_val = math.tan(rad)
ans = round(a * trig_val + a, 2)
stem = f"计算:{a} × {func}({angle}°) + {a} = ?(保留两位小数)"
return stem, ans
def _make_options(self, answer: Union[int, float], float_mode: bool = False) -> List[str]:
"""根据正确答案生成 4 个选项包含正确答案与3个干扰项
参数:
answer: 正确答案
float_mode: 是否为浮点数模式
返回:
包含4个选项的列表
"""
if float_mode:
return self._make_float_options(answer)
else:
return self._make_int_options(answer)
def _make_float_options(self, answer: float) -> List[str]:
"""生成浮点数选项"""
opts: Set[str] = set()
correct = f"{answer:.2f}"
opts.add(correct)
# 生成干扰项,确保不重复
attempts = 0
while len(opts) < 4 and attempts < 20:
delta = random.uniform(-5, 5)
# 确保干扰项与正确答案有一定差距
if abs(delta) < 0.1:
continue
opts.add(f"{answer + delta:.2f}")
attempts += 1
# 如果生成的选项不足4个添加固定偏移的选项
if len(opts) < 4:
for delta in [0.5, -0.5, 1.0, -1.0]:
if len(opts) >= 4:
break
opts.add(f"{answer + delta:.2f}")
options = list(opts)
random.shuffle(options)
return options
def _make_int_options(self, answer: int) -> List[str]:
"""生成整数选项"""
opts: Set[str] = set()
correct = str(answer)
opts.add(correct)
# 生成干扰项,确保不重复且有一定差距
attempts = 0
while len(opts) < 4 and attempts < 20:
delta = random.randint(-10, 10)
# 确保干扰项与正确答案有一定差距
if delta == 0 or abs(delta) < 2:
continue
opts.add(str(answer + delta))
attempts += 1
# 如果生成的选项不足4个添加固定偏移的选项
if len(opts) < 4:
for delta in [2, -2, 5, -5]:
if len(opts) >= 4:
break
opts.add(str(answer + delta))
options = list(opts)
random.shuffle(options)
return options

@ -0,0 +1,97 @@
"""
存储服务模块使用 JSON 文件持久化数据不使用数据库
提供用户数据与配置数据的读写接口
"""
import json
import os
from typing import Dict, Any, List
class StorageService:
"""使用 JSON 文件进行数据持久化的服务类。"""
def __init__(self) -> None:
"""初始化存储路径并确保必要文件存在。"""
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
self.storage_dir = os.path.join(base_dir, 'storage')
self.users_file = os.path.join(self.storage_dir, 'users.json')
self.config_file = os.path.join(self.storage_dir, 'config.json')
self._ensure_files()
def _ensure_files(self) -> None:
"""确保存储目录与文件存在,如不存在则创建。"""
os.makedirs(self.storage_dir, exist_ok=True)
if not os.path.exists(self.users_file):
with open(self.users_file, 'w', encoding='utf-8') as f:
json.dump({'users': []}, f, ensure_ascii=False, indent=2)
if not os.path.exists(self.config_file):
with open(self.config_file, 'w', encoding='utf-8') as f:
json.dump({
'smtp': {
'server': '',
'port': 587,
'username': '',
'password': '',
'use_tls': True,
'use_ssl': False,
'sender_name': 'Math Study App'
}
}, f, ensure_ascii=False, indent=2)
def load_users(self) -> Dict[str, List[Dict[str, Any]]]:
"""读取用户列表数据。返回字典 {'users': [...]}。"""
with open(self.users_file, 'r', encoding='utf-8') as f:
return json.load(f)
def save_users(self, data: Dict[str, List[Dict[str, Any]]]) -> None:
"""写入用户列表数据到文件。"""
with open(self.users_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def load_config(self) -> Dict[str, Any]:
"""读取配置数据(包含 SMTP 配置)。"""
with open(self.config_file, 'r', encoding='utf-8') as f:
return json.load(f)
def save_config(self, data: Dict[str, Any]) -> None:
"""写入配置数据到文件。"""
with open(self.config_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def get_user(self, email: str) -> Dict[str, Any] | None:
"""根据邮箱获取用户字典,不存在返回 None。"""
users = self.load_users().get('users', [])
for u in users:
if u.get('email') == email:
return u
return None
def get_user_by_username(self, username: str) -> Dict[str, Any] | None:
"""根据用户名获取用户字典,不存在返回 None。"""
users = self.load_users().get('users', [])
for u in users:
if u.get('username') == username:
return u
return None
def username_exists(self, username: str) -> bool:
"""判断指定用户名是否已被占用。"""
return self.get_user_by_username(username) is not None
def upsert_user(self, user: Dict[str, Any]) -> None:
"""插入或更新用户字典,并立即持久化。"""
data = self.load_users()
users = data.get('users', [])
found = False
for i, u in enumerate(users):
if u.get('email') == user.get('email'):
users[i] = user
found = True
break
if not found:
users.append(user)
self.save_users({'users': users})
def user_exists(self, email: str) -> bool:
"""判断指定邮箱的用户是否存在。"""
return self.get_user(email) is not None

@ -0,0 +1,174 @@
"""
用户服务模块处理注册验证码用户名设置登录与修改密码逻辑
不依赖真实邮件与SMTP仅进行邮箱格式校验与本地验证码生成/校验
"""
import time
from typing import Any, Dict
from utils.security_utils import (
validate_email,
validate_password_strength,
generate_verification_code,
hash_password,
verify_password,
)
from .storage_service import StorageService
# 删除 EmailService 依赖
class UserService:
"""封装用户相关业务逻辑的服务类。"""
def __init__(self, storage: StorageService) -> None:
"""初始化用户服务,仅注入存储服务。"""
self.storage = storage
def request_registration(self, email: str) -> tuple[bool, str]:
"""发起注册请求:校验邮箱,生成验证码并本地保存(不发送邮件)。
参数:
email: 用户邮箱
返回:
(success, message) 二元组message包含提示与模拟验证码信息
"""
if not validate_email(email):
return False, '邮箱格式不正确'
existing = self.storage.get_user(email)
if existing and existing.get('verified'):
return False, '该邮箱已注册'
code = generate_verification_code()
salt, code_hash = hash_password(code)
user = existing or {'email': email}
user.update({
'verified': False,
'code_salt': salt.hex(),
'code_hash': code_hash.hex(),
'code_time': int(time.time()),
'password_salt': '',
'password_hash': '',
'username': user.get('username', ''),
'created_at': user.get('created_at') or int(time.time()),
})
self.storage.upsert_user(user)
# 不发送邮件,直接提示验证码供用户在下一步手动输入
return True, f'验证码已生成(模拟):{code},请前往验证码页面手动输入'
def verify_code(self, email: str, code: str) -> tuple[bool, str]:
"""校验验证码并标记邮箱已验证。
参数:
email: 用户邮箱
code: 用户输入的验证码
返回:
(success, message)
"""
user = self.storage.get_user(email)
if not user:
return False, '用户不存在,请先注册'
if int(time.time()) - int(user.get('code_time', 0)) > 600:
return False, '验证码已过期,请重新获取'
salt_hex = user.get('code_salt', '')
hash_hex = user.get('code_hash', '')
if not salt_hex or not hash_hex:
return False, '未找到验证码信息'
salt = bytes.fromhex(salt_hex)
hash_bytes = bytes.fromhex(hash_hex)
if not verify_password(code, salt, hash_bytes):
return False, '验证码不正确'
user['verified'] = True
# 清除验证码信息
user['code_salt'] = ''
user['code_hash'] = ''
self.storage.upsert_user(user)
return True, '邮箱验证成功,请设置用户名与密码'
def set_username(self, email: str, username: str) -> tuple[bool, str]:
"""设置用户名:校验格式与唯一性,并写入用户信息。
参数:
email: 目标用户邮箱
username: 待设置的用户名3-16字母数字与下划线
返回:
(success, message)
"""
user = self.storage.get_user(email)
if not user or not user.get('verified'):
return False, '邮箱未验证或用户不存在'
uname = (username or '').strip()
if not (3 <= len(uname) <= 16) or not all(c.isalnum() or c == '_' for c in uname):
return False, '用户名需为3-16位且仅包含字母、数字或下划线'
if self.storage.username_exists(uname):
return False, '该用户名已被占用'
user['username'] = uname
self.storage.upsert_user(user)
return True, '用户名设置成功'
def set_password(self, email: str, password: str, confirm: str) -> tuple[bool, str]:
"""设置或重置密码:校验强度、确认一致并持久化哈希。"""
if password != confirm:
return False, '两次输入的密码不一致'
if not validate_password_strength(password):
return False, '密码需为6-10位且包含大小写字母与数字'
user = self.storage.get_user(email)
if not user or not user.get('verified'):
return False, '邮箱未验证或用户不存在'
salt, pwd_hash = hash_password(password)
user['password_salt'] = salt.hex()
user['password_hash'] = pwd_hash.hex()
self.storage.upsert_user(user)
return True, '密码设置成功'
def complete_registration(self, email: str) -> tuple[bool, str]:
"""完成注册:要求用户已设置用户名与密码,方视为完成。
参数:
email: 用户邮箱
返回:
(success, message)
"""
user = self.storage.get_user(email)
if not user:
return False, '用户不存在'
if not user.get('verified'):
return False, '邮箱未验证'
if not user.get('username'):
return False, '请先设置用户名'
if not user.get('password_hash'):
return False, '请先设置密码'
# 已经持久化,无需额外操作,这里仅作为流程校验提示
return True, '注册完成'
def login(self, email: str, password: str) -> tuple[bool, str]:
"""邮箱登录:校验邮箱存在与密码匹配。"""
user = self.storage.get_user(email)
if not user or not user.get('password_hash'):
return False, '用户不存在或未设置密码'
salt_hex = user.get('password_salt', '')
hash_hex = user.get('password_hash', '')
ok = verify_password(password, bytes.fromhex(salt_hex), bytes.fromhex(hash_hex))
if not ok:
return False, '密码不正确'
return True, '登录成功'
def login_by_username(self, username: str, password: str) -> tuple[bool, str]:
"""用户名登录:根据用户名查询并校验密码。"""
user = self.storage.get_user_by_username(username)
if not user or not user.get('password_hash'):
return False, '用户不存在或未设置密码'
salt_hex = user.get('password_salt', '')
hash_hex = user.get('password_hash', '')
ok = verify_password(password, bytes.fromhex(salt_hex), bytes.fromhex(hash_hex))
if not ok:
return False, '密码不正确'
return True, '登录成功'
def change_password(self, email: str, old_password: str, new_password: str, confirm: str) -> tuple[bool, str]:
"""修改密码:校验原密码正确并设置新密码。"""
user = self.storage.get_user(email)
if not user:
return False, '用户不存在'
salt_hex = user.get('password_salt', '')
hash_hex = user.get('password_hash', '')
if not verify_password(old_password, bytes.fromhex(salt_hex), bytes.fromhex(hash_hex)):
return False, '原密码不正确'
return self.set_password(email, new_password, confirm)

@ -0,0 +1,11 @@
{
"smtp": {
"server": "",
"port": 587,
"username": "",
"password": "",
"use_tls": true,
"use_ssl": false,
"sender_name": "Math Study App"
}
}

@ -0,0 +1,15 @@
{
"users": [
{
"email": "shenyongye@163.com",
"verified": true,
"code_salt": "",
"code_hash": "",
"code_time": 1760256425,
"password_salt": "e09f40c04b33d5f482ff682b3d43192b",
"password_hash": "dffeab45544b194e5f7efeb632cbe75eef3b46fbff23531692c7ef0e9a83f24b",
"username": "echo",
"created_at": 1760256425
}
]
}

@ -0,0 +1,585 @@
from PyQt6.QtWidgets import (
QMainWindow, QWidget, QStackedWidget, QVBoxLayout, QHBoxLayout,
QLabel, QLineEdit, QPushButton, QMessageBox, QRadioButton, QButtonGroup, QSpinBox
)
from PyQt6.QtCore import Qt
from services.storage_service import StorageService
from services.user_service import UserService
from services.question_service import QuestionService
class MainWindow(QMainWindow):
"""应用主窗口:组织各个页面并承载业务服务。"""
def __init__(self) -> None:
"""初始化主窗口并构建基础页面。"""
super().__init__()
self.setWindowTitle("数学学习软件")
self.setFixedSize(900, 600)
# 业务服务初始化
self.storage_service = StorageService()
self.session_email = None
self.user_service = UserService(self.storage_service)
self.question_service = QuestionService()
# 页面容器
self.stack = QStackedWidget()
self.setCentralWidget(self.stack)
# 构建页面:登录、注册(邮箱/验证码/用户名密码)、选择、答题、结果、修改密码
self._build_login_page() # index 0
self._build_register_email_page() # index 1
self._build_verify_page() # index 2
self._build_set_credentials_page() # index 3
self._build_choice_page() # index 4
self._build_quiz_page() # index 5
self._build_result_page() # index 6
self._build_change_password_page() # index 7
# 默认显示登录页
self.stack.setCurrentIndex(0)
def _build_login_page(self) -> None:
"""构建登录页面:支持用户名或邮箱登录。"""
page = QWidget()
layout = QVBoxLayout(page)
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
title = QLabel("登录")
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
title.setStyleSheet("font-size: 22px; font-weight: bold; margin: 16px 0;")
layout.addWidget(title)
self.login_identifier = QLineEdit()
self.login_identifier.setPlaceholderText("请输入邮箱或用户名")
layout.addWidget(self.login_identifier)
self.login_password = QLineEdit()
self.login_password.setPlaceholderText("请输入密码")
self.login_password.setEchoMode(QLineEdit.EchoMode.Password)
layout.addWidget(self.login_password)
btn_row = QHBoxLayout()
self.btn_login = QPushButton("登录")
self.btn_to_register = QPushButton("去注册")
btn_row.addWidget(self.btn_login)
btn_row.addWidget(self.btn_to_register)
layout.addLayout(btn_row)
self.btn_login.clicked.connect(self._on_login)
self.btn_to_register.clicked.connect(lambda: self.stack.setCurrentIndex(1))
self.stack.addWidget(page)
def _on_login(self) -> None:
"""处理登录逻辑:根据输入自动识别邮箱或用户名进行登录,并在成功后进入选择页。"""
identifier = (self.login_identifier.text() or "").strip()
password = self.login_password.text() or ""
if not identifier or not password:
QMessageBox.warning(self, "提示", "账号与密码均不能为空")
return
# 判断是否为邮箱
if "@" in identifier:
ok, msg = self.user_service.login(identifier, password)
if ok:
self.session_email = identifier
else:
QMessageBox.warning(self, "提示", msg or "登录失败")
return
else:
ok, msg = self.user_service.login_by_username(identifier, password)
if ok:
# 通过用户名获取邮箱,记录会话
user = self.storage_service.get_user_by_username(identifier)
self.session_email = user.get('email') if user else None
else:
QMessageBox.warning(self, "提示", msg or "登录失败")
return
QMessageBox.information(self, "提示", "登录成功")
# 跳转到选择页
self.stack.setCurrentIndex(4)
def _build_register_email_page(self) -> None:
"""构建注册第一步:邮箱输入页面。"""
page = QWidget()
layout = QVBoxLayout(page)
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
title = QLabel("注册 - 邮箱验证")
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
title.setStyleSheet("font-size: 22px; font-weight: bold; margin: 16px 0;")
layout.addWidget(title)
self.register_email = QLineEdit()
self.register_email.setPlaceholderText("请输入邮箱地址")
layout.addWidget(self.register_email)
btn_row = QHBoxLayout()
self.btn_send_code = QPushButton("获取验证码")
self.btn_back_to_login = QPushButton("返回登录")
btn_row.addWidget(self.btn_send_code)
btn_row.addWidget(self.btn_back_to_login)
layout.addLayout(btn_row)
self.btn_send_code.clicked.connect(self._on_send_code)
self.btn_back_to_login.clicked.connect(lambda: self.stack.setCurrentIndex(0))
self.stack.addWidget(page)
def _on_send_code(self) -> None:
"""处理发送验证码逻辑:验证邮箱格式并生成验证码。"""
email = (self.register_email.text() or "").strip()
if not email:
QMessageBox.warning(self, "提示", "请输入邮箱地址")
return
ok, msg = self.user_service.request_registration(email)
if ok:
self.session_email = email
QMessageBox.information(self, "提示", msg)
# 跳转到验证码页面
self.stack.setCurrentIndex(2)
else:
QMessageBox.warning(self, "提示", msg)
def _build_verify_page(self) -> None:
"""构建注册第二步:验证码验证页面。"""
page = QWidget()
layout = QVBoxLayout(page)
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
title = QLabel("注册 - 验证码验证")
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
title.setStyleSheet("font-size: 22px; font-weight: bold; margin: 16px 0;")
layout.addWidget(title)
self.verify_code = QLineEdit()
self.verify_code.setPlaceholderText("请输入6位验证码")
layout.addWidget(self.verify_code)
btn_row = QHBoxLayout()
self.btn_verify = QPushButton("验证")
self.btn_back_to_email = QPushButton("返回上一步")
btn_row.addWidget(self.btn_verify)
btn_row.addWidget(self.btn_back_to_email)
layout.addLayout(btn_row)
self.btn_verify.clicked.connect(self._on_verify_code)
self.btn_back_to_email.clicked.connect(lambda: self.stack.setCurrentIndex(1))
self.stack.addWidget(page)
def _on_verify_code(self) -> None:
"""处理验证码验证逻辑。"""
code = (self.verify_code.text() or "").strip()
if not code:
QMessageBox.warning(self, "提示", "请输入验证码")
return
if not self.session_email:
QMessageBox.warning(self, "提示", "会话已过期,请重新注册")
self.stack.setCurrentIndex(1)
return
ok, msg = self.user_service.verify_code(self.session_email, code)
if ok:
QMessageBox.information(self, "提示", msg)
# 跳转到设置用户名密码页面
self.stack.setCurrentIndex(3)
else:
QMessageBox.warning(self, "提示", msg)
def _build_set_credentials_page(self) -> None:
"""构建注册第三步:设置用户名和密码页面。"""
page = QWidget()
layout = QVBoxLayout(page)
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
title = QLabel("注册 - 设置用户名和密码")
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
title.setStyleSheet("font-size: 22px; font-weight: bold; margin: 16px 0;")
layout.addWidget(title)
self.register_username = QLineEdit()
self.register_username.setPlaceholderText("请输入用户名3-16位字母数字下划线")
layout.addWidget(self.register_username)
self.register_password = QLineEdit()
self.register_password.setPlaceholderText("请输入密码6-10位包含大小写字母和数字")
self.register_password.setEchoMode(QLineEdit.EchoMode.Password)
layout.addWidget(self.register_password)
self.register_confirm = QLineEdit()
self.register_confirm.setPlaceholderText("请确认密码")
self.register_confirm.setEchoMode(QLineEdit.EchoMode.Password)
layout.addWidget(self.register_confirm)
btn_row = QHBoxLayout()
self.btn_complete_register = QPushButton("完成注册")
self.btn_back_to_verify = QPushButton("返回上一步")
btn_row.addWidget(self.btn_complete_register)
btn_row.addWidget(self.btn_back_to_verify)
layout.addLayout(btn_row)
self.btn_complete_register.clicked.connect(self._on_complete_register)
self.btn_back_to_verify.clicked.connect(lambda: self.stack.setCurrentIndex(2))
self.stack.addWidget(page)
def _on_complete_register(self) -> None:
"""处理完成注册逻辑:设置用户名和密码。"""
username = (self.register_username.text() or "").strip()
password = self.register_password.text() or ""
confirm = self.register_confirm.text() or ""
if not username or not password or not confirm:
QMessageBox.warning(self, "提示", "请填写完整信息")
return
if not self.session_email:
QMessageBox.warning(self, "提示", "会话已过期,请重新注册")
self.stack.setCurrentIndex(1)
return
# 设置用户名
ok, msg = self.user_service.set_username(self.session_email, username)
if not ok:
QMessageBox.warning(self, "提示", msg)
return
# 设置密码
ok, msg = self.user_service.set_password(self.session_email, password, confirm)
if not ok:
QMessageBox.warning(self, "提示", msg)
return
# 完成注册
ok, msg = self.user_service.complete_registration(self.session_email)
if ok:
QMessageBox.information(self, "提示", "注册成功!请登录")
# 清空表单并跳转到登录页
self.register_email.clear()
self.verify_code.clear()
self.register_username.clear()
self.register_password.clear()
self.register_confirm.clear()
self.session_email = None
self.stack.setCurrentIndex(0)
else:
QMessageBox.warning(self, "提示", msg)
def _build_choice_page(self) -> None:
"""构建选择页面:选择年级和题目数量。"""
page = QWidget()
layout = QVBoxLayout(page)
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
title = QLabel("选择题目难度和数量")
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
title.setStyleSheet("font-size: 22px; font-weight: bold; margin: 16px 0;")
layout.addWidget(title)
# 年级选择
grade_label = QLabel("选择年级:")
layout.addWidget(grade_label)
self.grade_group = QButtonGroup()
grade_layout = QHBoxLayout()
self.primary_radio = QRadioButton("小学")
self.middle_radio = QRadioButton("初中")
self.high_radio = QRadioButton("高中")
self.grade_group.addButton(self.primary_radio, 0)
self.grade_group.addButton(self.middle_radio, 1)
self.grade_group.addButton(self.high_radio, 2)
grade_layout.addWidget(self.primary_radio)
grade_layout.addWidget(self.middle_radio)
grade_layout.addWidget(self.high_radio)
layout.addLayout(grade_layout)
# 默认选择小学
self.primary_radio.setChecked(True)
# 题目数量选择
count_label = QLabel("选择题目数量:")
layout.addWidget(count_label)
self.question_count = QSpinBox()
self.question_count.setMinimum(10)
self.question_count.setMaximum(30)
self.question_count.setValue(15)
layout.addWidget(self.question_count)
btn_row = QHBoxLayout()
self.btn_start_quiz = QPushButton("开始答题")
self.btn_change_password = QPushButton("修改密码")
self.btn_logout = QPushButton("退出登录")
btn_row.addWidget(self.btn_start_quiz)
btn_row.addWidget(self.btn_change_password)
btn_row.addWidget(self.btn_logout)
layout.addLayout(btn_row)
self.btn_start_quiz.clicked.connect(self._on_start_quiz)
self.btn_change_password.clicked.connect(lambda: self.stack.setCurrentIndex(7))
self.btn_logout.clicked.connect(self._on_logout)
self.stack.addWidget(page)
def _on_start_quiz(self) -> None:
"""开始答题:生成题目并跳转到答题页面。"""
# 获取选择的年级
grade_map = {0: 'primary', 1: 'middle', 2: 'high'}
grade = grade_map[self.grade_group.checkedId()]
count = self.question_count.value()
try:
self.questions = self.question_service.generate_questions(grade, count)
self.current_question = 0
self.user_answers = []
self._show_current_question()
self.stack.setCurrentIndex(5)
except Exception as e:
QMessageBox.warning(self, "提示", f"生成题目失败:{str(e)}")
def _on_logout(self) -> None:
"""退出登录:清空会话并返回登录页。"""
self.session_email = None
self.login_identifier.clear()
self.login_password.clear()
QMessageBox.information(self, "提示", "已退出登录")
self.stack.setCurrentIndex(0)
def _build_quiz_page(self) -> None:
"""构建答题页面:显示题目和选项。"""
page = QWidget()
layout = QVBoxLayout(page)
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# 进度显示
self.progress_label = QLabel()
self.progress_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.progress_label.setStyleSheet("font-size: 16px; margin: 10px 0;")
layout.addWidget(self.progress_label)
# 题目显示
self.question_label = QLabel()
self.question_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.question_label.setStyleSheet("font-size: 18px; margin: 20px 0; padding: 10px; border: 1px solid #ccc;")
self.question_label.setWordWrap(True)
layout.addWidget(self.question_label)
# 选项
self.option_group = QButtonGroup()
self.option_radios = []
for i in range(4):
radio = QRadioButton()
radio.setStyleSheet("font-size: 16px; margin: 5px 0;")
self.option_group.addButton(radio, i)
self.option_radios.append(radio)
layout.addWidget(radio)
# 按钮
btn_row = QHBoxLayout()
self.btn_prev = QPushButton("上一题")
self.btn_next = QPushButton("下一题")
self.btn_submit = QPushButton("提交答案")
btn_row.addWidget(self.btn_prev)
btn_row.addWidget(self.btn_next)
btn_row.addWidget(self.btn_submit)
layout.addLayout(btn_row)
self.btn_prev.clicked.connect(self._on_prev_question)
self.btn_next.clicked.connect(self._on_next_question)
self.btn_submit.clicked.connect(self._on_submit_quiz)
self.stack.addWidget(page)
def _show_current_question(self) -> None:
"""显示当前题目。"""
if not hasattr(self, 'questions') or not self.questions:
return
question = self.questions[self.current_question]
total = len(self.questions)
# 更新进度
self.progress_label.setText(f"{self.current_question + 1} 题 / 共 {total}")
# 更新题目
self.question_label.setText(question['stem'])
# 更新选项
for i, option in enumerate(question['options']):
self.option_radios[i].setText(f"{chr(65+i)}. {option}")
# 恢复之前的选择
if self.current_question < len(self.user_answers):
selected = self.user_answers[self.current_question]
if selected is not None:
self.option_radios[selected].setChecked(True)
else:
# 清空选择
for radio in self.option_radios:
radio.setChecked(False)
# 更新按钮状态
self.btn_prev.setEnabled(self.current_question > 0)
self.btn_next.setEnabled(self.current_question < total - 1)
def _on_prev_question(self) -> None:
"""上一题。"""
self._save_current_answer()
if self.current_question > 0:
self.current_question -= 1
self._show_current_question()
def _on_next_question(self) -> None:
"""下一题。"""
self._save_current_answer()
if self.current_question < len(self.questions) - 1:
self.current_question += 1
self._show_current_question()
def _save_current_answer(self) -> None:
"""保存当前题目的答案。"""
selected = self.option_group.checkedId()
# 确保user_answers列表足够长
while len(self.user_answers) <= self.current_question:
self.user_answers.append(None)
self.user_answers[self.current_question] = selected if selected >= 0 else None
def _on_submit_quiz(self) -> None:
"""提交答案并计算分数。"""
self._save_current_answer()
# 检查是否有未答题目
unanswered = []
for i, answer in enumerate(self.user_answers):
if answer is None:
unanswered.append(i + 1)
if unanswered:
reply = QMessageBox.question(
self, "提示",
f"还有第 {', '.join(map(str, unanswered))} 题未作答,确定要提交吗?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.No:
return
# 计算分数
correct = 0
total = len(self.questions)
for i, question in enumerate(self.questions):
if i < len(self.user_answers) and self.user_answers[i] == question['answer_index']:
correct += 1
self.score = correct
self.total_questions = total
self._show_result()
self.stack.setCurrentIndex(6)
def _build_result_page(self) -> None:
"""构建结果页面:显示分数和操作选项。"""
page = QWidget()
layout = QVBoxLayout(page)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
title = QLabel("答题结果")
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
title.setStyleSheet("font-size: 22px; font-weight: bold; margin: 16px 0;")
layout.addWidget(title)
self.score_label = QLabel()
self.score_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.score_label.setStyleSheet("font-size: 24px; margin: 20px 0; color: #2196F3;")
layout.addWidget(self.score_label)
btn_row = QHBoxLayout()
self.btn_continue = QPushButton("继续做题")
self.btn_exit = QPushButton("退出")
btn_row.addWidget(self.btn_continue)
btn_row.addWidget(self.btn_exit)
layout.addLayout(btn_row)
self.btn_continue.clicked.connect(lambda: self.stack.setCurrentIndex(4))
self.btn_exit.clicked.connect(self._on_logout)
self.stack.addWidget(page)
def _show_result(self) -> None:
"""显示答题结果。"""
if hasattr(self, 'score') and hasattr(self, 'total_questions'):
percentage = (self.score / self.total_questions) * 100
self.score_label.setText(
f"您答对了 {self.score} 题,共 {self.total_questions}\n"
f"正确率:{percentage:.1f}%"
)
def _build_change_password_page(self) -> None:
"""构建修改密码页面。"""
page = QWidget()
layout = QVBoxLayout(page)
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
title = QLabel("修改密码")
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
title.setStyleSheet("font-size: 22px; font-weight: bold; margin: 16px 0;")
layout.addWidget(title)
self.old_password = QLineEdit()
self.old_password.setPlaceholderText("请输入原密码")
self.old_password.setEchoMode(QLineEdit.EchoMode.Password)
layout.addWidget(self.old_password)
self.new_password = QLineEdit()
self.new_password.setPlaceholderText("请输入新密码6-10位包含大小写字母和数字")
self.new_password.setEchoMode(QLineEdit.EchoMode.Password)
layout.addWidget(self.new_password)
self.confirm_new_password = QLineEdit()
self.confirm_new_password.setPlaceholderText("请确认新密码")
self.confirm_new_password.setEchoMode(QLineEdit.EchoMode.Password)
layout.addWidget(self.confirm_new_password)
btn_row = QHBoxLayout()
self.btn_change_pwd = QPushButton("修改密码")
self.btn_back_to_choice = QPushButton("返回")
btn_row.addWidget(self.btn_change_pwd)
btn_row.addWidget(self.btn_back_to_choice)
layout.addLayout(btn_row)
self.btn_change_pwd.clicked.connect(self._on_change_password)
self.btn_back_to_choice.clicked.connect(lambda: self.stack.setCurrentIndex(4))
self.stack.addWidget(page)
def _on_change_password(self) -> None:
"""处理修改密码逻辑。"""
old_pwd = self.old_password.text() or ""
new_pwd = self.new_password.text() or ""
confirm_pwd = self.confirm_new_password.text() or ""
if not old_pwd or not new_pwd or not confirm_pwd:
QMessageBox.warning(self, "提示", "请填写完整信息")
return
if not self.session_email:
QMessageBox.warning(self, "提示", "会话已过期,请重新登录")
self.stack.setCurrentIndex(0)
return
ok, msg = self.user_service.change_password(self.session_email, old_pwd, new_pwd, confirm_pwd)
if ok:
QMessageBox.information(self, "提示", "密码修改成功")
# 清空表单并返回选择页
self.old_password.clear()
self.new_password.clear()
self.confirm_new_password.clear()
self.stack.setCurrentIndex(4)
else:
QMessageBox.warning(self, "提示", msg)

@ -0,0 +1,79 @@
"""
安全工具模块提供邮箱校验密码强度校验验证码生成密码哈希与校验
遵循 Google Python 风格指南所有函数均提供中文函数级注释
"""
import re
import secrets
import string
import hashlib
from typing import Tuple
def validate_email(email: str) -> bool:
"""验证邮箱格式是否正确。
参数:
email: 待验证的邮箱字符串
返回:
True 表示格式合法False 表示格式不合法
"""
pattern = r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$"
return re.match(pattern, email) is not None
def validate_password_strength(password: str) -> bool:
"""验证密码是否满足强度要求6-10位必须包含大小写字母和数字。
参数:
password: 待验证的密码字符串
返回:
True 表示满足要求False 表示不满足
"""
if not (6 <= len(password) <= 10):
return False
has_upper = any(c.isupper() for c in password)
has_lower = any(c.islower() for c in password)
has_digit = any(c.isdigit() for c in password)
return has_upper and has_lower and has_digit
def generate_verification_code(length: int = 6) -> str:
"""生成数字验证码字符串。
参数:
length: 验证码长度默认为 6
返回:
由数字组成的验证码字符串
"""
return ''.join(secrets.choice(string.digits) for _ in range(length))
def hash_password(password: str, salt: bytes | None = None) -> Tuple[bytes, bytes]:
"""对密码进行 PBKDF2 哈希。
参数:
password: 原始密码
salt: 可选的盐值若未提供则自动生成
返回:
(salt, pwd_hash) 二元组其中 salt 为随机盐pwd_hash 为哈希值
"""
if salt is None:
salt = secrets.token_bytes(16)
pwd_hash = hashlib.pbkdf2_hmac(
'sha256', password.encode('utf-8'), salt, 100_000
)
return salt, pwd_hash
def verify_password(password: str, salt: bytes, pwd_hash: bytes) -> bool:
"""校验密码是否与存储的哈希匹配。
参数:
password: 用户输入的密码
salt: 存储的盐值
pwd_hash: 存储的密码哈希
返回:
True 表示匹配False 表示不匹配
"""
calc_hash = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100_000)
return secrets.compare_digest(calc_hash, pwd_hash)
Loading…
Cancel
Save