diff --git a/README.md b/README.md deleted file mode 100644 index f2b9cdd..0000000 --- a/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# math-learing - diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..1c15ac9 --- /dev/null +++ b/pom.xml @@ -0,0 +1,150 @@ + + + 4.0.0 + + com.personalproject + math-learning + 1.0-SNAPSHOT + jar + + Math Learning Application + A desktop application for math learning with exam functionality + + + 17 + 17 + UTF-8 + 21 + 0.0.8 + + + + + + org.openjfx + javafx-controls + ${javafx.version} + + + org.openjfx + javafx-fxml + ${javafx.version} + + + + + com.sun.mail + jakarta.mail + 2.0.1 + + + + + org.junit.jupiter + junit-jupiter-api + 5.9.2 + test + + + org.junit.jupiter + junit-jupiter-engine + 5.9.2 + test + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 17 + 17 + + + + + + org.openjfx + javafx-maven-plugin + ${javafx.maven.plugin.version} + + com.personalproject.ui.MathExamGui + + + + + + + + + + default-cli + + com.personalproject.ui.MathExamGui + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0 + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + com.personalproject.ui.MathExamGui + + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.4.1 + + + package + + shade + + + + + com.personalproject.ui.MathExamGui + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + diff --git a/src/main/java/com/personalproject/MathExamApplication.java b/src/main/java/com/personalproject/MathExamApplication.java new file mode 100644 index 0000000..3298df1 --- /dev/null +++ b/src/main/java/com/personalproject/MathExamApplication.java @@ -0,0 +1,19 @@ +package com.personalproject; + +import com.personalproject.ui.MathExamGui; + +/** + * 数学学习软件主应用程序入口. 这个类现在启动JavaFX GUI应用程序. + */ +public final class MathExamApplication { + + /** + * 程序入口:启动JavaFX GUI应用程序. + * + * @param args 命令行参数,当前未使用. + */ + public static void main(String[] args) { + // 启动JavaFX应用程序 + MathExamGui.main(args); + } +} diff --git a/src/main/java/com/personalproject/auth/AccountRepository.java b/src/main/java/com/personalproject/auth/AccountRepository.java new file mode 100644 index 0000000..e30df5b --- /dev/null +++ b/src/main/java/com/personalproject/auth/AccountRepository.java @@ -0,0 +1,324 @@ +package com.personalproject.auth; + +import com.personalproject.model.DifficultyLevel; +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 存放用户账号并提供认证和注册查询能力. + */ +public final class AccountRepository { + + private static final Path ACCOUNTS_DIRECTORY = Paths.get("user_data", "accounts"); + private static final String FILE_EXTENSION = ".properties"; + + private final Map accounts = new ConcurrentHashMap<>(); + + /** + * 创建账号仓库并立即加载磁盘中的账号数据. + */ + public AccountRepository() { + loadAccounts(); + } + + /** + * 根据用户名与密码查找匹配的账号. + * + * @param username 用户名. + * @param password 密码. + * @return 匹配成功时返回账号信息,否则返回空结果. + */ + public Optional authenticate(String username, String password) { + if (username == null || password == null) { + return Optional.empty(); + } + String normalizedUsername = username.trim(); + String normalizedPassword = password.trim(); + UserAccount account = accounts.get(normalizedUsername); + if (account == null || !account.isRegistered()) { + return Optional.empty(); + } + if (!account.password().equals(hashPassword(normalizedPassword))) { + return Optional.empty(); + } + return Optional.of(account); + } + + /** + * 使用电子邮箱注册新用户账号. + * + * @param username 用户名 + * @param email 邮箱地址 + * @param difficultyLevel 选择的难度级别 + * @return 若注册成功则返回 true,若用户名已存在则返回 false + */ + public synchronized boolean registerUser(String username, String email, + DifficultyLevel difficultyLevel) { + if (username == null || email == null || difficultyLevel == null) { + return false; + } + + String normalizedUsername = username.trim(); + String normalizedEmail = email.trim(); + if (normalizedUsername.isEmpty() || normalizedEmail.isEmpty()) { + return false; + } + + UserAccount existing = accounts.get(normalizedUsername); + if (existing != null && existing.isRegistered()) { + return false; + } + + if (isEmailInUse(normalizedEmail, normalizedUsername)) { + return false; + } + + LocalDateTime registrationDate = + existing != null ? existing.registrationDate() : LocalDateTime.now(); + UserAccount account = new UserAccount( + normalizedUsername, + normalizedEmail, + "", + difficultyLevel, + registrationDate, + false); + + accounts.put(normalizedUsername, account); + persistAccount(account); + return true; + } + + /** + * 在注册后为用户设置密码. + * + * @param username 用户名 + * @param password 要设置的密码 + * @return 设置成功返回 true,若用户不存在则返回 false + */ + public synchronized boolean setPassword(String username, String password) { + if (username == null || password == null) { + return false; + } + String normalizedUsername = username.trim(); + UserAccount account = accounts.get(normalizedUsername); + if (account == null || account.isRegistered()) { + return false; + } + + UserAccount updatedAccount = new UserAccount( + account.username(), + account.email(), + hashPassword(password.trim()), + account.difficultyLevel(), + account.registrationDate(), + true); + accounts.put(normalizedUsername, updatedAccount); + persistAccount(updatedAccount); + return true; + } + + /** + * 修改现有用户的密码. + * + * @param username 用户名 + * @param oldPassword 当前密码 + * @param newPassword 新密码 + * @return 修改成功返回 true,若旧密码错误或用户不存在则返回 false + */ + public synchronized boolean changePassword(String username, String oldPassword, + String newPassword) { + if (username == null || oldPassword == null || newPassword == null) { + return false; + } + String normalizedUsername = username.trim(); + UserAccount account = accounts.get(normalizedUsername); + if (account == null || !account.isRegistered()) { + return false; + } + + if (!account.password().equals(hashPassword(oldPassword.trim()))) { + return false; + } + + UserAccount updatedAccount = new UserAccount( + account.username(), + account.email(), + hashPassword(newPassword.trim()), + account.difficultyLevel(), + account.registrationDate(), + true); + accounts.put(normalizedUsername, updatedAccount); + persistAccount(updatedAccount); + return true; + } + + /** + * 移除未完成注册的用户,便于重新注册. + * + * @param username 待移除的用户名 + */ + public synchronized void removeUnverifiedUser(String username) { + if (username == null) { + return; + } + String normalizedUsername = username.trim(); + UserAccount account = accounts.get(normalizedUsername); + if (account != null && !account.isRegistered()) { + accounts.remove(normalizedUsername); + deleteAccountFile(normalizedUsername); + } + } + + /** + * 检查用户是否存在. + * + * @param username 待检查的用户名 + * @return 若存在则返回 true,否则返回 false + */ + public boolean userExists(String username) { + if (username == null) { + return false; + } + return accounts.containsKey(username.trim()); + } + + /** + * 按用户名获取用户账户. + * + * @param username 用户名 + * @return 若找到则返回包含用户账户的 Optional + */ + public Optional getUser(String username) { + if (username == null) { + return Optional.empty(); + } + return Optional.ofNullable(accounts.get(username.trim())); + } + + private void loadAccounts() { + try { + if (!Files.exists(ACCOUNTS_DIRECTORY)) { + Files.createDirectories(ACCOUNTS_DIRECTORY); + return; + } + + try (var paths = Files.list(ACCOUNTS_DIRECTORY)) { + paths + .filter(path -> path.getFileName().toString().endsWith(FILE_EXTENSION)) + .forEach(this::loadAccountFromFile); + } + } catch (IOException exception) { + throw new IllegalStateException("加载账户数据失败", exception); + } + } + + private void loadAccountFromFile(Path file) { + Properties properties = new Properties(); + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + properties.load(reader); + } catch (IOException exception) { + System.err.println("读取账户文件失败: " + file + ",原因: " + exception.getMessage()); + return; + } + + String username = properties.getProperty("username"); + if (username == null || username.isBlank()) { + return; + } + + String email = properties.getProperty("email", ""); + String passwordHash = properties.getProperty("passwordHash", ""); + String difficultyValue = properties.getProperty("difficulty", DifficultyLevel.PRIMARY.name()); + String registrationDateValue = properties.getProperty("registrationDate"); + String registeredValue = properties.getProperty("registered", "false"); + + try { + DifficultyLevel difficultyLevel = DifficultyLevel.valueOf(difficultyValue); + LocalDateTime registrationDate = registrationDateValue == null + ? LocalDateTime.now() + : LocalDateTime.parse(registrationDateValue); + boolean registered = Boolean.parseBoolean(registeredValue); + UserAccount account = new UserAccount( + username, + email, + passwordHash, + difficultyLevel, + registrationDate, + registered); + accounts.put(username, account); + } catch (Exception exception) { + System.err.println("解析账户文件失败: " + file + ",原因: " + exception.getMessage()); + } + } + + private void persistAccount(UserAccount account) { + Properties properties = new Properties(); + properties.setProperty("username", account.username()); + properties.setProperty("email", account.email()); + properties.setProperty("passwordHash", account.password()); + properties.setProperty("difficulty", account.difficultyLevel().name()); + properties.setProperty("registrationDate", account.registrationDate().toString()); + properties.setProperty("registered", Boolean.toString(account.isRegistered())); + + Path targetFile = accountFile(account.username()); + try { + if (!Files.exists(ACCOUNTS_DIRECTORY)) { + Files.createDirectories(ACCOUNTS_DIRECTORY); + } + try (Writer writer = Files.newBufferedWriter(targetFile, StandardCharsets.UTF_8)) { + properties.store(writer, "User account data"); + } + } catch (IOException exception) { + throw new IllegalStateException("保存账户数据失败: " + account.username(), exception); + } + } + + private void deleteAccountFile(String username) { + Path file = accountFile(username); + try { + Files.deleteIfExists(file); + } catch (IOException exception) { + System.err.println("删除账户文件失败: " + file + ",原因: " + exception.getMessage()); + } + } + + private boolean isEmailInUse(String email, String currentUsername) { + return accounts.values().stream() + .anyMatch(account -> account.email().equalsIgnoreCase(email) + && !account.username().equals(currentUsername)); + } + + private Path accountFile(String username) { + return ACCOUNTS_DIRECTORY.resolve(username + FILE_EXTENSION); + } + + private String hashPassword(String password) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(password.getBytes(StandardCharsets.UTF_8)); + return toHex(hashBytes); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("当前运行环境不支持SHA-256算法", exception); + } + } + + private String toHex(byte[] bytes) { + StringBuilder builder = new StringBuilder(bytes.length * 2); + for (byte value : bytes) { + builder.append(String.format("%02x", value)); + } + return builder.toString(); + } +} diff --git a/src/main/java/com/personalproject/auth/EmailService.java b/src/main/java/com/personalproject/auth/EmailService.java new file mode 100644 index 0000000..f12a81d --- /dev/null +++ b/src/main/java/com/personalproject/auth/EmailService.java @@ -0,0 +1,231 @@ +package com.personalproject.auth; + +import jakarta.mail.Authenticator; +import jakarta.mail.Message; +import jakarta.mail.MessagingException; +import jakarta.mail.PasswordAuthentication; +import jakarta.mail.Session; +import jakarta.mail.Transport; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.Random; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; + +/** + * 用于发送带有注册码的电子邮件的工具类. + */ +public final class EmailService { + + private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + private static final int CODE_LENGTH = 6; + private static final Random RANDOM = new Random(); + private static final Path OUTBOX_DIRECTORY = Paths.get("user_data", "emails"); + private static final String CONFIG_RESOURCE = "email-config.properties"; + private static final DateTimeFormatter DATE_TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); + private static final Map LAST_CODES = new ConcurrentHashMap<>(); + private static final AtomicReference CONFIG_CACHE = new AtomicReference<>(); + private static final String DEFAULT_SUBJECT = "数学学习软件注册验证码"; + + private EmailService() { + // 防止实例化此工具类 + } + + /** + * 生成随机注册码. + * + * @return 随机生成的注册码 + */ + public static String generateRegistrationCode() { + StringBuilder code = new StringBuilder(); + for (int i = 0; i < CODE_LENGTH; i++) { + code.append(CHARACTERS.charAt(RANDOM.nextInt(CHARACTERS.length()))); + } + return code.toString(); + } + + /** + * 将注册码发送到指定邮箱. + * + * @param email 收件人邮箱 + * @param registrationCode 要发送的注册码 + * @return 发送成功返回 true + */ + public static boolean sendRegistrationCode(String email, String registrationCode) { + try { + MailConfiguration configuration = loadConfiguration(); + sendEmail(email, registrationCode, configuration); + persistMessage(email, registrationCode); + LAST_CODES.put(email.trim().toLowerCase(), registrationCode); + return true; + } catch (Exception exception) { + System.err.println("发送验证码失败: " + exception.getMessage()); + return false; + } + } + + /** + * 获取最近一次发送到指定邮箱的验证码(便于调试或测试). + * + * @param email 收件人邮箱 + * @return 若存在则返回验证码 + */ + public static Optional getLastSentRegistrationCode(String email) { + if (email == null) { + return Optional.empty(); + } + return Optional.ofNullable(LAST_CODES.get(email.trim().toLowerCase())); + } + + /** + * 校验电子邮件地址的格式是否有效. + * + * @param email 待校验的邮箱地址 + * @return 若格式有效则返回 true,否则返回 false + */ + public static boolean isValidEmail(String email) { + if (email == null || email.trim().isEmpty()) { + return false; + } + + String emailRegex = "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@" + + "(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$"; + return email.matches(emailRegex); + } + + private static MailConfiguration loadConfiguration() throws IOException { + MailConfiguration cached = CONFIG_CACHE.get(); + if (cached != null) { + return cached; + } + + synchronized (CONFIG_CACHE) { + cached = CONFIG_CACHE.get(); + if (cached != null) { + return cached; + } + + Properties properties = new Properties(); + try (InputStream inputStream = + EmailService.class.getClassLoader().getResourceAsStream(CONFIG_RESOURCE)) { + if (inputStream == null) { + throw new IllegalStateException( + "类路径下缺少邮箱配置文件: " + CONFIG_RESOURCE + + ",请在 src/main/resources 下提供该文件"); + } + try (InputStreamReader reader = + new InputStreamReader(inputStream, StandardCharsets.UTF_8)) { + properties.load(reader); + } + } + + String username = require(properties, "mail.username"); + final String password = require(properties, "mail.password"); + final String from = properties.getProperty("mail.from", username); + final String subject = properties.getProperty("mail.subject", DEFAULT_SUBJECT); + + Properties smtpProperties = new Properties(); + for (Map.Entry entry : properties.entrySet()) { + String key = entry.getKey().toString(); + if (key.startsWith("mail.smtp.")) { + smtpProperties.put(key, entry.getValue()); + } + } + + if (!smtpProperties.containsKey("mail.smtp.host")) { + throw new IllegalStateException("邮箱配置缺少 mail.smtp.host"); + } + if (!smtpProperties.containsKey("mail.smtp.port")) { + throw new IllegalStateException("邮箱配置缺少 mail.smtp.port"); + } + + smtpProperties.putIfAbsent("mail.smtp.auth", "true"); + smtpProperties.putIfAbsent("mail.smtp.starttls.enable", "true"); + + MailConfiguration configuration = + new MailConfiguration(smtpProperties, username, password, from, subject); + CONFIG_CACHE.set(configuration); + return configuration; + } + } + + private static void sendEmail( + String recipientEmail, String registrationCode, MailConfiguration configuration) + throws MessagingException { + Session session = Session.getInstance(configuration.smtpProperties(), new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + return new PasswordAuthentication(configuration.username(), configuration.password()); + } + }); + + MimeMessage message = new MimeMessage(session); + message.setFrom(new InternetAddress(configuration.from())); + message.setRecipients( + Message.RecipientType.TO, + InternetAddress.parse(recipientEmail, false)); + message.setSubject(configuration.subject()); + + String body = "您好,您的注册码为:" + registrationCode + System.lineSeparator() + + "请在10分钟内使用该验证码完成注册。"; + message.setText(body, StandardCharsets.UTF_8.name()); + + Transport.send(message); + } + + private static void persistMessage(String email, String registrationCode) throws IOException { + if (!Files.exists(OUTBOX_DIRECTORY)) { + Files.createDirectories(OUTBOX_DIRECTORY); + } + + String sanitizedEmail = sanitizeEmail(email); + String timestamp = DATE_TIME_FORMATTER.format(LocalDateTime.now()); + final Path messageFile = OUTBOX_DIRECTORY.resolve(sanitizedEmail + "_" + timestamp + ".txt"); + StringBuilder content = new StringBuilder(); + content.append("收件人: ").append(email).append(System.lineSeparator()); + content.append("注册码: ").append(registrationCode).append(System.lineSeparator()); + content.append("发送时间: ").append(LocalDateTime.now()).append(System.lineSeparator()); + + Files.writeString( + messageFile, + content.toString(), + StandardCharsets.UTF_8, + StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE); + } + + private static String require(Properties properties, String key) { + String value = properties.getProperty(key); + if (value == null || value.trim().isEmpty()) { + throw new IllegalStateException("邮箱配置缺少必要字段: " + key); + } + return value.trim(); + } + + private static String sanitizeEmail(String email) { + return email.replaceAll("[^a-zA-Z0-9._-]", "_"); + } + + private record MailConfiguration( + Properties smtpProperties, + String username, + String password, + String from, + String subject) { + + } +} diff --git a/src/main/java/com/personalproject/auth/PasswordValidator.java b/src/main/java/com/personalproject/auth/PasswordValidator.java new file mode 100644 index 0000000..e8196af --- /dev/null +++ b/src/main/java/com/personalproject/auth/PasswordValidator.java @@ -0,0 +1,37 @@ +package com.personalproject.auth; + +import java.util.regex.Pattern; + +/** + * 用于验证密码规则的工具类. + */ +public final class PasswordValidator { + + private static final Pattern PASSWORD_PATTERN = + Pattern.compile("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{6,10}$"); + + private PasswordValidator() { + // 防止实例化此工具类 + } + + /** + * 校验密码是否满足以下要求. + * - 长度 6-10 位 + * - 至少包含一个大写字母 + * - 至少包含一个小写字母 + * - 至少包含一个数字 + * + * @param password 待校验的密码 + * @return 若满足要求则返回 true,否则返回 false + */ + public static boolean isValidPassword(String password) { + if (password == null) { + return false; + } + String normalized = password.trim(); + if (normalized.isEmpty()) { + return false; + } + return PASSWORD_PATTERN.matcher(normalized).matches(); + } +} diff --git a/src/main/java/com/personalproject/auth/UserAccount.java b/src/main/java/com/personalproject/auth/UserAccount.java new file mode 100644 index 0000000..3900693 --- /dev/null +++ b/src/main/java/com/personalproject/auth/UserAccount.java @@ -0,0 +1,40 @@ +package com.personalproject.auth; + +import com.personalproject.model.DifficultyLevel; +import java.time.LocalDateTime; + +/** + * 不可变的账号定义. + */ +public record UserAccount( + String username, + String email, + String password, + DifficultyLevel difficultyLevel, + LocalDateTime registrationDate, + boolean isRegistered) { + + /** + * 创建新的用户账户,并使用当前时间作为注册时间. + * + * @param username 用户名 + * @param email 邮箱地址 + * @param password 密码 + * @param difficultyLevel 选择的难度级别 + * @param isRegistered 用户是否已完成注册 + */ + public UserAccount { + if (username == null || username.trim().isEmpty()) { + throw new IllegalArgumentException("Username cannot be null or empty"); + } + if (email == null || email.trim().isEmpty()) { + throw new IllegalArgumentException("Email cannot be null or empty"); + } + if (password == null) { + throw new IllegalArgumentException("Password cannot be null"); + } + if (difficultyLevel == null) { + throw new IllegalArgumentException("Difficulty level cannot be null"); + } + } +} diff --git a/src/main/java/com/personalproject/controller/MathLearningController.java b/src/main/java/com/personalproject/controller/MathLearningController.java new file mode 100644 index 0000000..0a5fe61 --- /dev/null +++ b/src/main/java/com/personalproject/controller/MathLearningController.java @@ -0,0 +1,155 @@ +package com.personalproject.controller; + +import com.personalproject.generator.QuestionGenerator; +import com.personalproject.model.DifficultyLevel; +import com.personalproject.model.ExamSession; +import com.personalproject.service.ExamResultService; +import com.personalproject.service.MathLearningService; +import com.personalproject.service.QuestionGenerationService; +import java.util.Map; +import java.util.Optional; + +/** + * 遵循MVC模式处理应用程序逻辑的控制器类. + */ +public final class MathLearningController { + + private final MathLearningService mathLearningService; + + /** + * 创建具有所需服务的新控制器. + * + * @param generatorMap 难度级别到题目生成器的映射 + * @param questionGenerationService 题目生成服务 + */ + public MathLearningController( + Map generatorMap, + QuestionGenerationService questionGenerationService) { + this.mathLearningService = new MathLearningService(generatorMap, questionGenerationService); + } + + /** + * 启动用户注册. + * + * @param username 所需用户名 + * @param email 用户的电子邮箱地址 + * @param difficultyLevel 选择的难度级别 + * @return 注册成功启动则返回true,否则返回false + */ + public boolean initiateRegistration(String username, String email, + DifficultyLevel difficultyLevel) { + return mathLearningService.initiateRegistration(username, email, difficultyLevel); + } + + /** + * 验证注册码. + * + * @param username 用户名 + * @param registrationCode 注册码 + * @return 码有效则返回true,否则返回false + */ + public boolean verifyRegistrationCode(String username, String registrationCode) { + return mathLearningService.verifyRegistrationCode(username, registrationCode); + } + + /** + * 注册后设置用户密码. + * + * @param username 用户名 + * @param password 要设置的密码 + * @return 密码设置成功则返回true,否则返回false + */ + public boolean setPassword(String username, String password) { + return mathLearningService.setPassword(username, password); + } + + /** + * 验证用户. + * + * @param username 用户名 + * @param password 密码 + * @return 验证成功则返回包含用户账户的Optional + */ + public Optional authenticate(String username, + String password) { + return mathLearningService.authenticate(username, password); + } + + /** + * 为用户创建考试会话. + * + * @param username 用户名 + * @param difficultyLevel 难度级别 + * @param questionCount 题目数量 + * @return 创建的考试会话 + */ + public ExamSession createExamSession(String username, DifficultyLevel difficultyLevel, + int questionCount) { + return mathLearningService.createExamSession(username, difficultyLevel, questionCount); + } + + /** + * 保存考试结果. + * + * @param examSession 要保存的考试会话 + */ + public void saveExamResults(ExamSession examSession) { + mathLearningService.saveExamResults(examSession); + } + + /** + * 处理考试结果并确定下一步操作. + * + * @param examSession 完成的考试会话 + * @param continueWithSameLevel 是否继续相同难度 + * @param newDifficultyLevel 更改时的新难度(如果不更改则为null) + * @return 要执行的下一步操作 + */ + public ExamResultService.ExamContinuationAction processExamResult( + ExamSession examSession, boolean continueWithSameLevel, DifficultyLevel newDifficultyLevel) { + return mathLearningService.processExamResult(examSession, continueWithSameLevel, + newDifficultyLevel); + } + + /** + * 更改用户密码. + * + * @param username 用户名 + * @param oldPassword 旧密码 + * @param newPassword 新密码 + * @return 密码更改成功则返回true,否则返回false + */ + public boolean changePassword(String username, String oldPassword, String newPassword) { + return mathLearningService.changePassword(username, oldPassword, newPassword); + } + + /** + * 验证密码. + * + * @param password 要验证的密码 + * @return 密码有效则返回true,否则返回false + */ + public boolean isValidPassword(String password) { + return MathLearningService.isValidPassword(password); + } + + /** + * 验证电子邮箱地址. + * + * @param email 要验证的邮箱 + * @return 邮箱有效则返回true,否则返回false + */ + public boolean isValidEmail(String email) { + return MathLearningService.isValidEmail(email); + } + + /** + * 按用户名获取用户账户. + * + * @param username 用户名 + * @return 若找到则返回包含用户账户的 Optional + */ + public Optional getUserAccount(String username) { + return mathLearningService.getUser(username); + } +} diff --git a/src/main/java/com/personalproject/generator/HighSchoolQuestionGenerator.java b/src/main/java/com/personalproject/generator/HighSchoolQuestionGenerator.java new file mode 100644 index 0000000..d22bf53 --- /dev/null +++ b/src/main/java/com/personalproject/generator/HighSchoolQuestionGenerator.java @@ -0,0 +1,37 @@ +package com.personalproject.generator; + +import java.util.Random; + +/** + * 生成至少包含一种三角函数的高中难度题目表达式. + */ +public final class HighSchoolQuestionGenerator implements QuestionGenerator { + + private static final String[] OPERATORS = {"+", "-", "*", "/"}; + private static final String[] TRIG_FUNCTIONS = {"sin", "cos", "tan"}; + + @Override + public String generateQuestion(Random random) { + int operandCount = random.nextInt(5) + 1; + String[] operands = new String[operandCount]; + for (int index = 0; index < operandCount; index++) { + operands[index] = String.valueOf(random.nextInt(100) + 1); + } + int specialIndex = random.nextInt(operandCount); + String function = TRIG_FUNCTIONS[random.nextInt(TRIG_FUNCTIONS.length)]; + operands[specialIndex] = function + '(' + operands[specialIndex] + ')'; + StringBuilder builder = new StringBuilder(); + for (int index = 0; index < operandCount; index++) { + if (index > 0) { + String operator = OPERATORS[random.nextInt(OPERATORS.length)]; + builder.append(' ').append(operator).append(' '); + } + builder.append(operands[index]); + } + String expression = builder.toString(); + if (operandCount > 1 && random.nextBoolean()) { + return '(' + expression + ')'; + } + return expression; + } +} diff --git a/src/main/java/com/personalproject/generator/MiddleSchoolQuestionGenerator.java b/src/main/java/com/personalproject/generator/MiddleSchoolQuestionGenerator.java new file mode 100644 index 0000000..ad46678 --- /dev/null +++ b/src/main/java/com/personalproject/generator/MiddleSchoolQuestionGenerator.java @@ -0,0 +1,39 @@ +package com.personalproject.generator; + +import java.util.Random; + +/** + * 生成包含平方或开根号运算的初中难度题目表达式. + */ +public final class MiddleSchoolQuestionGenerator implements QuestionGenerator { + + private static final String[] OPERATORS = {"+", "-", "*", "/"}; + + @Override + public String generateQuestion(Random random) { + int operandCount = random.nextInt(5) + 1; + String[] operands = new String[operandCount]; + for (int index = 0; index < operandCount; index++) { + operands[index] = String.valueOf(random.nextInt(100) + 1); + } + int specialIndex = random.nextInt(operandCount); + if (random.nextBoolean()) { + operands[specialIndex] = '(' + operands[specialIndex] + ")^2"; + } else { + operands[specialIndex] = "sqrt(" + operands[specialIndex] + ')'; + } + StringBuilder builder = new StringBuilder(); + for (int index = 0; index < operandCount; index++) { + if (index > 0) { + String operator = OPERATORS[random.nextInt(OPERATORS.length)]; + builder.append(' ').append(operator).append(' '); + } + builder.append(operands[index]); + } + String expression = builder.toString(); + if (operandCount > 1 && random.nextBoolean()) { + return '(' + expression + ')'; + } + return expression; + } +} diff --git a/src/main/java/com/personalproject/generator/PrimaryQuestionGenerator.java b/src/main/java/com/personalproject/generator/PrimaryQuestionGenerator.java new file mode 100644 index 0000000..6c1536b --- /dev/null +++ b/src/main/java/com/personalproject/generator/PrimaryQuestionGenerator.java @@ -0,0 +1,119 @@ +package com.personalproject.generator; + +import com.personalproject.model.QuizQuestion; +import com.personalproject.service.MathExpressionEvaluator; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; + +/** + * 生成包含基础四则运算的小学难度题目表达式. + */ +public final class PrimaryQuestionGenerator implements QuestionGenerator, QuizQuestionGenerator { + + private static final String[] OPERATORS = {"+", "-", "*", "/"}; + private static final int OPTIONS_COUNT = 4; + + @Override + public String generateQuestion(Random random) { + // 至少生成两个操作数,避免题目退化成单个数字 + int operandCount = random.nextInt(4) + 2; + StringBuilder builder = new StringBuilder(); + for (int index = 0; index < operandCount; index++) { + if (index > 0) { + String operator = OPERATORS[random.nextInt(OPERATORS.length)]; + builder.append(' ').append(operator).append(' '); + } + int value = random.nextInt(100) + 1; + builder.append(value); + } + String expression = builder.toString(); + if (operandCount > 1 && random.nextBoolean()) { + return '(' + expression + ')'; + } + return expression; + } + + @Override + public QuizQuestion generateQuizQuestion(Random random) { + // 生成数学表达式 + int operandCount = random.nextInt(4) + 2; + StringBuilder builder = new StringBuilder(); + for (int index = 0; index < operandCount; index++) { + if (index > 0) { + String operator = OPERATORS[random.nextInt(OPERATORS.length)]; + builder.append(' ').append(operator).append(' '); + } + int value = random.nextInt(100) + 1; + builder.append(value); + } + String expression = builder.toString(); + if (operandCount > 1 && random.nextBoolean()) { + expression = '(' + expression + ')'; + } + + // 直接计算正确答案 + double correctAnswer; + try { + correctAnswer = MathExpressionEvaluator.evaluate(expression); + } catch (Exception e) { + // 如果计算失败则使用兜底值 + correctAnswer = 0.0; + } + + List options = generateOptions(correctAnswer, random); + String correctOption = formatOption(correctAnswer); + int correctAnswerIndex = options.indexOf(correctOption); + if (correctAnswerIndex < 0) { + // 兜底逻辑:确保正确答案存在于选项中 + options.set(0, correctOption); + correctAnswerIndex = 0; + } + + return new QuizQuestion(expression, options, correctAnswerIndex); + } + + /** + * 生成选择题选项. + */ + private List generateOptions(double correctAnswer, Random random) { + String correctOption = formatOption(correctAnswer); + Set optionSet = new LinkedHashSet<>(); + optionSet.add(correctOption); + + double scale = Math.max(Math.abs(correctAnswer) * 0.2, 1.0); + int attempts = 0; + while (optionSet.size() < OPTIONS_COUNT && attempts < 100) { + double delta = random.nextGaussian() * scale; + if (Math.abs(delta) < 0.5) { + double direction = random.nextBoolean() ? 1 : -1; + delta = direction * (0.5 + random.nextDouble()) * scale; + } + double candidate = correctAnswer + delta; + if (Double.isNaN(candidate) || Double.isInfinite(candidate)) { + attempts++; + continue; + } + optionSet.add(formatOption(candidate)); + attempts++; + } + + double step = Math.max(scale, 1.0); + while (optionSet.size() < OPTIONS_COUNT) { + double candidate = correctAnswer + step * optionSet.size(); + optionSet.add(formatOption(candidate)); + step += 1.0; + } + + List options = new ArrayList<>(optionSet); + Collections.shuffle(options, random); + return options; + } + + private String formatOption(double value) { + return String.format("%.2f", value); + } +} diff --git a/src/main/java/com/personalproject/generator/QuestionGenerator.java b/src/main/java/com/personalproject/generator/QuestionGenerator.java new file mode 100644 index 0000000..a2a5ea7 --- /dev/null +++ b/src/main/java/com/personalproject/generator/QuestionGenerator.java @@ -0,0 +1,17 @@ +package com.personalproject.generator; + +import java.util.Random; + +/** + * 负责生成单条数学题目的表达式. + */ +public interface QuestionGenerator { + + /** + * 基于提供的随机数生成器构造一道题目的表达式. + * + * @param random 用于生成随机数的实例. + * @return 生成的题目表达式. + */ + String generateQuestion(Random random); +} diff --git a/src/main/java/com/personalproject/generator/QuizQuestionGenerator.java b/src/main/java/com/personalproject/generator/QuizQuestionGenerator.java new file mode 100644 index 0000000..7d52817 --- /dev/null +++ b/src/main/java/com/personalproject/generator/QuizQuestionGenerator.java @@ -0,0 +1,18 @@ +package com.personalproject.generator; + +import com.personalproject.model.QuizQuestion; +import java.util.Random; + +/** + * 负责生成带答案的数学题目. + */ +public interface QuizQuestionGenerator { + + /** + * 基于提供的随机数生成器构造一道带答案的题目. + * + * @param random 用于生成随机数的实例. + * @return 生成的带答案的题目. + */ + QuizQuestion generateQuizQuestion(Random random); +} \ No newline at end of file diff --git a/src/main/java/com/personalproject/model/DifficultyLevel.java b/src/main/java/com/personalproject/model/DifficultyLevel.java new file mode 100644 index 0000000..bc654bb --- /dev/null +++ b/src/main/java/com/personalproject/model/DifficultyLevel.java @@ -0,0 +1,49 @@ +package com.personalproject.model; + +import java.util.Optional; + +/** + * 系统支持的出题难度级别. + */ +public enum DifficultyLevel { + PRIMARY("小学"), + MIDDLE("初中"), + HIGH("高中"); + + private final String displayName; + + DifficultyLevel(String displayName) { + this.displayName = displayName; + } + + /** + * 获取当前难度对应的展示名称. + * + * @return 难度的展示名称. + */ + public String getDisplayName() { + return displayName; + } + + /** + * 根据展示名称查找对应的难度枚举值. + * + * @param name 展示名称. + * @return 匹配到的难度枚举,可空返回. + */ + public static Optional fromDisplayName(String name) { + if (name == null) { + return Optional.empty(); + } + String trimmed = name.trim(); + if (trimmed.isEmpty()) { + return Optional.empty(); + } + for (DifficultyLevel level : values()) { + if (level.displayName.equals(trimmed)) { + return Optional.of(level); + } + } + return Optional.empty(); + } +} diff --git a/src/main/java/com/personalproject/model/ExamSession.java b/src/main/java/com/personalproject/model/ExamSession.java new file mode 100644 index 0000000..bce8b58 --- /dev/null +++ b/src/main/java/com/personalproject/model/ExamSession.java @@ -0,0 +1,255 @@ +package com.personalproject.model; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 表示用户的考试会话. + */ +public final class ExamSession { + + private final String username; + private final DifficultyLevel difficultyLevel; + private final List questions; + private final List userAnswers; + private final LocalDateTime startTime; + private int currentQuestionIndex; + + /** + * 创建新的考试会话. + * + * @param username 考生的用户名 + * @param difficultyLevel 考试的难度级别 + * @param questions 考试题目列表 + */ + public ExamSession(String username, DifficultyLevel difficultyLevel, + List questions) { + if (username == null || username.trim().isEmpty()) { + throw new IllegalArgumentException("Username cannot be null or empty"); + } + if (difficultyLevel == null) { + throw new IllegalArgumentException("Difficulty level cannot be null"); + } + if (questions == null || questions.isEmpty()) { + throw new IllegalArgumentException("Questions list cannot be null or empty"); + } + + this.username = username; + this.difficultyLevel = difficultyLevel; + this.questions = List.copyOf(questions); // 题目的不可变副本 + this.userAnswers = new ArrayList<>(Collections.nCopies(questions.size(), -1)); + this.startTime = LocalDateTime.now(); + this.currentQuestionIndex = 0; + } + + /** + * 获取考生的用户名. + * + * @return 用户名 + */ + public String getUsername() { + return username; + } + + /** + * 获取考试的难度级别. + * + * @return 考试难度级别 + */ + public DifficultyLevel getDifficultyLevel() { + return difficultyLevel; + } + + /** + * 获取考试中的题目列表. + * + * @return 不可修改的题目列表 + */ + public List getQuestions() { + return questions; + } + + /** + * 获取用户对各题的作答. + * + * @return 答案索引列表(-1 表示未选择) + */ + public List getUserAnswers() { + return List.copyOf(userAnswers); // 返回副本以防止被修改 + } + + /** + * 获取当前题目的索引. + * + * @return 当前题目索引 + */ + public int getCurrentQuestionIndex() { + return currentQuestionIndex; + } + + /** + * 为当前题目记录用户的答案. + * + * @param answerIndex 选中答案的索引 + */ + public void setAnswer(int answerIndex) { + if (currentQuestionIndex < 0 || currentQuestionIndex >= questions.size()) { + throw new IllegalStateException("No valid question at current index"); + } + int optionCount = questions.get(currentQuestionIndex).getOptions().size(); + if (answerIndex < 0 || answerIndex >= optionCount) { + throw new IllegalArgumentException("Invalid answer index"); + } + userAnswers.set(currentQuestionIndex, answerIndex); + } + + /** + * 跳转到下一题. + * + * @return 若成功跳转则返回 true,若已是最后一题则返回 false + */ + public boolean goToNextQuestion() { + if (currentQuestionIndex < questions.size() - 1) { + currentQuestionIndex++; + return true; + } + return false; + } + + /** + * 返回上一题. + * + * @return 若成功返回则返回 true,若已是第一题则返回 false + */ + public boolean goToPreviousQuestion() { + if (currentQuestionIndex > 0) { + currentQuestionIndex--; + return true; + } + return false; + } + + /** + * 检查考试是否完成(所有题目已作答或到达最后一题). + * + * @return 若考试已完成则返回 true,否则返回 false + */ + public boolean isComplete() { + return userAnswers.stream().allMatch(answer -> answer != -1); + } + + /** + * 获取当前题目. + * + * @return 当前的测验题 + */ + public QuizQuestion getCurrentQuestion() { + if (currentQuestionIndex < 0 || currentQuestionIndex >= questions.size()) { + throw new IllegalStateException("No valid question at current index"); + } + return questions.get(currentQuestionIndex); + } + + /** + * 获取指定题目的用户答案. + * + * @param questionIndex 题目索引 + * @return 用户答案的索引(未选择时为 -1) + */ + public int getUserAnswer(int questionIndex) { + if (questionIndex < 0 || questionIndex >= questions.size()) { + throw new IllegalArgumentException("Question index out of bounds"); + } + return userAnswers.get(questionIndex); + } + + /** + * 计算得分百分比. + * + * @return 0-100 范围内的得分百分比 + */ + public double calculateScore() { + int correctCount = 0; + for (int i = 0; i < questions.size(); i++) { + QuizQuestion question = questions.get(i); + int userAnswer = userAnswers.get(i); + if (userAnswer != -1 && question.isAnswerCorrect(userAnswer)) { + correctCount++; + } + } + return questions.isEmpty() ? 0.0 : (double) correctCount / questions.size() * 100.0; + } + + /** + * 获取考试中的题目总数. + * + * @return 题目总数 + */ + public int getTotalQuestions() { + return questions.size(); + } + + /** + * 检查指定题目是否已作答. + * + * @param questionIndex 题目索引 + * @return 若已作答则返回 true,否则返回 false + */ + public boolean hasAnswered(int questionIndex) { + if (questionIndex < 0 || questionIndex >= questions.size()) { + throw new IllegalArgumentException("Question index out of bounds"); + } + return userAnswers.get(questionIndex) != -1; + } + + /** + * 获取考试开始时间. + * + * @return 开始时间 + */ + public LocalDateTime getStartTime() { + return startTime; + } + + /** + * 获取答对的题目数量. + * + * @return 正确题目数量 + */ + public int getCorrectAnswersCount() { + int correctCount = 0; + for (int i = 0; i < questions.size(); i++) { + QuizQuestion question = questions.get(i); + int userAnswer = userAnswers.get(i); + if (userAnswer != -1 && question.isAnswerCorrect(userAnswer)) { + correctCount++; + } + } + return correctCount; + } + + /** + * 获取答错的题目数量. + * + * @return 错误题目数量 + */ + public int getIncorrectAnswersCount() { + int totalAnswered = 0; + int correctCount = 0; + + for (int i = 0; i < questions.size(); i++) { + int userAnswer = userAnswers.get(i); + if (userAnswer != -1) { + totalAnswered++; + QuizQuestion question = questions.get(i); + if (question.isAnswerCorrect(userAnswer)) { + correctCount++; + } + } + } + + return totalAnswered - correctCount; + } +} diff --git a/src/main/java/com/personalproject/model/QuizQuestion.java b/src/main/java/com/personalproject/model/QuizQuestion.java new file mode 100644 index 0000000..cb6c8c9 --- /dev/null +++ b/src/main/java/com/personalproject/model/QuizQuestion.java @@ -0,0 +1,73 @@ +package com.personalproject.model; + +import java.util.List; + +/** + * 表示一个带有多项选择的测验题目. + */ +public final class QuizQuestion { + + private final String questionText; + private final List options; + private final int correctAnswerIndex; + + /** + * 创建新的测验题目. + * + * @param questionText 题目文本 + * @param options 答案选项列表 + * @param correctAnswerIndex 正确答案在选项列表中的索引 + */ + public QuizQuestion(String questionText, List options, int correctAnswerIndex) { + if (questionText == null || questionText.trim().isEmpty()) { + throw new IllegalArgumentException("Question text cannot be null or empty"); + } + if (options == null || options.size() < 2) { + throw new IllegalArgumentException("Options must contain at least 2 choices"); + } + if (correctAnswerIndex < 0 || correctAnswerIndex >= options.size()) { + throw new IllegalArgumentException("Correct answer index out of bounds"); + } + + this.questionText = questionText; + this.options = List.copyOf(options); // 不可变副本 + this.correctAnswerIndex = correctAnswerIndex; + } + + /** + * 获取题目文本. + * + * @return 题目文本 + */ + public String getQuestionText() { + return questionText; + } + + /** + * 获取答案选项列表. + * + * @return 不可修改的答案选项列表 + */ + public List getOptions() { + return options; + } + + /** + * 获取正确答案在选项列表中的索引. + * + * @return 正确答案的索引 + */ + public int getCorrectAnswerIndex() { + return correctAnswerIndex; + } + + /** + * 检查给定的答案索引是否正确. + * + * @param answerIndex 用户答案的索引 + * @return 若答案正确则返回 true,否则返回 false + */ + public boolean isAnswerCorrect(int answerIndex) { + return answerIndex == correctAnswerIndex; + } +} diff --git a/src/main/java/com/personalproject/service/ExamResultService.java b/src/main/java/com/personalproject/service/ExamResultService.java new file mode 100644 index 0000000..55a79f6 --- /dev/null +++ b/src/main/java/com/personalproject/service/ExamResultService.java @@ -0,0 +1,83 @@ +package com.personalproject.service; + +import com.personalproject.model.DifficultyLevel; +import com.personalproject.model.ExamSession; + +/** + * 用于处理考试后选项(如继续或退出)的服务. + */ +public final class ExamResultService { + + /** + * 评估用户在完成考试后的选择. + * + * @param examSession 完成的考试会话 + * @param continueWithSameLevel 是否使用相同难度级别继续 + * @param newDifficultyLevel 更改时的新难度级别(如果不更改则为null) + * @return 指示下一步操作的操作 + */ + public ExamContinuationAction processExamResult( + ExamSession examSession, boolean continueWithSameLevel, DifficultyLevel newDifficultyLevel) { + + if (continueWithSameLevel) { + return new ExamContinuationAction( + true, examSession.getDifficultyLevel(), (int) examSession.getQuestions().size()); + } else if (newDifficultyLevel != null) { + return new ExamContinuationAction(true, newDifficultyLevel, + (int) examSession.getQuestions().size()); + } else { + return new ExamContinuationAction(false, null, 0); + } + } + + /** + * 表示完成考试后要采取的操作. + */ + public static final class ExamContinuationAction { + + private final boolean shouldContinue; + private final DifficultyLevel nextDifficultyLevel; + private final int nextQuestionCount; + + /** + * 创建新的考试延续操作. + * + * @param shouldContinue 用户是否想继续参加另一次考试 + * @param nextDifficultyLevel 下次考试的难度级别(如果不继续则为null) + * @param nextQuestionCount 下次考试的题目数量(如果不继续则为0) + */ + public ExamContinuationAction( + boolean shouldContinue, DifficultyLevel nextDifficultyLevel, int nextQuestionCount) { + this.shouldContinue = shouldContinue; + this.nextDifficultyLevel = nextDifficultyLevel; + this.nextQuestionCount = nextQuestionCount; + } + + /** + * 获取用户是否想继续参加另一次考试. + * + * @return 如果继续则为true,如果退出则为false + */ + public boolean shouldContinue() { + return shouldContinue; + } + + /** + * 获取下次考试的难度级别. + * + * @return 下次难度级别(如果不继续则为null) + */ + public DifficultyLevel getNextDifficultyLevel() { + return nextDifficultyLevel; + } + + /** + * 获取下次考试的题目数量. + * + * @return 下次题目数量(如果不继续则为0) + */ + public int getNextQuestionCount() { + return nextQuestionCount; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/personalproject/service/ExamService.java b/src/main/java/com/personalproject/service/ExamService.java new file mode 100644 index 0000000..643c311 --- /dev/null +++ b/src/main/java/com/personalproject/service/ExamService.java @@ -0,0 +1,158 @@ +package com.personalproject.service; + +import com.personalproject.generator.QuestionGenerator; +import com.personalproject.model.DifficultyLevel; +import com.personalproject.model.ExamSession; +import com.personalproject.model.QuizQuestion; +import com.personalproject.storage.QuestionStorageService; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; + +/** + * 用于管理考试会话和生成测验题目的服务类. + */ +public final class ExamService { + + private static final int OPTIONS_COUNT = 4; + private final Map generators; + private final Random random = new Random(); + private final QuestionGenerationService questionGenerationService; + private final QuestionStorageService questionStorageService; + + /** + * 创建新的考试服务. + * + * @param generatorMap 难度级别到题目生成器的映射 + * @param questionGenerationService 题目生成服务 + * @param questionStorageService 题目存储服务 + */ + public ExamService( + Map generatorMap, + QuestionGenerationService questionGenerationService, + QuestionStorageService questionStorageService) { + this.generators = new EnumMap<>(DifficultyLevel.class); + this.generators.putAll(generatorMap); + this.questionGenerationService = questionGenerationService; + this.questionStorageService = questionStorageService; + } + + /** + * 使用指定参数创建新的考试会话. + * + * @param username 考生用户名 + * @param difficultyLevel 考试难度级别 + * @param questionCount 考试中包含的题目数量 + * @return 新的考试会话 + */ + public ExamSession createExamSession( + String username, DifficultyLevel difficultyLevel, int questionCount) { + if (questionCount < 10 || questionCount > 30) { + throw new IllegalArgumentException("题目数量必须在10到30之间"); + } + + // 根据难度级别生成题目 + if (!generators.containsKey(difficultyLevel)) { + throw new IllegalArgumentException("找不到难度级别的生成器: " + difficultyLevel); + } + + try { + Set existingQuestions = questionStorageService.loadExistingQuestions(username); + List uniqueQuestions = questionGenerationService.generateUniqueQuestions( + difficultyLevel, questionCount, existingQuestions); + + List quizQuestions = new ArrayList<>(); + for (String questionText : uniqueQuestions) { + quizQuestions.add(buildQuizQuestion(questionText)); + } + + questionStorageService.saveQuestions(username, uniqueQuestions); + return new ExamSession(username, difficultyLevel, quizQuestions); + } catch (IOException exception) { + throw new IllegalStateException("生成考试题目失败", exception); + } + } + + /** + * 使用预定义题目创建考试会话(用于测试). + * + * @param username 考生用户名 + * @param difficultyLevel 考试难度级别 + * @param questions 预定义题目列表 + * @return 新的考试会话 + */ + public ExamSession createExamSession( + String username, DifficultyLevel difficultyLevel, List questions) { + if (questions.size() < 10 || questions.size() > 30) { + throw new IllegalArgumentException("题目数量必须在10到30之间"); + } + return new ExamSession(username, difficultyLevel, questions); + } + + private QuizQuestion buildQuizQuestion(String questionText) { + try { + double correctAnswer = MathExpressionEvaluator.evaluate(questionText); + String formattedCorrectAnswer = formatAnswer(correctAnswer); + List options = buildOptions(correctAnswer, formattedCorrectAnswer); + int correctAnswerIndex = options.indexOf(formattedCorrectAnswer); + if (correctAnswerIndex < 0) { + options.set(0, formattedCorrectAnswer); + correctAnswerIndex = 0; + } + return new QuizQuestion(questionText, options, correctAnswerIndex); + } catch (RuntimeException exception) { + List fallbackOptions = new ArrayList<>(); + for (int i = 1; i <= OPTIONS_COUNT; i++) { + fallbackOptions.add("选项" + i); + } + return new QuizQuestion(questionText, fallbackOptions, 0); + } + } + + private List buildOptions(double correctAnswer, String formattedCorrectAnswer) { + Set optionSet = new LinkedHashSet<>(); + optionSet.add(formattedCorrectAnswer); + + int attempts = 0; + while (optionSet.size() < OPTIONS_COUNT && attempts < 100) { + double offset = generateOffset(correctAnswer); + double candidateValue = correctAnswer + offset; + if (Double.isNaN(candidateValue) || Double.isInfinite(candidateValue)) { + attempts++; + continue; + } + String candidate = formatAnswer(candidateValue); + if (!candidate.equals(formattedCorrectAnswer)) { + optionSet.add(candidate); + } + attempts++; + } + + while (optionSet.size() < OPTIONS_COUNT) { + optionSet.add(formatAnswer(correctAnswer + optionSet.size() * 1.5)); + } + + List options = new ArrayList<>(optionSet); + Collections.shuffle(options, random); + return options; + } + + private double generateOffset(double base) { + double scale = Math.max(1.0, Math.abs(base) / 2.0); + double offset = random.nextGaussian() * scale; + if (Math.abs(offset) < 0.5) { + offset += offset >= 0 ? 1.5 : -1.5; + } + return offset; + } + + private String formatAnswer(double value) { + return String.format("%.2f", value); + } +} diff --git a/src/main/java/com/personalproject/service/MathExpressionEvaluator.java b/src/main/java/com/personalproject/service/MathExpressionEvaluator.java new file mode 100644 index 0000000..49b919d --- /dev/null +++ b/src/main/java/com/personalproject/service/MathExpressionEvaluator.java @@ -0,0 +1,269 @@ +package com.personalproject.service; + +import java.util.HashMap; +import java.util.Map; +import java.util.Stack; +import java.util.function.DoubleUnaryOperator; +import java.util.regex.Pattern; + +/** + * 可处理基本算术运算的数学表达式求值器. + */ +public final class MathExpressionEvaluator { + + private static final Pattern NUMBER_PATTERN = Pattern.compile("-?\\d+(\\.\\d+)?"); + private static final Map PRECEDENCE = new HashMap<>(); + private static final Map FUNCTIONS = new HashMap<>(); + + static { + PRECEDENCE.put('+', 1); + PRECEDENCE.put('-', 1); + PRECEDENCE.put('*', 2); + PRECEDENCE.put('/', 2); + PRECEDENCE.put('^', 3); + + FUNCTIONS.put("sin", angle -> Math.sin(Math.toRadians(angle))); + FUNCTIONS.put("cos", angle -> Math.cos(Math.toRadians(angle))); + FUNCTIONS.put("tan", angle -> Math.tan(Math.toRadians(angle))); + FUNCTIONS.put("sqrt", Math::sqrt); + } + + private MathExpressionEvaluator() { + // 防止实例化此工具类 + } + + /** + * 计算数学表达式字符串的结果. + * + * @param expression 要计算的数学表达式 + * @return 计算结果 + * @throws IllegalArgumentException 如果表达式无效 + */ + public static double evaluate(String expression) { + if (expression == null) { + throw new IllegalArgumentException("Expression cannot be null"); + } + + expression = expression.replaceAll("\\s+", ""); // 移除空白字符 + if (expression.isEmpty()) { + throw new IllegalArgumentException("Expression cannot be empty"); + } + + // 将表达式拆分为记号 + String[] tokens = tokenize(expression); + + // 使用调度场算法将中缀表达式转换为后缀表达式 + String[] postfix = infixToPostfix(tokens); + + // 计算后缀表达式 + return evaluatePostfix(postfix); + } + + /** + * 将表达式拆分为数字与运算符的记号. + * + * @param expression 待拆分的表达式 + * @return 记号数组 + */ + private static String[] tokenize(String expression) { + java.util.List tokens = new java.util.ArrayList<>(); + StringBuilder currentNumber = new StringBuilder(); + + for (int i = 0; i < expression.length(); i++) { + char c = expression.charAt(i); + + if (Character.isDigit(c) || c == '.') { + currentNumber.append(c); + } else if (Character.isLetter(c)) { + if (currentNumber.length() > 0) { + tokens.add(currentNumber.toString()); + currentNumber.setLength(0); + } + StringBuilder functionBuilder = new StringBuilder(); + functionBuilder.append(c); + while (i + 1 < expression.length() && Character.isLetter(expression.charAt(i + 1))) { + functionBuilder.append(expression.charAt(++i)); + } + String function = functionBuilder.toString(); + if (!isFunction(function)) { + throw new IllegalArgumentException("Unsupported function: " + function); + } + tokens.add(function); + } else if (c == '(' || c == ')') { + if (currentNumber.length() > 0) { + tokens.add(currentNumber.toString()); + currentNumber.setLength(0); + } + tokens.add(String.valueOf(c)); + } else if (isOperator(c)) { + if (currentNumber.length() > 0) { + tokens.add(currentNumber.toString()); + currentNumber.setLength(0); + } + + // 处理一元负号 + if (c == '-' && (i == 0 || expression.charAt(i - 1) == '(')) { + currentNumber.append(c); + } else { + tokens.add(String.valueOf(c)); + } + } else { + throw new IllegalArgumentException("Invalid character in expression: " + c); + } + } + + if (currentNumber.length() > 0) { + tokens.add(currentNumber.toString()); + } + + return tokens.toArray(new String[0]); + } + + /** + * 检查字符是否为运算符. + * + * @param c 待检查的字符 + * @return 若字符是运算符则返回 true,否则返回 false + */ + private static boolean isOperator(char c) { + return c == '+' || c == '-' || c == '*' || c == '/' || c == '^'; + } + + private static boolean isFunction(String token) { + return FUNCTIONS.containsKey(token); + } + + /** + * 使用调度场算法将中缀表达式转换为后缀表达式. + * + * @param tokens 中缀表达式的记号数组 + * @return 后缀表达式的记号数组 + */ + private static String[] infixToPostfix(String[] tokens) { + java.util.List output = new java.util.ArrayList<>(); + Stack operators = new Stack<>(); + + for (String token : tokens) { + if (isNumber(token)) { + output.add(token); + } else if (isFunction(token)) { + operators.push(token); + } else if (token.equals("(")) { + operators.push(token); + } else if (token.equals(")")) { + while (!operators.isEmpty() && !operators.peek().equals("(")) { + output.add(operators.pop()); + } + if (!operators.isEmpty()) { + operators.pop(); // 移除 "(" + } + if (!operators.isEmpty() && isFunction(operators.peek())) { + output.add(operators.pop()); + } + } else if (isOperator(token.charAt(0))) { + while (!operators.isEmpty() + && isOperator(operators.peek().charAt(0)) + && PRECEDENCE.get(operators.peek().charAt(0)) >= PRECEDENCE.get(token.charAt(0))) { + output.add(operators.pop()); + } + operators.push(token); + } + } + + while (!operators.isEmpty()) { + String operator = operators.pop(); + if (operator.equals("(") || operator.equals(")")) { + throw new IllegalArgumentException("Mismatched parentheses in expression"); + } + output.add(operator); + } + + return output.toArray(new String[0]); + } + + /** + * 计算后缀表达式的值. + * + * @param postfix 后缀表达式的记号数组 + * @return 计算结果 + */ + private static double evaluatePostfix(String[] postfix) { + Stack values = new Stack<>(); + + for (String token : postfix) { + if (isNumber(token)) { + values.push(Double.parseDouble(token)); + } else if (isOperator(token.charAt(0))) { + if (values.size() < 2) { + throw new IllegalArgumentException("Invalid expression: insufficient operands"); + } + + double b = values.pop(); + double a = values.pop(); + double result = performOperation(a, b, token.charAt(0)); + values.push(result); + } else if (isFunction(token)) { + if (values.isEmpty()) { + throw new IllegalArgumentException( + "Invalid expression: insufficient operands for function"); + } + double value = values.pop(); + values.push(applyFunction(token, value)); + } else { + throw new IllegalArgumentException("Unknown token: " + token); + } + } + + if (values.size() != 1) { + throw new IllegalArgumentException("Invalid expression: too many operands"); + } + + return values.pop(); + } + + /** + * 对两个操作数执行指定运算. + * + * @param a 第一个操作数 + * @param b 第二个操作数 + * @param operator 要应用的运算符 + * @return 运算结果 + */ + private static double performOperation(double a, double b, char operator) { + switch (operator) { + case '+': + return a + b; + case '-': + return a - b; + case '*': + return a * b; + case '/': + if (b == 0) { + throw new ArithmeticException("Division by zero"); + } + return a / b; + case '^': + return Math.pow(a, b); + default: + throw new IllegalArgumentException("Unknown operator: " + operator); + } + } + + private static double applyFunction(String function, double value) { + DoubleUnaryOperator operator = FUNCTIONS.get(function); + if (operator == null) { + throw new IllegalArgumentException("Unknown function: " + function); + } + return operator.applyAsDouble(value); + } + + /** + * 检查记号是否为数字. + * + * @param token 待检查的记号 + * @return 若为数字则返回 true,否则返回 false + */ + private static boolean isNumber(String token) { + return NUMBER_PATTERN.matcher(token).matches(); + } +} diff --git a/src/main/java/com/personalproject/service/MathLearningService.java b/src/main/java/com/personalproject/service/MathLearningService.java new file mode 100644 index 0000000..71a0fd8 --- /dev/null +++ b/src/main/java/com/personalproject/service/MathLearningService.java @@ -0,0 +1,180 @@ +package com.personalproject.service; + +import com.personalproject.auth.AccountRepository; +import com.personalproject.auth.EmailService; +import com.personalproject.auth.PasswordValidator; +import com.personalproject.generator.QuestionGenerator; +import com.personalproject.model.DifficultyLevel; +import com.personalproject.model.ExamSession; +import com.personalproject.storage.QuestionStorageService; +import java.util.Map; +import java.util.Optional; + +/** + * 为主JavaFX UI提供所有功能的服务类. 此类协调各种服务以为UI提供统一的API. + */ +public final class MathLearningService { + + private final AccountRepository accountRepository; + private final RegistrationService registrationService; + private final ExamService examService; + private final QuestionStorageService storageService; + private final ExamResultService resultService; + + /** + * 创建一个新的数学学习服务. + * + * @param generatorMap 难度级别到题目生成器的映射 + * @param questionGenerationService 题目生成服务 + */ + public MathLearningService( + Map generatorMap, + QuestionGenerationService questionGenerationService) { + this.storageService = new QuestionStorageService(); + this.accountRepository = new AccountRepository(); + this.registrationService = new RegistrationService(accountRepository); + this.examService = new ExamService(generatorMap, questionGenerationService, storageService); + this.resultService = new ExamResultService(); + } + + // 注册方法 + + /** + * 通过向提供的电子邮件发送注册码来启动注册过程. + * + * @param username 所需用户名 + * @param email 要发送注册码的电子邮件地址 + * @param difficultyLevel 选择的难度级别 + * @return 如果注册成功启动则返回true,如果电子邮件无效或用户名已存在则返回false + */ + public boolean initiateRegistration(String username, String email, + DifficultyLevel difficultyLevel) { + return registrationService.initiateRegistration(username, email, difficultyLevel); + } + + /** + * 验证用户输入的注册码. + * + * @param username 用户名 + * @param registrationCode 通过电子邮件收到的注册码 + * @return 如果注册码有效则返回true,否则返回false + */ + public boolean verifyRegistrationCode(String username, String registrationCode) { + return registrationService.verifyRegistrationCode(username, registrationCode); + } + + /** + * 在成功验证注册码后为用户设置密码. + * + * @param username 用户名 + * @param password 要设置的密码 + * @return 如果密码设置成功则返回true,如果验证失败或用户不存在则返回false + */ + public boolean setPassword(String username, String password) { + return registrationService.setPassword(username, password); + } + + /** + * 使用用户名和密码验证用户. + * + * @param username 用户名 + * @param password 密码 + * @return 如果验证成功则返回包含用户账户的Optional + */ + public Optional authenticate(String username, + String password) { + return registrationService.authenticate(username, password); + } + + /** + * 检查密码是否符合验证要求. + * + * @param password 要验证的密码 + * @return 如果密码有效则返回true,否则返回false + */ + public static boolean isValidPassword(String password) { + return PasswordValidator.isValidPassword(password); + } + + /** + * 更改现有用户的密码. + * + * @param username 用户名 + * @param oldPassword 当前密码 + * @param newPassword 新密码 + * @return 如果密码更改成功则返回true,如果验证失败或验证失败则返回false + */ + public boolean changePassword(String username, String oldPassword, String newPassword) { + return registrationService.changePassword(username, oldPassword, newPassword); + } + + /** + * 检查电子邮件地址是否具有有效格式. + * + * @param email 要验证的电子邮件地址 + * @return 如果电子邮件格式有效则返回true,否则返回false + */ + public static boolean isValidEmail(String email) { + return EmailService.isValidEmail(email); + } + + /** + * 为指定用户和难度级别创建新的考试会话. + * + * @param username 应试者用户名 + * @param difficultyLevel 考试难度级别 + * @param questionCount 考试中包含的题目数量 + * @return 新的考试会话 + */ + public ExamSession createExamSession(String username, DifficultyLevel difficultyLevel, + int questionCount) { + return examService.createExamSession(username, difficultyLevel, questionCount); + } + + /** + * 将考试结果保存到存储中. + * + * @param examSession 完成的考试会话 + * @return 保存结果文件的路径 + */ + public java.nio.file.Path saveExamResults(ExamSession examSession) { + try { + return storageService.saveExamResults(examSession); + } catch (java.io.IOException e) { + throw new RuntimeException("保存考试结果失败", e); + } + } + + /** + * 处理用户完成考试后的选择. + * + * @param examSession 完成的考试会话 + * @param continueWithSameLevel 是否继续使用相同难度级别 + * @param newDifficultyLevel 更改时的新难度级别(如果不更改则为null) + * @return 表示下一步操作的动作 + */ + public ExamResultService.ExamContinuationAction processExamResult( + ExamSession examSession, boolean continueWithSameLevel, DifficultyLevel newDifficultyLevel) { + return resultService.processExamResult(examSession, continueWithSameLevel, newDifficultyLevel); + } + + /** + * 检查用户是否存在于系统中. + * + * @param username 要检查的用户名 + * @return 如果用户存在则返回true,否则返回false + */ + public boolean userExists(String username) { + return registrationService.userExists(username); + } + + /** + * 按用户名获取用户账户. + * + * @param username 用户名 + * @return 若找到则返回包含用户账户的 Optional + */ + public Optional getUser(String username) { + return registrationService.getUser(username); + } +} diff --git a/src/main/java/com/personalproject/service/QuestionGenerationService.java b/src/main/java/com/personalproject/service/QuestionGenerationService.java new file mode 100644 index 0000000..b536a7d --- /dev/null +++ b/src/main/java/com/personalproject/service/QuestionGenerationService.java @@ -0,0 +1,71 @@ +package com.personalproject.service; + +import com.personalproject.generator.QuestionGenerator; +import com.personalproject.model.DifficultyLevel; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; + +/** + * 负责批量生成题目并避免与历史题库重复. + */ +public final class QuestionGenerationService { + + private static final int MAX_ATTEMPTS = 10_000; + private final Map generators; + private final Random random = new SecureRandom(); + + /** + * 构造函数:复制难度与生成器的映射,保留内部安全副本. + * + * @param generatorMap 难度到题目生成器的外部映射. + */ + public QuestionGenerationService(Map generatorMap) { + generators = new EnumMap<>(DifficultyLevel.class); + generators.putAll(generatorMap); + } + + /** + * 根据指定难度批量生成不与历史题目重复的新题目. + * + * @param level 目标题目难度. + * @param count 需要生成的题目数量. + * @param existingQuestions 已存在的题目集合,用于查重. + * @return 生成的题目列表. + * @throws IllegalArgumentException 当难度未配置时抛出. + * @throws IllegalStateException 当达到最大尝试次数仍无法生成足够题目时抛出. + */ + public List generateUniqueQuestions( + DifficultyLevel level, int count, Set existingQuestions) { + QuestionGenerator generator = generators.get(level); + if (generator == null) { + throw new IllegalArgumentException("Unsupported difficulty level: " + level); + } + Set produced = new HashSet<>(); + List results = new ArrayList<>(); + int attempts = 0; + while (results.size() < count) { + if (attempts >= MAX_ATTEMPTS) { + throw new IllegalStateException("Unable to generate enough unique questions."); + } + attempts++; + String question = generator.generateQuestion(random).trim(); + if (question.isEmpty()) { + continue; + } + if (existingQuestions.contains(question)) { + continue; + } + if (!produced.add(question)) { + continue; + } + results.add(question); + } + return results; + } +} diff --git a/src/main/java/com/personalproject/service/RegistrationService.java b/src/main/java/com/personalproject/service/RegistrationService.java new file mode 100644 index 0000000..ee2bc82 --- /dev/null +++ b/src/main/java/com/personalproject/service/RegistrationService.java @@ -0,0 +1,201 @@ +package com.personalproject.service; + +import com.personalproject.auth.AccountRepository; +import com.personalproject.auth.EmailService; +import com.personalproject.auth.PasswordValidator; +import com.personalproject.model.DifficultyLevel; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 用于处理用户注册和身份验证过程的服务类. + */ +public final class RegistrationService { + + private final AccountRepository accountRepository; + private final Map pendingRegistrations; + private final Map registrationAttempts; + private final Set verifiedUsers; + + /** + * 创建新的注册服务. + * + * @param accountRepository 使用的账户存储库 + */ + public RegistrationService(AccountRepository accountRepository) { + this.accountRepository = accountRepository; + this.pendingRegistrations = new ConcurrentHashMap<>(); + this.registrationAttempts = new ConcurrentHashMap<>(); + this.verifiedUsers = ConcurrentHashMap.newKeySet(); + } + + /** + * 通过向提供的电子邮件发送注册码来启动注册过程. + * + * @param username 所需用户名 + * @param email 要发送注册码的电子邮件地址 + * @param difficultyLevel 选择的难度级别 + * @return 注册成功启动则返回true,如果电子邮件无效或用户名已存在则返回false + */ + public boolean initiateRegistration(String username, String email, + DifficultyLevel difficultyLevel) { + if (username == null || email == null || difficultyLevel == null) { + return false; + } + + String normalizedUsername = username.trim(); + String normalizedEmail = email.trim(); + if (normalizedUsername.isEmpty() || normalizedEmail.isEmpty()) { + return false; + } + + if (!EmailService.isValidEmail(normalizedEmail)) { + return false; + } + + if (!accountRepository.registerUser(normalizedUsername, normalizedEmail, difficultyLevel)) { + return false; // 用户名已存在或邮箱已注册 + } + + String registrationCode = EmailService.generateRegistrationCode(); + pendingRegistrations.put(normalizedUsername, registrationCode); + registrationAttempts.put(normalizedUsername, 0); + verifiedUsers.remove(normalizedUsername); + + boolean sent = EmailService.sendRegistrationCode(normalizedEmail, registrationCode); + if (!sent) { + pendingRegistrations.remove(normalizedUsername); + registrationAttempts.remove(normalizedUsername); + accountRepository.removeUnverifiedUser(normalizedUsername); + } + return sent; + } + + /** + * 通过验证注册码完成注册. + * + * @param username 用户名 + * @param registrationCode 通过电子邮件收到的注册码 + * @return 注册码有效则返回true,否则返回false + */ + public boolean verifyRegistrationCode(String username, String registrationCode) { + if (username == null || registrationCode == null) { + return false; + } + String normalizedUsername = username.trim(); + String trimmedCode = registrationCode.trim(); + + String storedCode = pendingRegistrations.get(normalizedUsername); + if (storedCode == null || !storedCode.equals(trimmedCode)) { + // 跟踪失败尝试 + int attempts = registrationAttempts.getOrDefault(normalizedUsername, 0); + attempts++; + registrationAttempts.put(normalizedUsername, attempts); + + if (attempts >= 3) { + // 如果失败次数过多,则删除用户 + pendingRegistrations.remove(normalizedUsername); + registrationAttempts.remove(normalizedUsername); + verifiedUsers.remove(normalizedUsername); + accountRepository.removeUnverifiedUser(normalizedUsername); + return false; + } + return false; + } + + // 有效码,从待处理列表中移除 + pendingRegistrations.remove(normalizedUsername); + registrationAttempts.remove(normalizedUsername); + verifiedUsers.add(normalizedUsername); + return true; + } + + /** + * 在成功验证注册码后为用户设置密码. + * + * @param username 用户名 + * @param password 要设置的密码 + * @return 密码设置成功则返回true,如果验证失败或用户不存在则返回false + */ + public boolean setPassword(String username, String password) { + if (username == null || password == null) { + return false; + } + String normalizedUsername = username.trim(); + if (!PasswordValidator.isValidPassword(password)) { + return false; + } + + if (!verifiedUsers.contains(normalizedUsername)) { + return false; + } + + boolean updated = accountRepository.setPassword(normalizedUsername, password); + if (updated) { + verifiedUsers.remove(normalizedUsername); + } + return updated; + } + + /** + * 更改现有用户的密码. + * + * @param username 用户名 + * @param oldPassword 当前密码 + * @param newPassword 新密码 + * @return 密码更改成功则返回true,如果验证失败或身份验证失败则返回false + */ + public boolean changePassword(String username, String oldPassword, String newPassword) { + if (username == null || oldPassword == null || newPassword == null) { + return false; + } + if (!PasswordValidator.isValidPassword(newPassword)) { + return false; + } + + return accountRepository.changePassword(username.trim(), oldPassword, newPassword); + } + + /** + * 使用用户名和密码验证用户. + * + * @param username 用户名 + * @param password 密码 + * @return 验证成功则返回包含用户账户的Optional + */ + public Optional authenticate(String username, + String password) { + if (username == null || password == null) { + return Optional.empty(); + } + return accountRepository.authenticate(username.trim(), password); + } + + /** + * 检查用户是否存在于系统中. + * + * @param username 要检查的用户名 + * @return 用户存在则返回true,否则返回false + */ + public boolean userExists(String username) { + if (username == null) { + return false; + } + return accountRepository.userExists(username.trim()); + } + + /** + * 按用户名获取用户账户. + * + * @param username 用户名 + * @return 若找到则返回包含用户账户的 Optional + */ + public Optional getUser(String username) { + if (username == null) { + return Optional.empty(); + } + return accountRepository.getUser(username.trim()); + } +} diff --git a/src/main/java/com/personalproject/storage/QuestionStorageService.java b/src/main/java/com/personalproject/storage/QuestionStorageService.java new file mode 100644 index 0000000..dc483c9 --- /dev/null +++ b/src/main/java/com/personalproject/storage/QuestionStorageService.java @@ -0,0 +1,150 @@ +package com.personalproject.storage; + +import com.personalproject.model.ExamSession; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +/** + * 负责保存生成的题目并维护查重信息. + */ +public final class QuestionStorageService { + + private static final String BASE_DIRECTORY = "user_data"; + private static final String QUESTIONS_SUBDIR = "questions"; + private static final String RESULTS_SUBDIR = "results"; + private static final DateTimeFormatter FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm-ss"); + + /** + * 加载指定用户历史生成的题目,用于后续查重. + * + * @param username 用户名. + * @return 历史题目的去重集合. + * @throws IOException 当读取文件时发生 I/O 错误. + */ + public Set loadExistingQuestions(String username) throws IOException { + Path accountDirectory = getQuestionsDirectory(username); + Set questions = new HashSet<>(); + if (!Files.exists(accountDirectory)) { + return questions; + } + try (Stream paths = Files.list(accountDirectory)) { + paths + .filter(path -> path.getFileName().toString().endsWith(".txt")) + .sorted() + .forEach(path -> readQuestionsFromFile(path, questions)); + } + return questions; + } + + /** + * 将新生成的题目保存到用户目录,并返回生成的文件路径. + * + * @param username 用户名. + * @param questions 待保存的题目列表. + * @return 保存题目的文件路径. + * @throws IOException 当写入文件失败时抛出. + */ + public Path saveQuestions(String username, List questions) throws IOException { + Path accountDirectory = getQuestionsDirectory(username); + Files.createDirectories(accountDirectory); + String fileName = FORMATTER.format(LocalDateTime.now()) + ".txt"; + Path outputFile = accountDirectory.resolve(fileName); + StringBuilder builder = new StringBuilder(); + for (int index = 0; index < questions.size(); index++) { + String question = questions.get(index); + builder + .append(index + 1) + .append(". ") + .append(question) + .append(System.lineSeparator()) + .append(System.lineSeparator()); + } + Files.writeString( + outputFile, builder.toString(), StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW); + return outputFile; + } + + /** + * 保存用户的考试结果. + * + * @param examSession 包含结果的考试会话 + * @return 保存结果文件的路径 + * @throws IOException 如果写入文件失败 + */ + public Path saveExamResults(ExamSession examSession) throws IOException { + Path resultsDirectory = getResultsDirectory(examSession.getUsername()); + Files.createDirectories(resultsDirectory); + + StringBuilder builder = new StringBuilder(); + builder.append("考试试卷").append(System.lineSeparator()); + builder.append("用户名: ").append(examSession.getUsername()).append(System.lineSeparator()); + builder.append("难度: ").append(examSession.getDifficultyLevel().getDisplayName()) + .append(System.lineSeparator()); + builder.append("开始时间: ").append(examSession.getStartTime()).append(System.lineSeparator()); + builder.append("题目数量: ").append(examSession.getQuestions().size()) + .append(System.lineSeparator()); + builder.append(System.lineSeparator()); + + // 只保存题目内容 + for (int i = 0; i < examSession.getQuestions().size(); i++) { + var question = examSession.getQuestions().get(i); + builder.append("题目 ").append(i + 1).append(": ").append(question.getQuestionText()) + .append(System.lineSeparator()); + builder.append(System.lineSeparator()); + } + + String fileName = "exam_result_" + FORMATTER.format(LocalDateTime.now()) + ".txt"; + Path outputFile = resultsDirectory.resolve(fileName); + + Files.writeString( + outputFile, builder.toString(), StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW); + return outputFile; + } + + private Path getAccountDirectory(String username) { + return Paths.get(BASE_DIRECTORY, username); + } + + private Path getQuestionsDirectory(String username) { + return getAccountDirectory(username).resolve(QUESTIONS_SUBDIR); + } + + private Path getResultsDirectory(String username) { + return getAccountDirectory(username).resolve(RESULTS_SUBDIR); + } + + private void readQuestionsFromFile(Path path, Set questions) { + try { + List lines = Files.readAllLines(path, StandardCharsets.UTF_8); + for (String line : lines) { + String trimmed = line.trim(); + if (trimmed.isEmpty()) { + continue; + } + int dotIndex = trimmed.indexOf('.'); + if (dotIndex >= 0 && dotIndex + 1 < trimmed.length()) { + String question = trimmed.substring(dotIndex + 1).trim(); + if (!question.isEmpty()) { + questions.add(question); + } + } else { + questions.add(trimmed); + } + } + } catch (IOException exception) { + System.err.println( + "读取题目文件失败:" + path + ",原因:" + exception.getMessage() + ",将跳过该文件."); + } + } +} diff --git a/src/main/java/com/personalproject/ui/MathExamGui.java b/src/main/java/com/personalproject/ui/MathExamGui.java new file mode 100644 index 0000000..2603eaf --- /dev/null +++ b/src/main/java/com/personalproject/ui/MathExamGui.java @@ -0,0 +1,54 @@ +package com.personalproject.ui; + +import com.personalproject.controller.MathLearningController; +import com.personalproject.generator.HighSchoolQuestionGenerator; +import com.personalproject.generator.MiddleSchoolQuestionGenerator; +import com.personalproject.generator.PrimaryQuestionGenerator; +import com.personalproject.generator.QuestionGenerator; +import com.personalproject.model.DifficultyLevel; +import com.personalproject.service.QuestionGenerationService; +import com.personalproject.ui.scenes.LoginScene; +import java.util.EnumMap; +import java.util.Map; +import javafx.application.Application; +import javafx.scene.Scene; +import javafx.stage.Stage; + +/** + * 数学学习软件的 JavaFX 图形界面应用程序. 这是图形界面的主要入口点. + */ +public final class MathExamGui extends Application { + + private MathLearningController controller; + + @Override + public void start(Stage primaryStage) { + // 使用题目生成器初始化控制器 + Map generatorMap = new EnumMap<>(DifficultyLevel.class); + generatorMap.put(DifficultyLevel.PRIMARY, new PrimaryQuestionGenerator()); + generatorMap.put(DifficultyLevel.MIDDLE, new MiddleSchoolQuestionGenerator()); + generatorMap.put(DifficultyLevel.HIGH, new HighSchoolQuestionGenerator()); + QuestionGenerationService questionGenerationService = new QuestionGenerationService( + generatorMap); + this.controller = new MathLearningController(generatorMap, questionGenerationService); + + // 配置主舞台 + primaryStage.setTitle("数学学习软件"); + + // 从登录界面开始 + LoginScene loginScene = new LoginScene(primaryStage, controller); + Scene scene = new Scene(loginScene, 600, 400); + + primaryStage.setScene(scene); + primaryStage.show(); + } + + /** + * 启动 JavaFX 应用程序. + * + * @param args 命令行参数 + */ + public static void main(String[] args) { + launch(args); + } +} diff --git a/src/main/java/com/personalproject/ui/scenes/LoginScene.java b/src/main/java/com/personalproject/ui/scenes/LoginScene.java new file mode 100644 index 0000000..b12f741 --- /dev/null +++ b/src/main/java/com/personalproject/ui/scenes/LoginScene.java @@ -0,0 +1,162 @@ +package com.personalproject.ui.scenes; + +import com.personalproject.controller.MathLearningController; +import com.personalproject.ui.views.MainMenuView; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.PasswordField; +import javafx.scene.control.TextField; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; +import javafx.stage.Stage; + +/** + * 负责处理用户登录与注册的场景. + */ +public class LoginScene extends BorderPane { + + private final Stage primaryStage; + private final MathLearningController controller; + private TextField usernameField; + private PasswordField passwordField; + private Button loginButton; + private Button registerButton; + + /** + * LoginScene 的构造函数. + * + * @param primaryStage 应用程序的主舞台 + * @param controller 数学学习控制器 + */ + public LoginScene(Stage primaryStage, MathLearningController controller) { + this.primaryStage = primaryStage; + this.controller = controller; + initializeUi(); + } + + /** + * 初始化界面组件. + */ + private void initializeUi() { + // 创建主布局 + VBox mainLayout = new VBox(15); + mainLayout.setAlignment(Pos.CENTER); + mainLayout.setPadding(new Insets(20)); + + // 标题 + final Label titleLabel = new Label("数学学习软件"); + titleLabel.setFont(Font.font("System", FontWeight.BOLD, 24)); + + // 登录表单 + GridPane loginForm = new GridPane(); + loginForm.setHgap(10); + loginForm.setVgap(10); + loginForm.setAlignment(Pos.CENTER); + + final Label usernameLabel = new Label("用户名:"); + usernameField = new TextField(); + usernameField.setPrefWidth(200); + + final Label passwordLabel = new Label("密码:"); + passwordField = new PasswordField(); + passwordField.setPrefWidth(200); + + loginForm.add(usernameLabel, 0, 0); + loginForm.add(usernameField, 1, 0); + loginForm.add(passwordLabel, 0, 1); + loginForm.add(passwordField, 1, 1); + + // 按钮 + HBox buttonBox = new HBox(10); + buttonBox.setAlignment(Pos.CENTER); + + loginButton = new Button("登录"); + registerButton = new Button("注册"); + + // 设置按钮样式 + loginButton.setPrefWidth(100); + registerButton.setPrefWidth(100); + + buttonBox.getChildren().addAll(loginButton, registerButton); + + // 将组件添加到主布局 + mainLayout.getChildren().addAll(titleLabel, loginForm, buttonBox); + + // 将主布局放到边界面板中央 + setCenter(mainLayout); + + // 添加事件处理器 + addEventHandlers(); + } + + /** + * 为界面组件添加事件处理器. + */ + private void addEventHandlers() { + loginButton.setOnAction(e -> handleLogin()); + registerButton.setOnAction(e -> handleRegistration()); + + // 允许使用回车键登录 + setOnKeyPressed(event -> { + if (event.getCode().toString().equals("ENTER")) { + handleLogin(); + } + }); + } + + /** + * 处理登录流程. + */ + private void handleLogin() { + String username = usernameField.getText().trim(); + String password = passwordField.getText(); + + if (username.isEmpty() || password.isEmpty()) { + showAlert(Alert.AlertType.WARNING, "警告", "请输入用户名和密码"); + return; + } + + // 验证用户 + var userAccount = controller.authenticate(username, password); + + if (userAccount.isPresent()) { + // 登录成功,跳转到主菜单 + MainMenuView mainMenuView = new MainMenuView(primaryStage, controller, userAccount.get()); + primaryStage.getScene().setRoot(mainMenuView); + } else { + // 登录失败 + showAlert(Alert.AlertType.ERROR, "登录失败", "用户名或密码错误"); + } + } + + /** + * 处理注册流程. + */ + private void handleRegistration() { + // 切换到注册界面 + RegistrationScene registrationScene = new RegistrationScene(primaryStage, controller); + primaryStage.getScene().setRoot(registrationScene); + } + + /** + * 显示提示对话框. + * + * @param alertType 提示类型 + * @param title 对话框标题 + * @param message 显示的消息 + */ + private void showAlert(Alert.AlertType alertType, String title, String message) { + Alert alert = new Alert(alertType); + alert.setTitle(title); + alert.setHeaderText(null); + alert.setContentText(message); + alert.showAndWait(); + } +} diff --git a/src/main/java/com/personalproject/ui/scenes/RegistrationScene.java b/src/main/java/com/personalproject/ui/scenes/RegistrationScene.java new file mode 100644 index 0000000..8141bf9 --- /dev/null +++ b/src/main/java/com/personalproject/ui/scenes/RegistrationScene.java @@ -0,0 +1,288 @@ +package com.personalproject.ui.scenes; + +import com.personalproject.controller.MathLearningController; +import com.personalproject.model.DifficultyLevel; +import com.personalproject.ui.views.MainMenuView; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.PasswordField; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.TextField; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.VBox; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; +import javafx.stage.Stage; + +/** + * 负责处理用户注册的场景. + */ +public class RegistrationScene extends BorderPane { + + private final Stage primaryStage; + private final MathLearningController controller; + private TextField usernameField; + private TextField emailField; + private ComboBox difficultyComboBox; + private Button sendCodeButton; + private TextField registrationCodeField; + private Button verifyCodeButton; + private PasswordField passwordField; + private PasswordField confirmPasswordField; + private Button setPasswordButton; + private Button backButton; + private VBox registrationForm; + + /** + * RegistrationScene 的构造函数. + * + * @param primaryStage 应用程序的主舞台 + * @param controller 数学学习控制器 + */ + public RegistrationScene(Stage primaryStage, MathLearningController controller) { + this.primaryStage = primaryStage; + this.controller = controller; + initializeUi(); + } + + /** + * 初始化界面组件. + */ + private void initializeUi() { + // 创建主布局 + VBox mainLayout = new VBox(15); + mainLayout.setAlignment(Pos.TOP_CENTER); + mainLayout.setPadding(new Insets(20)); + + // 标题 + final Label titleLabel = new Label("用户注册"); + titleLabel.setFont(Font.font("System", FontWeight.BOLD, 24)); + + // 注册表单 + registrationForm = new VBox(15); + registrationForm.setAlignment(Pos.CENTER); + + // 步骤1:填写基础信息 + GridPane basicInfoForm = new GridPane(); + basicInfoForm.setHgap(10); + basicInfoForm.setVgap(10); + basicInfoForm.setAlignment(Pos.CENTER); + + final Label usernameLabel = new Label("用户名:"); + usernameField = new TextField(); + usernameField.setPrefWidth(200); + + final Label emailLabel = new Label("邮箱:"); + emailField = new TextField(); + emailField.setPrefWidth(200); + + final Label difficultyLabel = new Label("默认难度:"); + difficultyComboBox = new ComboBox<>(); + difficultyComboBox.getItems() + .addAll(DifficultyLevel.PRIMARY, DifficultyLevel.MIDDLE, DifficultyLevel.HIGH); + difficultyComboBox.setValue(DifficultyLevel.PRIMARY); + difficultyComboBox.setPrefWidth(200); + + basicInfoForm.add(usernameLabel, 0, 0); + basicInfoForm.add(usernameField, 1, 0); + basicInfoForm.add(emailLabel, 0, 1); + basicInfoForm.add(emailField, 1, 1); + basicInfoForm.add(difficultyLabel, 0, 2); + basicInfoForm.add(difficultyComboBox, 1, 2); + + sendCodeButton = new Button("发送注册码"); + sendCodeButton.setPrefWidth(120); + + registrationForm.getChildren().addAll(basicInfoForm, sendCodeButton); + + // 步骤2:验证码验证(初始隐藏) + VBox verificationSection = new VBox(10); + verificationSection.setAlignment(Pos.CENTER); + verificationSection.setVisible(false); + verificationSection.setManaged(false); + + final Label codeLabel = new Label("注册码:"); + registrationCodeField = new TextField(); + registrationCodeField.setPrefWidth(200); + + verifyCodeButton = new Button("验证注册码"); + verifyCodeButton.setPrefWidth(120); + + verificationSection.getChildren().addAll(codeLabel, registrationCodeField, verifyCodeButton); + registrationForm.getChildren().add(verificationSection); + + // 步骤3:设置密码(初始隐藏) + VBox passwordSection = new VBox(10); + passwordSection.setAlignment(Pos.CENTER); + passwordSection.setVisible(false); + passwordSection.setManaged(false); + + final Label passwordLabel = new Label("设置密码 (6-10位,包含大小写字母和数字):"); + passwordField = new PasswordField(); + passwordField.setPrefWidth(200); + + final Label confirmPasswordLabel = new Label("确认密码:"); + confirmPasswordField = new PasswordField(); + confirmPasswordField.setPrefWidth(200); + + setPasswordButton = new Button("设置密码"); + setPasswordButton.setPrefWidth(120); + + passwordSection.getChildren().addAll(passwordLabel, passwordField, confirmPasswordLabel, + confirmPasswordField, setPasswordButton); + registrationForm.getChildren().add(passwordSection); + + // 返回按钮 + backButton = new Button("返回"); + backButton.setPrefWidth(100); + + // 将组件添加到主布局 + mainLayout.getChildren().addAll(titleLabel, registrationForm, backButton); + + // 使用可滚动容器保证内容在小窗口中完整显示 + ScrollPane scrollPane = new ScrollPane(mainLayout); + scrollPane.setFitToWidth(true); + scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + setCenter(scrollPane); + + // 添加事件处理器 + addEventHandlers(sendCodeButton, verificationSection, verifyCodeButton, passwordSection); + } + + /** + * 为界面组件添加事件处理器. + */ + private void addEventHandlers(Button sendCodeButton, VBox verificationSection, + Button verifyCodeButton, VBox passwordSection) { + sendCodeButton.setOnAction(e -> handleSendCode(verificationSection)); + verifyCodeButton.setOnAction(e -> handleVerifyCode(passwordSection)); + setPasswordButton.setOnAction(e -> handleSetPassword()); + backButton.setOnAction(e -> handleBack()); + } + + /** + * 处理发送注册码的逻辑. + */ + private void handleSendCode(VBox verificationSection) { + final String username = usernameField.getText().trim(); + final String email = emailField.getText().trim(); + final DifficultyLevel difficultyLevel = difficultyComboBox.getValue(); + + if (username.isEmpty() || email.isEmpty()) { + showAlert(Alert.AlertType.WARNING, "警告", "请输入用户名和邮箱"); + return; + } + + if (!controller.isValidEmail(email)) { + showAlert(Alert.AlertType.ERROR, "邮箱格式错误", "请输入有效的邮箱地址"); + return; + } + + // 发起注册 + boolean success = controller.initiateRegistration(username, email, difficultyLevel); + + if (success) { + showAlert(Alert.AlertType.INFORMATION, "注册码已发送", "注册码已发送至您的邮箱,请查收。"); + verificationSection.setVisible(true); + verificationSection.setManaged(true); + } else { + showAlert(Alert.AlertType.ERROR, "注册失败", "用户名或邮箱可能已存在,请重试。"); + } + } + + /** + * 处理注册码验证. + */ + private void handleVerifyCode(VBox passwordSection) { + final String username = usernameField.getText().trim(); + final String registrationCode = registrationCodeField.getText().trim(); + + if (registrationCode.isEmpty()) { + showAlert(Alert.AlertType.WARNING, "警告", "请输入注册码"); + return; + } + + boolean verified = controller.verifyRegistrationCode(username, registrationCode); + + if (verified) { + showAlert(Alert.AlertType.INFORMATION, "验证成功", "注册码验证成功!"); + passwordSection.setVisible(true); + passwordSection.setManaged(true); + } else { + showAlert(Alert.AlertType.ERROR, "验证失败", "注册码验证失败,请检查后重试。"); + } + } + + /** + * 处理设置用户密码的逻辑. + */ + private void handleSetPassword() { + final String username = usernameField.getText().trim(); + final String password = passwordField.getText(); + final String confirmPassword = confirmPasswordField.getText(); + + if (password.isEmpty() || confirmPassword.isEmpty()) { + showAlert(Alert.AlertType.WARNING, "警告", "请输入并确认密码"); + return; + } + + if (!password.equals(confirmPassword)) { + showAlert(Alert.AlertType.ERROR, "密码不匹配", "两次输入的密码不一致"); + return; + } + + if (!controller.isValidPassword(password)) { + showAlert(Alert.AlertType.ERROR, "密码不符合要求", + "密码长度必须为6-10位,且包含大小写字母和数字"); + return; + } + + boolean success = controller.setPassword(username, password); + + if (success) { + var userAccountOptional = controller.getUserAccount(username); + if (userAccountOptional.isPresent()) { + showAlert(Alert.AlertType.INFORMATION, "注册成功", "注册成功!正在进入难度选择界面。"); + MainMenuView mainMenuView = new MainMenuView(primaryStage, controller, + userAccountOptional.get()); + if (primaryStage.getScene() != null) { + primaryStage.getScene().setRoot(mainMenuView); + } + } else { + showAlert(Alert.AlertType.WARNING, "注册提示", + "注册成功,但未能加载用户信息,请使用新密码登录。"); + handleBack(); + } + } else { + showAlert(Alert.AlertType.ERROR, "设置密码失败", "设置密码失败,请重试。"); + } + } + + /** + * 处理返回按钮的操作. + */ + private void handleBack() { + LoginScene loginScene = new LoginScene(primaryStage, controller); + primaryStage.getScene().setRoot(loginScene); + } + + /** + * 显示提示对话框. + * + * @param alertType 提示类型 + * @param title 对话框标题 + * @param message 显示的消息 + */ + private void showAlert(Alert.AlertType alertType, String title, String message) { + Alert alert = new Alert(alertType); + alert.setTitle(title); + alert.setHeaderText(null); + alert.setContentText(message); + alert.showAndWait(); + } +} diff --git a/src/main/java/com/personalproject/ui/views/ExamResultsView.java b/src/main/java/com/personalproject/ui/views/ExamResultsView.java new file mode 100644 index 0000000..18de3c2 --- /dev/null +++ b/src/main/java/com/personalproject/ui/views/ExamResultsView.java @@ -0,0 +1,161 @@ +package com.personalproject.ui.views; + +import com.personalproject.controller.MathLearningController; +import com.personalproject.model.ExamSession; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; +import javafx.stage.Stage; + +/** + * 用于展示考试结果的界面. + */ +public class ExamResultsView extends BorderPane { + + private final Stage primaryStage; + private final MathLearningController controller; + private final ExamSession examSession; + private Button continueButton; + private Button exitButton; + + /** + * ExamResultsView 的构造函数. + * + * @param primaryStage 应用程序的主舞台 + * @param controller 数学学习控制器 + * @param examSession 已完成的考试会话 + */ + public ExamResultsView(Stage primaryStage, MathLearningController controller, + ExamSession examSession) { + this.primaryStage = primaryStage; + this.controller = controller; + this.examSession = examSession; + initializeUi(); + } + + /** + * 初始化界面组件. + */ + private void initializeUi() { + // 创建主布局 + VBox mainLayout = new VBox(20); + mainLayout.setAlignment(Pos.CENTER); + mainLayout.setPadding(new Insets(20)); + + // 结果标题 + Label titleLabel = new Label("考试结果"); + titleLabel.setFont(Font.font("System", FontWeight.BOLD, 24)); + + // 分数展示 + double score = examSession.calculateScore(); + Label scoreLabel = new Label(String.format("您的得分: %.2f", score)); + scoreLabel.setFont(Font.font("System", FontWeight.BOLD, 18)); + + // 成绩明细 + VBox breakdownBox = new VBox(10); + breakdownBox.setAlignment(Pos.CENTER); + + Label totalQuestionsLabel = new Label("总题数: " + examSession.getTotalQuestions()); + Label correctAnswersLabel = new Label("答对题数: " + examSession.getCorrectAnswersCount()); + Label incorrectAnswersLabel = new Label("答错题数: " + examSession.getIncorrectAnswersCount()); + + breakdownBox.getChildren() + .addAll(totalQuestionsLabel, correctAnswersLabel, incorrectAnswersLabel); + + // 按钮区域 + HBox buttonBox = new HBox(15); + buttonBox.setAlignment(Pos.CENTER); + + continueButton = new Button("继续考试"); + exitButton = new Button("退出"); + + // 设置按钮尺寸 + continueButton.setPrefSize(120, 40); + exitButton.setPrefSize(120, 40); + + buttonBox.getChildren().addAll(continueButton, exitButton); + + // 将组件添加到主布局 + mainLayout.getChildren().addAll(titleLabel, scoreLabel, breakdownBox, buttonBox); + + // 将主布局置于边界面板中央 + setCenter(mainLayout); + + // 添加事件处理器 + addEventHandlers(); + } + + /** + * 为界面组件添加事件处理器. + */ + private void addEventHandlers() { + continueButton.setOnAction(e -> handleContinue()); + exitButton.setOnAction(e -> handleExit()); + } + + /** + * 处理继续考试按钮的操作. + */ + private void handleContinue() { + // 返回主菜单以开始新考试 + controller.getUserAccount(examSession.getUsername()) + .ifPresentOrElse( + userAccount -> { + MainMenuView mainMenuView = new MainMenuView(primaryStage, controller, userAccount); + primaryStage.getScene().setRoot(mainMenuView); + }, + () -> { + // 如果找不到用户信息,则提示错误并返回登录界面 + showAlert(Alert.AlertType.ERROR, "错误", "用户信息无法找到,请重新登录"); + // 返回登录场景 + com.personalproject.ui.scenes.LoginScene loginScene = + new com.personalproject.ui.scenes.LoginScene(primaryStage, controller); + primaryStage.getScene().setRoot(loginScene); + } + ); + } + + /** + * 处理退出按钮的操作. + */ + private void handleExit() { + // 返回主菜单 + controller.getUserAccount(examSession.getUsername()) + .ifPresentOrElse( + userAccount -> { + MainMenuView mainMenuView = new MainMenuView(primaryStage, controller, userAccount); + primaryStage.getScene().setRoot(mainMenuView); + }, + () -> { + // 如果找不到用户信息,则提示错误并返回登录界面 + showAlert(Alert.AlertType.ERROR, "错误", "用户信息无法找到,请重新登录"); + // 返回登录场景 + com.personalproject.ui.scenes.LoginScene loginScene = + new com.personalproject.ui.scenes.LoginScene(primaryStage, controller); + primaryStage.getScene().setRoot(loginScene); + } + ); + } + + /** + * 显示提示对话框. + * + * @param alertType 提示类型 + * @param title 对话框标题 + * @param message 显示的消息 + */ + private void showAlert(Alert.AlertType alertType, String title, String message) { + Alert alert = new Alert(alertType); + alert.setTitle(title); + alert.setHeaderText(null); + alert.setContentText(message); + alert.showAndWait(); + } +} diff --git a/src/main/java/com/personalproject/ui/views/ExamSelectionView.java b/src/main/java/com/personalproject/ui/views/ExamSelectionView.java new file mode 100644 index 0000000..2a2d640 --- /dev/null +++ b/src/main/java/com/personalproject/ui/views/ExamSelectionView.java @@ -0,0 +1,157 @@ +package com.personalproject.ui.views; + +import com.personalproject.auth.UserAccount; +import com.personalproject.controller.MathLearningController; +import com.personalproject.model.DifficultyLevel; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.Spinner; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; +import javafx.stage.Stage; + +/** + * 用于选择考试难度和题目数量的界面. + */ +public class ExamSelectionView extends BorderPane { + + private final Stage primaryStage; + private final MathLearningController controller; + private final UserAccount userAccount; + private ComboBox difficultyComboBox; + private Spinner questionCountSpinner; + private Button startExamButton; + private Button backButton; + + /** + * ExamSelectionView 的构造函数. + * + * @param primaryStage 应用程序的主舞台 + * @param controller 数学学习控制器 + * @param userAccount 当前用户账户 + */ + public ExamSelectionView(Stage primaryStage, MathLearningController controller, + UserAccount userAccount) { + this.primaryStage = primaryStage; + this.controller = controller; + this.userAccount = userAccount; + initializeUi(); + } + + /** + * 初始化界面组件. + */ + private void initializeUi() { + // 创建主布局 + VBox mainLayout = new VBox(20); + mainLayout.setAlignment(Pos.CENTER); + mainLayout.setPadding(new Insets(20)); + + // 标题 + final Label titleLabel = new Label("考试设置"); + titleLabel.setFont(Font.font("System", FontWeight.BOLD, 24)); + + // 考试设置表单 + GridPane examSettingsForm = new GridPane(); + examSettingsForm.setHgap(15); + examSettingsForm.setVgap(15); + examSettingsForm.setAlignment(Pos.CENTER); + + final Label difficultyLabel = new Label("选择难度:"); + difficultyComboBox = new ComboBox<>(); + difficultyComboBox.getItems() + .addAll(DifficultyLevel.PRIMARY, DifficultyLevel.MIDDLE, DifficultyLevel.HIGH); + difficultyComboBox.setValue(userAccount.difficultyLevel()); // 默认选中用户的难度 + difficultyComboBox.setPrefWidth(200); + + final Label questionCountLabel = new Label("题目数量 (10-30):"); + questionCountSpinner = new Spinner<>(10, 30, 10); // 最小值、最大值、初始值 + questionCountSpinner.setPrefWidth(200); + + examSettingsForm.add(difficultyLabel, 0, 0); + examSettingsForm.add(difficultyComboBox, 1, 0); + examSettingsForm.add(questionCountLabel, 0, 1); + examSettingsForm.add(questionCountSpinner, 1, 1); + + // 按钮区域 + HBox buttonBox = new HBox(15); + buttonBox.setAlignment(Pos.CENTER); + + startExamButton = new Button("开始考试"); + backButton = new Button("返回"); + + // 设置按钮尺寸 + startExamButton.setPrefSize(120, 40); + backButton.setPrefSize(120, 40); + + buttonBox.getChildren().addAll(startExamButton, backButton); + + // 将组件添加到主布局 + mainLayout.getChildren().addAll(titleLabel, examSettingsForm, buttonBox); + + // 将主布局置于边界面板中央 + setCenter(mainLayout); + + // 添加事件处理器 + addEventHandlers(); + } + + /** + * 为界面组件添加事件处理器. + */ + private void addEventHandlers() { + startExamButton.setOnAction(e -> handleStartExam()); + backButton.setOnAction(e -> handleBack()); + } + + /** + * 处理开始考试按钮的操作. + */ + private void handleStartExam() { + DifficultyLevel selectedDifficulty = difficultyComboBox.getValue(); + int questionCount = questionCountSpinner.getValue(); + + if (questionCount < 10 || questionCount > 30) { + showAlert(Alert.AlertType.WARNING, "无效输入", "题目数量必须在10到30之间"); + return; + } + + // 创建并启动考试会话 + com.personalproject.model.ExamSession examSession = controller.createExamSession( + userAccount.username(), selectedDifficulty, questionCount); + + ExamView examView = new ExamView(primaryStage, controller, examSession); + primaryStage.getScene().setRoot(examView); + } + + /** + * 处理返回按钮的操作. + */ + private void handleBack() { + MainMenuView mainMenuView = new MainMenuView(primaryStage, controller, userAccount); + primaryStage.getScene().setRoot(mainMenuView); + } + + /** + * 显示提示对话框. + * + * @param alertType 提示类型 + * @param title 对话框标题 + * @param message 显示的消息 + */ + private void showAlert(Alert.AlertType alertType, String title, String message) { + Alert alert = new Alert(alertType); + alert.setTitle(title); + alert.setHeaderText(null); + alert.setContentText(message); + alert.showAndWait(); + } +} diff --git a/src/main/java/com/personalproject/ui/views/ExamView.java b/src/main/java/com/personalproject/ui/views/ExamView.java new file mode 100644 index 0000000..2ff4b17 --- /dev/null +++ b/src/main/java/com/personalproject/ui/views/ExamView.java @@ -0,0 +1,267 @@ +package com.personalproject.ui.views; + +import com.personalproject.controller.MathLearningController; +import com.personalproject.model.ExamSession; +import com.personalproject.model.QuizQuestion; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.RadioButton; +import javafx.scene.control.ToggleGroup; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; +import javafx.stage.Stage; + +/** + * 提供答题界面以及题目与选项的呈现. + */ +public class ExamView extends BorderPane { + + private final Stage primaryStage; + private final MathLearningController controller; + private final ExamSession examSession; + private Label questionNumberLabel; + private Label questionTextLabel; + private ToggleGroup answerToggleGroup; + private VBox optionsBox; + private Button nextButton; + private Button previousButton; + private Button finishButton; + private HBox buttonBox; + + /** + * ExamView 的构造函数. + * + * @param primaryStage 应用程序的主舞台 + * @param controller 数学学习控制器 + * @param examSession 当前的考试会话 + */ + public ExamView(Stage primaryStage, MathLearningController controller, ExamSession examSession) { + this.primaryStage = primaryStage; + this.controller = controller; + this.examSession = examSession; + initializeUi(); + } + + /** + * 初始化界面组件. + */ + private void initializeUi() { + // 创建主布局 + VBox mainLayout = new VBox(20); + mainLayout.setAlignment(Pos.CENTER); + mainLayout.setPadding(new Insets(20)); + + // 题号 + questionNumberLabel = new Label(); + questionNumberLabel.setFont(Font.font("System", FontWeight.BOLD, 16)); + + // 题目文本 + questionTextLabel = new Label(); + questionTextLabel.setWrapText(true); + questionTextLabel.setFont(Font.font("System", FontWeight.NORMAL, 14)); + questionTextLabel.setMaxWidth(500); + + // 选项容器 + optionsBox = new VBox(10); + optionsBox.setPadding(new Insets(10)); + answerToggleGroup = new ToggleGroup(); + + // 按钮区域 + buttonBox = new HBox(15); + buttonBox.setAlignment(Pos.CENTER); + + previousButton = new Button("上一题"); + nextButton = new Button("下一题"); + finishButton = new Button("完成考试"); + + // 设置按钮尺寸 + previousButton.setPrefSize(100, 35); + nextButton.setPrefSize(100, 35); + finishButton.setPrefSize(120, 35); + + buttonBox.getChildren().addAll(previousButton, nextButton, finishButton); + + // 将组件添加到主布局 + mainLayout.getChildren().addAll(questionNumberLabel, questionTextLabel, optionsBox, buttonBox); + + // 将主布局置于边界面板中央 + setCenter(mainLayout); + + // 加载第一题 + loadCurrentQuestion(); + + // 添加事件处理器 + addEventHandlers(); + } + + /** + * 将当前题目加载到界面. + */ + private void loadCurrentQuestion() { + try { + // 在加载下一题之前检查考试是否已完成 + if (examSession.isComplete()) { + // 如果考试已完成,则启用“完成考试”按钮 + updateButtonStates(); + return; + } + + QuizQuestion currentQuestion = examSession.getCurrentQuestion(); + int currentIndex = examSession.getCurrentQuestionIndex(); + + if (currentQuestion == null) { + showAlert(Alert.AlertType.ERROR, "错误", "当前题目为空,请重新开始考试"); + return; + } + + // 更新题号与题目文本 + questionNumberLabel.setText("第 " + (currentIndex + 1) + " 题"); + questionTextLabel.setText(currentQuestion.getQuestionText()); + + // 清空上一题的选项 + answerToggleGroup.selectToggle(null); + answerToggleGroup.getToggles().clear(); + optionsBox.getChildren().clear(); + + // 创建新的选项组件 + for (int i = 0; i < currentQuestion.getOptions().size(); i++) { + String option = currentQuestion.getOptions().get(i); + RadioButton optionButton = new RadioButton((i + 1) + ". " + option); + optionButton.setToggleGroup(answerToggleGroup); + optionButton.setUserData(i); // 存储选项索引 + + // 如果该题已有答案则自动选中 + if (examSession.hasAnswered(currentIndex) + && examSession.getUserAnswer(currentIndex) == i) { + optionButton.setSelected(true); + } + + optionsBox.getChildren().add(optionButton); + } + + // 更新按钮状态 + updateButtonStates(); + } catch (Exception e) { + showAlert(Alert.AlertType.ERROR, "错误", "加载题目时发生错误: " + e.getMessage()); + } + } + + /** + * 根据当前位置更新导航按钮的状态. + */ + private void updateButtonStates() { + try { + int currentIndex = examSession.getCurrentQuestionIndex(); + int totalQuestions = examSession.getTotalQuestions(); + + // 处理潜在极端情况 + if (totalQuestions <= 0) { + // 如果没有题目,则禁用所有导航按钮 + previousButton.setDisable(true); + nextButton.setDisable(true); + finishButton.setDisable(false); // 仍允许完成考试 + return; + } + + // “上一题”按钮状态 + previousButton.setDisable(currentIndex < 0 || currentIndex == 0); + + // “下一题”按钮状态 + nextButton.setDisable(currentIndex < 0 || currentIndex >= totalQuestions - 1); + + // “完成考试”按钮状态——在考试完成或到达最后一题时启用 + boolean isExamComplete = examSession.isComplete(); + boolean isAtLastQuestion = (currentIndex >= totalQuestions - 1); + finishButton.setDisable(!(isExamComplete || isAtLastQuestion)); + } catch (Exception e) { + // 若出现异常,禁用导航按钮以避免进一步问题 + previousButton.setDisable(true); + nextButton.setDisable(true); + finishButton.setDisable(false); // 仍允许完成考试 + showAlert(Alert.AlertType.ERROR, "错误", "更新按钮状态时发生错误: " + e.getMessage()); + } + } + + /** + * 为界面组件添加事件处理器. + */ + private void addEventHandlers() { + nextButton.setOnAction(e -> handleNextQuestion()); + previousButton.setOnAction(e -> handlePreviousQuestion()); + finishButton.setOnAction(e -> handleFinishExam()); + + // 添加变更监听器,在选项被选择时保存答案 + answerToggleGroup.selectedToggleProperty().addListener((obs, oldSelection, newSelection) -> { + if (newSelection != null) { + int selectedIndex = (Integer) newSelection.getUserData(); + examSession.setAnswer(selectedIndex); + } + }); + } + + /** + * 处理“下一题”按钮的操作. + */ + private void handleNextQuestion() { + try { + if (examSession.goToNextQuestion()) { + loadCurrentQuestion(); + } else { + // 若无法跳转到下一题,可能已经到达末尾 + // 检查考试是否完成并据此更新按钮状态 + updateButtonStates(); + } + } catch (Exception e) { + showAlert(Alert.AlertType.ERROR, "错误", "导航到下一题时发生错误: " + e.getMessage()); + } + } + + /** + * 处理“上一题”按钮的操作. + */ + private void handlePreviousQuestion() { + try { + if (examSession.goToPreviousQuestion()) { + loadCurrentQuestion(); + } else { + // 若无法返回上一题,可能已经位于开头 + updateButtonStates(); + } + } catch (Exception e) { + showAlert(Alert.AlertType.ERROR, "错误", "导航到上一题时发生错误: " + e.getMessage()); + } + } + + /** + * 显示提示对话框. + * + * @param alertType 提示类型 + * @param title 对话框标题 + * @param message 显示的消息 + */ + private void showAlert(Alert.AlertType alertType, String title, String message) { + Alert alert = new Alert(alertType); + alert.setTitle(title); + alert.setHeaderText(null); + alert.setContentText(message); + alert.showAndWait(); + } + + /** + * 处理“完成考试”按钮的操作. + */ + private void handleFinishExam() { + // 保存考试结果 + controller.saveExamResults(examSession); + + // 展示考试结果 + ExamResultsView resultsView = new ExamResultsView(primaryStage, controller, examSession); + primaryStage.getScene().setRoot(resultsView); + } +} diff --git a/src/main/java/com/personalproject/ui/views/MainMenuView.java b/src/main/java/com/personalproject/ui/views/MainMenuView.java new file mode 100644 index 0000000..7866b73 --- /dev/null +++ b/src/main/java/com/personalproject/ui/views/MainMenuView.java @@ -0,0 +1,169 @@ +package com.personalproject.ui.views; + +import com.personalproject.auth.UserAccount; +import com.personalproject.controller.MathLearningController; +import com.personalproject.model.DifficultyLevel; +import com.personalproject.ui.scenes.LoginScene; +import java.util.Optional; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.TextInputDialog; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.VBox; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; +import javafx.stage.Stage; + +/** + * 用户可以开始考试或更改设置的主菜单界面. + */ +public class MainMenuView extends BorderPane { + + private final Stage primaryStage; + private final MathLearningController controller; + private final UserAccount userAccount; + private Button changePasswordButton; + private Button logoutButton; + + /** + * MainMenuView 的构造函数. + * + * @param primaryStage 应用程序的主舞台 + * @param controller 数学学习控制器 + * @param userAccount 当前用户账户 + */ + public MainMenuView(Stage primaryStage, MathLearningController controller, + UserAccount userAccount) { + this.primaryStage = primaryStage; + this.controller = controller; + this.userAccount = userAccount; + initializeUi(); + } + + /** + * 初始化界面组件. + */ + private void initializeUi() { + // 创建主布局 + VBox mainLayout = new VBox(20); + mainLayout.setAlignment(Pos.CENTER); + mainLayout.setPadding(new Insets(20)); + + // 欢迎信息 + Label welcomeLabel = new Label("欢迎, " + userAccount.username()); + welcomeLabel.setFont(Font.font("System", FontWeight.BOLD, 18)); + + // 难度信息 + Label difficultyLabel = new Label( + "当前难度: " + userAccount.difficultyLevel().getDisplayName()); + difficultyLabel.setFont(Font.font("System", FontWeight.NORMAL, 14)); + + Label promptLabel = new Label("请选择考试难度开始答题"); + promptLabel.setFont(Font.font("System", FontWeight.NORMAL, 14)); + + VBox difficultyBox = new VBox(10); + difficultyBox.setAlignment(Pos.CENTER); + difficultyBox.getChildren().addAll( + createDifficultyButton(DifficultyLevel.PRIMARY), + createDifficultyButton(DifficultyLevel.MIDDLE), + createDifficultyButton(DifficultyLevel.HIGH)); + + // 按钮区域 + VBox buttonBox = new VBox(15); + buttonBox.setAlignment(Pos.CENTER); + + changePasswordButton = new Button("修改密码"); + logoutButton = new Button("退出登录"); + + // 设置按钮尺寸 + changePasswordButton.setPrefSize(150, 40); + logoutButton.setPrefSize(150, 40); + + buttonBox.getChildren().addAll(changePasswordButton, logoutButton); + + // 将组件添加到主布局 + mainLayout.getChildren().addAll(welcomeLabel, difficultyLabel, promptLabel, difficultyBox, + buttonBox); + + // 将主布局置于边界面板中央 + setCenter(mainLayout); + + // 添加事件处理器 + addEventHandlers(); + } + + /** + * 为界面组件添加事件处理器. + */ + private void addEventHandlers() { + changePasswordButton.setOnAction(e -> handleChangePassword()); + logoutButton.setOnAction(e -> handleLogout()); + } + + private Button createDifficultyButton(DifficultyLevel level) { + Button button = new Button(level.getDisplayName()); + button.setPrefSize(150, 40); + button.setOnAction(e -> handleDifficultySelection(level)); + return button; + } + + private void handleDifficultySelection(DifficultyLevel level) { + TextInputDialog dialog = new TextInputDialog("10"); + dialog.setTitle("题目数量"); + dialog.setHeaderText("请选择题目数量"); + dialog.setContentText("请输入题目数量 (10-30):"); + + Optional result = dialog.showAndWait(); + if (result.isEmpty()) { + return; + } + + int questionCount; + try { + questionCount = Integer.parseInt(result.get().trim()); + } catch (NumberFormatException ex) { + showAlert(Alert.AlertType.ERROR, "无效输入", "请输入有效的数字。"); + return; + } + + if (questionCount < 10 || questionCount > 30) { + showAlert(Alert.AlertType.WARNING, "题目数量范围错误", "题目数量必须在10到30之间。"); + return; + } + + com.personalproject.model.ExamSession examSession = controller.createExamSession( + userAccount.username(), level, questionCount); + + ExamView examView = new ExamView(primaryStage, controller, examSession); + primaryStage.getScene().setRoot(examView); + } + + /** + * 处理修改密码按钮的操作. + */ + private void handleChangePassword() { + PasswordChangeView passwordChangeView = new PasswordChangeView(primaryStage, controller, + userAccount); + primaryStage.getScene().setRoot(passwordChangeView); + } + + private void showAlert(Alert.AlertType alertType, String title, String message) { + Alert alert = new Alert(alertType); + alert.setTitle(title); + alert.setHeaderText(null); + alert.setContentText(message); + alert.showAndWait(); + } + + /** + * 处理退出登录按钮的操作. + */ + private void handleLogout() { + // 返回登录界面 + LoginScene loginScene = new LoginScene(primaryStage, controller); + primaryStage.getScene().setRoot(loginScene); + } +} diff --git a/src/main/java/com/personalproject/ui/views/PasswordChangeView.java b/src/main/java/com/personalproject/ui/views/PasswordChangeView.java new file mode 100644 index 0000000..4a55267 --- /dev/null +++ b/src/main/java/com/personalproject/ui/views/PasswordChangeView.java @@ -0,0 +1,173 @@ +package com.personalproject.ui.views; + +import com.personalproject.auth.UserAccount; +import com.personalproject.controller.MathLearningController; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.PasswordField; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; +import javafx.stage.Stage; + +/** + * 用户修改密码的界面. + */ +public class PasswordChangeView extends BorderPane { + + private final Stage primaryStage; + private final MathLearningController controller; + private final UserAccount userAccount; + private PasswordField oldPasswordField; + private PasswordField newPasswordField; + private PasswordField confirmNewPasswordField; + private Button changePasswordButton; + private Button backButton; + + /** + * PasswordChangeView 的构造函数. + * + * @param primaryStage 应用程序的主舞台 + * @param controller 数学学习控制器 + * @param userAccount 当前用户账户 + */ + public PasswordChangeView(Stage primaryStage, MathLearningController controller, + UserAccount userAccount) { + this.primaryStage = primaryStage; + this.controller = controller; + this.userAccount = userAccount; + initializeUi(); + } + + /** + * 初始化界面组件. + */ + private void initializeUi() { + // 创建主布局 + VBox mainLayout = new VBox(20); + mainLayout.setAlignment(Pos.CENTER); + mainLayout.setPadding(new Insets(20)); + + // 标题 + final Label titleLabel = new Label("修改密码"); + titleLabel.setFont(Font.font("System", FontWeight.BOLD, 24)); + + // 修改密码表单 + GridPane passwordForm = new GridPane(); + passwordForm.setHgap(15); + passwordForm.setVgap(15); + passwordForm.setAlignment(Pos.CENTER); + + final Label oldPasswordLabel = new Label("当前密码:"); + oldPasswordField = new PasswordField(); + oldPasswordField.setPrefWidth(200); + + final Label newPasswordLabel = new Label("新密码 (6-10位,包含大小写字母和数字):"); + newPasswordField = new PasswordField(); + newPasswordField.setPrefWidth(200); + + final Label confirmNewPasswordLabel = new Label("确认新密码:"); + confirmNewPasswordField = new PasswordField(); + confirmNewPasswordField.setPrefWidth(200); + + passwordForm.add(oldPasswordLabel, 0, 0); + passwordForm.add(oldPasswordField, 1, 0); + passwordForm.add(newPasswordLabel, 0, 1); + passwordForm.add(newPasswordField, 1, 1); + passwordForm.add(confirmNewPasswordLabel, 0, 2); + passwordForm.add(confirmNewPasswordField, 1, 2); + + // 按钮区域 + HBox buttonBox = new HBox(15); + buttonBox.setAlignment(Pos.CENTER); + + changePasswordButton = new Button("修改密码"); + backButton = new Button("返回"); + + // 设置按钮尺寸 + changePasswordButton.setPrefSize(120, 40); + backButton.setPrefSize(120, 40); + + buttonBox.getChildren().addAll(changePasswordButton, backButton); + + // 将组件添加到主布局 + mainLayout.getChildren().addAll(titleLabel, passwordForm, buttonBox); + + // 将主布局置于边界面板中央 + setCenter(mainLayout); + + // 添加事件处理器 + addEventHandlers(); + } + + /** + * 为界面组件添加事件处理器. + */ + private void addEventHandlers() { + changePasswordButton.setOnAction(e -> handleChangePassword()); + backButton.setOnAction(e -> handleBack()); + } + + /** + * 处理修改密码按钮的操作. + */ + private void handleChangePassword() { + String oldPassword = oldPasswordField.getText(); + String newPassword = newPasswordField.getText(); + String confirmNewPassword = confirmNewPasswordField.getText(); + + if (oldPassword.isEmpty() || newPassword.isEmpty() || confirmNewPassword.isEmpty()) { + showAlert(Alert.AlertType.WARNING, "警告", "请填写所有字段"); + return; + } + + if (!newPassword.equals(confirmNewPassword)) { + showAlert(Alert.AlertType.ERROR, "密码不匹配", "新密码和确认密码不一致"); + return; + } + + if (!controller.isValidPassword(newPassword)) { + showAlert(Alert.AlertType.ERROR, "密码不符合要求", + "密码长度必须为6-10位,且包含大小写字母和数字"); + return; + } + + boolean success = controller.changePassword(userAccount.username(), oldPassword, newPassword); + + if (success) { + showAlert(Alert.AlertType.INFORMATION, "修改成功", "密码修改成功!"); + handleBack(); // 返回主菜单 + } else { + showAlert(Alert.AlertType.ERROR, "修改失败", "当前密码错误或修改失败"); + } + } + + /** + * 处理返回按钮的操作. + */ + private void handleBack() { + MainMenuView mainMenuView = new MainMenuView(primaryStage, controller, userAccount); + primaryStage.getScene().setRoot(mainMenuView); + } + + /** + * 显示提示对话框. + * + * @param alertType 提示类型 + * @param title 对话框标题 + * @param message 显示的消息 + */ + private void showAlert(Alert.AlertType alertType, String title, String message) { + Alert alert = new Alert(alertType); + alert.setTitle(title); + alert.setHeaderText(null); + alert.setContentText(message); + alert.showAndWait(); + } +} diff --git a/src/main/resources/email-config.properties b/src/main/resources/email-config.properties new file mode 100644 index 0000000..db546dc --- /dev/null +++ b/src/main/resources/email-config.properties @@ -0,0 +1,20 @@ + +# 主机不变 +mail.smtp.host=smtp.126.com + +# 关键修改:端口改为 465 +mail.smtp.port=465 + +# 关键修改:禁用 STARTTLS +mail.smtp.starttls.enable=false + +# 关键修改:启用 SSL +mail.smtp.ssl.enable=true + +# 以下不变 +mail.smtp.auth=true +mail.username=soloyouth@126.com +mail.password=ZYsjxwDXFBsWeQcX +mail.from=soloyouth@126.com +mail.subject=数学学习软件注册验证码 +mail.debug=true