pull/2/head
Rumia 7 months ago
parent 8b0f76180a
commit 102d188f71

@ -1,2 +1,151 @@
# Pair
# 小初高数学学习软件Electron 桌面应用 - 功能验证版)
本前端基于 Electron提供从注册→密码设置→年级选择→输入题数→答题页→评分页的完整界面流程。**已内置测试功能**,无需后端即可验证所有基础功能。
## 功能验证模式
当前为**测试模式**`TEST_MODE = true`),可以:
- 模拟邮箱验证码发送(界面直接显示验证码)
- 模拟用户注册和密码设置
- 自动生成小初高数学题目
- 完整的答题和评分流程
- 用户头像上传和用户名修改
## 运行
1. 安装依赖(需 Node.js 18+
```bash
npm i
npm run dev
```
应用窗口将启动。
## 功能验证流程
### 1. 注册验证
- 输入用户名(必填,需唯一)
- 输入邮箱(如 `test@example.com`
- 点击"获取验证码":界面会显示验证码(如 `123456`
- 输入显示的验证码,点击"注册"
### 2. 密码设置验证
- 设置密码6-10位含大小写字母和数字`Test123`
- 确认两次输入一致
- 点击"确认设置"
### 3. 用户信息显示
- 注册完成后,右上角显示"头像+用户名+修改按钮"
- 点击头像可选择本地图片更换
- 点击"修改用户名"可更改用户名(需唯一)
- 点击"修改密码"可更改密码
### 4. 答题功能验证
- 选择年级(小学/初中/高中)
- 输入题目数量10-30
- 点击"生成试卷"
- 逐题作答,查看最终分数
## 打包发布Windows 桌面应用)
```bash
npm run build
```
产物位于 `dist/`,默认生成 NSIS 安装包,可直接分发。
## 后端对接指南
### 1. 切换到真实后端
编辑 `api/httpApi.js`
```javascript
const BASE_URL = 'http://127.0.0.1:8080'; // 你的后端地址
const TEST_MODE = false; // 关闭测试模式
```
### 2. 后端接口要求
你的后端需要实现以下接口(返回 JSON 格式):
#### 发送验证码
- **接口**`POST /api/send-code`
- **请求体**`{ "email": "user@example.com", "username": "用户名" }`
- **返回**`{ "ok": true, "message": "验证码已发送" }`
#### 用户注册
- **接口**`POST /api/register`
- **请求体**`{ "email": "user@example.com", "username": "用户名", "code": "123456" }`
- **返回**`{ "ok": true, "message": "注册成功" }`
#### 设置密码
- **接口**`POST /api/set-password`
- **请求体**`{ "email": "user@example.com", "password": "密码" }`
- **返回**`{ "ok": true, "message": "密码设置成功" }`
#### 修改密码
- **接口**`POST /api/change-password`
- **请求体**`{ "email": "user@example.com", "oldPassword": "原密码", "newPassword": "新密码" }`
- **返回**`{ "ok": true, "message": "密码修改成功" }`
#### 修改用户名
- **接口**`POST /api/change-username`
- **请求体**`{ "email": "user@example.com", "username": "新用户名" }`
- **返回**`{ "ok": true, "message": "用户名修改成功" }`
#### 获取题目
- **接口**`GET /api/questions?grade=小学&count=10`
- **返回**`{ "ok": true, "data": [题目数组], "message": "题目生成成功" }`
### 3. 题目数据格式
```javascript
{
"ok": true,
"data": [
{
"id": "小学-1",
"stem": "计算2 + 3",
"options": [
{ "key": "A", "text": "5" },
{ "key": "B", "text": "6" },
{ "key": "C", "text": "4" },
{ "key": "D", "text": "7" }
],
"answer": "A"
}
]
}
```
### 4. 后端开发建议
- **用户名唯一性**:在注册和修改用户名时都要校验
- **邮箱验证码**:建议使用 SMTP 或第三方邮件服务
- **密码安全**:建议使用 bcrypt 等加密存储
- **CORS 设置**:如果前端在浏览器中运行,需要设置 CORS 允许跨域
- **错误处理**:失败时返回 `{ "ok": false, "message": "错误信息" }`
## 约束与评分表要求对应
- 所有操作通过图形化界面完成桌面应用形态Electron
- 表单校验:邮箱格式、密码规则与两次一致、题数范围、用户名唯一性
- 交互提示成功与错误提示Toast/Alert
- 布局稳定:主要内容卡片固定最小高度,避免随题目长度跳动
- 用户管理:用户名、头像、密码修改功能完整
## 目录结构
```
project/
├─ api/
│ └─ httpApi.js # 接口层(测试模式+HTTP模式
├─ electron-main.js # Electron 主进程
├─ preload.js # 预加载脚本
├─ index.html # 主页面
├─ renderer.js # 前端逻辑
├─ styles.css # 样式文件
└─ package.json # 项目配置
```
## 截图(示意)
- 注册页、设置密码页、年级选择页、输入题数页、答题页、评分页(运行后可自行截图保存)
## 快速开始(后端开发者)
1. 下载前端项目到本地
2. 修改 `api/httpApi.js` 中的 `BASE_URL` 为你的后端地址
3. 设置 `TEST_MODE = false`
4. 实现上述接口
5. 运行 `npm run dev` 测试前端功能
6. 运行 `npm run build` 打包桌面应用

@ -0,0 +1,205 @@
// Electron 版本:功能验证版本(内置测试接口 + HTTP 接口)
(function(){
const BASE_URL = ''; // 设置为后端地址,如 'http://127.0.0.1:8080'
const TEST_MODE = true; // 设为 false 使用真实后端
// 测试数据存储
const testData = {
users: {}, // key: email, value: { email, username, password }
codes: {}, // key: email, value: code
usernames: {} // key: username(lowercased), value: email
};
function generateCode() {
return Math.floor(100000 + Math.random() * 900000).toString();
}
function generateQuestions(grade, count) {
const questions = [];
const opsByGrade = {
'小学': ['+', '-'],
'初中': ['+', '-', '×', '÷'],
'高中': ['+', '-', '×', '÷']
};
const ops = opsByGrade[grade] || ['+', '-'];
function randInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }
function makeExpr() {
const operands = randInt(2, 4);
let expr = '';
for (let i = 0; i < operands; i++) {
const n = randInt(1, 100);
expr += (i === 0 ? '' : ` ${ops[randInt(0, ops.length - 1)]} `) + n;
}
return expr;
}
const used = new Set();
while (questions.length < count) {
const expr = makeExpr();
if (used.has(expr)) continue;
used.add(expr);
const evalExpr = expr.replace(/×/g, '*').replace(/÷/g, '/');
let answer = 0;
try { answer = Math.round(eval(evalExpr)); } catch(e) { continue; }
const correct = answer;
const candidates = new Set([correct]);
while (candidates.size < 4) {
candidates.add(correct + randInt(-10, 10));
}
const options = Array.from(candidates).sort(() => Math.random() - 0.5).map((v, idx) => ({
key: String.fromCharCode(65 + idx),
text: String(v)
}));
const correctKey = options.find(o => Number(o.text) === correct)?.key;
questions.push({
id: `${grade}-${questions.length + 1}`,
stem: `计算:${expr}`,
options,
answer: correctKey
});
}
return questions;
}
async function post(path, body) {
if (TEST_MODE) {
// 测试模式:模拟延迟
await new Promise(resolve => setTimeout(resolve, 500));
return { ok: true, message: '测试模式' };
}
const res = await fetch(BASE_URL + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {})
});
return await res.json();
}
async function get(path) {
if (TEST_MODE) {
await new Promise(resolve => setTimeout(resolve, 500));
return { ok: true, data: [], message: '测试模式' };
}
const res = await fetch(BASE_URL + path);
return await res.json();
}
window.API = {
async sendRegisterCode(email, username) {
if (TEST_MODE) {
if (!username || !username.trim()) {
return { ok: false, message: '用户名不能为空' };
}
const unameKey = username.trim().toLowerCase();
if (testData.usernames[unameKey]) {
return { ok: false, message: '用户名已存在,请更换' };
}
const code = generateCode();
testData.codes[email] = code;
console.log(`[测试模式] 验证码发送到 ${email}: ${code}`);
return { ok: true, message: `验证码已发送(测试模式):${code}` };
}
try {
const data = await post('/api/send-code', { email, username });
return { ok: !!data.ok, message: data.message };
}
catch (e) { return { ok: false, message: '网络异常' }; }
},
async register(email, username, code) {
if (TEST_MODE) {
if (testData.codes[email] === code) {
if (!username || !username.trim()) {
return { ok: false, message: '用户名不能为空' };
}
const unameKey = username.trim().toLowerCase();
if (testData.usernames[unameKey]) {
return { ok: false, message: '用户名已存在,请更换' };
}
testData.users[email] = { email, username, registered: true };
testData.usernames[unameKey] = email;
delete testData.codes[email];
return { ok: true, message: '注册成功' };
}
return { ok: false, message: '验证码错误' };
}
try { const data = await post('/api/register', { email, username, code }); return { ok: !!data.ok, message: data.message }; }
catch (e) { return { ok: false, message: '网络异常' }; }
},
async setPassword(email, password) {
if (TEST_MODE) {
if (testData.users[email]) {
testData.users[email].password = password;
return { ok: true, message: '密码设置成功' };
}
return { ok: false, message: '用户未注册' };
}
try { const data = await post('/api/set-password', { email, password }); return { ok: !!data.ok, message: data.message }; }
catch (e) { return { ok: false, message: '网络异常' }; }
},
async changePassword(email, oldPassword, newPassword) {
if (TEST_MODE) {
if (testData.users[email] && testData.users[email].password === oldPassword) {
testData.users[email].password = newPassword;
return { ok: true, message: '密码修改成功' };
}
return { ok: false, message: '原密码错误' };
}
try { const data = await post('/api/change-password', { email, oldPassword, newPassword }); return { ok: !!data.ok, message: data.message }; }
catch (e) { return { ok: false, message: '网络异常' }; }
},
async changeUsername(email, newUsername) {
if (TEST_MODE) {
if (!testData.users[email]) return { ok: false, message: '用户未注册' };
if (!newUsername || !newUsername.trim()) return { ok: false, message: '用户名不能为空' };
const key = newUsername.trim().toLowerCase();
if (testData.usernames[key]) return { ok: false, message: '用户名已存在,请更换' };
// 删除旧映射并写入新映射
const old = testData.users[email].username;
if (old) delete testData.usernames[old.trim().toLowerCase()];
testData.users[email].username = newUsername;
testData.usernames[key] = email;
return { ok: true, message: '用户名修改成功' };
}
try { const data = await post('/api/change-username', { email, username: newUsername }); return { ok: !!data.ok, message: data.message }; }
catch (e) { return { ok: false, message: '网络异常' }; }
},
async getQuestions(grade, count) {
if (TEST_MODE) {
const questions = generateQuestions(grade, count);
return { ok: true, data: questions, message: '题目生成成功' };
}
try { const data = await get(`/api/questions?grade=${encodeURIComponent(grade)}&count=${count}`); return { ok: !!data.ok, data: data.data, message: data.message }; }
catch (e) { return { ok: false, message: '网络异常' }; }
}
};
// 登录接口(测试模式 + HTTP
window.API.login = async function(account, password) {
if (TEST_MODE) {
if (!account || !password) return { ok: false, message: '请输入账号与密码' };
// 允许邮箱或用户名登录
let email = null;
if (testData.users[account]) {
email = account;
} else {
const key = account.trim().toLowerCase();
if (testData.usernames[key]) email = testData.usernames[key];
}
if (!email || !testData.users[email]) return { ok: false, message: '账号不存在' };
if (testData.users[email].password !== password) return { ok: false, message: '密码不正确' };
return { ok: true, data: { email, username: testData.users[email].username } };
}
try {
const data = await post('/api/login', { account, password });
return { ok: !!data.ok, data: data.data, message: data.message };
} catch(e) {
return { ok: false, message: '网络异常' };
}
};
})();

@ -0,0 +1,136 @@
// 简单的前端Mock使用 localStorage 做轻量持久化,模拟后端接口
// 说明:真实对接时,可替换为基于 fetch 的 HTTP 调用
(function () {
const STORAGE_KEY = 'mock_users_v1';
function loadUsers() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : {};
} catch (e) {
return {};
}
}
function saveUsers(map) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
}
function hash(str) {
// 简化的哈希,仅演示,勿用于生产
let h = 0;
for (let i = 0; i < str.length; i++) {
h = ((h << 5) - h) + str.charCodeAt(i);
h |= 0;
}
return 'h' + Math.abs(h);
}
function generateCode() {
return Math.floor(100000 + Math.random() * 900000).toString();
}
function ensureUser(email) {
const users = loadUsers();
if (!users[email]) {
users[email] = { email, code: null, passwordHash: null };
saveUsers(users);
}
}
const MockAPI = {
async sendRegisterCode(email) {
ensureUser(email);
const code = generateCode();
const users = loadUsers();
users[email].code = code;
saveUsers(users);
console.log('[Mock] 向邮箱发送验证码:', email, code);
return { ok: true };
},
async register(email, code) {
const users = loadUsers();
if (!users[email] || users[email].code !== code) {
return { ok: false, message: '验证码错误或邮箱未请求验证码' };
}
return { ok: true };
},
async setPassword(email, password) {
const users = loadUsers();
if (!users[email]) return { ok: false, message: '邮箱未注册' };
users[email].passwordHash = hash(password);
saveUsers(users);
return { ok: true };
},
async changePassword(email, oldPassword, newPassword) {
const users = loadUsers();
const u = users[email];
if (!u || !u.passwordHash) return { ok: false, message: '尚未设置密码' };
if (u.passwordHash !== hash(oldPassword)) return { ok: false, message: '原密码不正确' };
u.passwordHash = hash(newPassword);
saveUsers(users);
return { ok: true };
},
async getQuestions(grade, count) {
// 生成选择题,避免重复(同卷内)
const questions = [];
const used = new Set();
const opsByGrade = {
'小学': ['+', '-'],
'初中': ['+', '-', '×', '÷'],
'高中': ['+', '-', '×', '÷']
};
const ops = opsByGrade[grade] || ['+', '-'];
function randInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }
function makeExpr() {
const operands = randInt(2, 4);
let expr = '';
for (let i = 0; i < operands; i++) {
const n = randInt(1, 100);
expr += (i === 0 ? '' : ` ${ops[randInt(0, ops.length - 1)]} `) + n;
}
return expr;
}
while (questions.length < count) {
const expr = makeExpr();
if (used.has(expr)) continue;
used.add(expr);
// 计算结果(仅演示,× ÷ 替换为 * /
const evalExpr = expr.replace(/×/g, '*').replace(/÷/g, '/');
let answer = 0;
try { answer = Math.round(eval(evalExpr)); } catch(e) { continue; }
const correct = answer;
const candidates = new Set([correct]);
while (candidates.size < 4) {
candidates.add(correct + randInt(-10, 10));
}
const options = Array.from(candidates).sort(() => Math.random() - 0.5).map((v, idx) => ({
key: String.fromCharCode(65 + idx),
text: String(v)
}));
const correctKey = options.find(o => Number(o.text) === correct)?.key;
questions.push({
id: `${grade}-${questions.length + 1}`,
stem: `计算:${expr}`,
options,
answer: correctKey
});
}
return { ok: true, data: questions };
}
};
window.API = MockAPI;
})();

@ -0,0 +1,48 @@
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
let mainWindow;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1000,
height: 680,
minWidth: 860,
minHeight: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
});
mainWindow.loadFile(path.join(__dirname, 'index.html'));
// mainWindow.webContents.openDevTools();
mainWindow.on('closed', () => {
mainWindow = null;
});
}
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (mainWindow === null) {
createWindow();
}
});
// 窗口关闭请求(用于“退出应用”)
ipcMain.handle('app:close', () => {
if (mainWindow) {
mainWindow.close();
}
});

@ -0,0 +1,179 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>小初高数学学习软件</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div id="app">
<header class="app-header">
<div class="app-title">小初高数学学习软件</div>
<div class="app-actions">
<div id="user-box" class="user-box" style="display:none;">
<div class="avatar-container">
<img id="user-avatar" class="avatar" src="" alt="avatar" />
<input type="file" id="avatar-input" accept="image/*" style="display:none;" />
</div>
<span id="user-name" class="user-name"></span>
</div>
<div id="user-actions" class="user-actions" style="display:none;">
<button id="btn-change-username" class="link">修改用户名</button>
<button id="btn-change-password" class="link">修改密码</button>
</div>
</div>
</header>
<main class="view-container">
<!-- 登录页 -->
<section id="view-login" class="view active">
<h2>登录</h2>
<div class="form-group">
<label for="login-account">邮箱或用户名</label>
<input type="text" id="login-account" placeholder="请输入邮箱或用户名" />
</div>
<div class="form-group">
<label for="login-password">密码</label>
<input type="password" id="login-password" placeholder="请输入密码" />
</div>
<div class="form-row">
<button id="btn-login" class="primary">登录</button>
<button id="btn-goto-register" class="secondary">没有账号?去注册</button>
</div>
<div class="toast" id="login-toast"></div>
</section>
<!-- 注册页 -->
<section id="view-register" class="view">
<h2>注册</h2>
<div class="form-group">
<label for="reg-username">用户名(必填,需唯一)</label>
<input type="text" id="reg-username" placeholder="请输入用户名" />
<div class="hint" id="reg-username-hint"></div>
</div>
<div class="form-group">
<label for="reg-email">邮箱</label>
<input type="email" id="reg-email" placeholder="请输入邮箱" />
<div class="hint" id="reg-email-hint"></div>
</div>
<div class="form-row">
<div class="form-group flex-1" style="margin-bottom:0;">
<label for="reg-code">验证码</label>
<input type="text" id="reg-code" placeholder="请输入验证码" />
</div>
<button id="btn-send-code" class="secondary">获取验证码</button>
</div>
<button id="btn-register" class="primary">注册</button>
<div class="toast" id="register-toast"></div>
</section>
<!-- 设置密码页 -->
<section id="view-set-password" class="view">
<h2>设置密码</h2>
<div class="form-group">
<label for="pwd-1">密码6-10位含大小写字母和数字</label>
<input type="password" id="pwd-1" placeholder="请输入密码" />
</div>
<div class="form-group">
<label for="pwd-2">重复密码</label>
<input type="password" id="pwd-2" placeholder="请再次输入密码" />
<div class="hint" id="pwd-hint"></div>
</div>
<button id="btn-set-password" class="primary">确认设置</button>
<div class="toast" id="setpwd-toast"></div>
</section>
<!-- 年级选择页 -->
<section id="view-grade" class="view">
<h2>选择年级</h2>
<div class="grade-grid">
<button class="grade-card" data-grade="小学">小学</button>
<button class="grade-card" data-grade="初中">初中</button>
<button class="grade-card" data-grade="高中">高中</button>
</div>
</section>
<!-- 输入题数页 -->
<section id="view-count" class="view">
<h2 id="count-title">请输入题目数量10-30</h2>
<div class="form-group small">
<input type="number" id="question-count" min="10" max="30" placeholder="10-30" />
<div class="hint" id="count-hint"></div>
</div>
<button id="btn-generate" class="primary">生成试卷</button>
</section>
<!-- 答题页 -->
<section id="view-quiz" class="view">
<div class="quiz-header">
<div id="quiz-progress">第 1/1 题</div>
<div id="quiz-grade"></div>
</div>
<div class="quiz-card">
<div class="question" id="quiz-question">题干加载中...</div>
<ul class="options" id="quiz-options"></ul>
<div class="quiz-actions">
<button id="btn-submit-next" class="primary">提交 / 下一题</button>
</div>
</div>
</section>
<!-- 评分页 -->
<section id="view-result" class="view">
<h2>成绩</h2>
<div class="score" id="score-text">0 分</div>
<div class="result-actions">
<button id="btn-continue" class="primary">继续做题</button>
<button id="btn-exit" class="danger">退出</button>
</div>
</section>
</main>
<!-- 修改密码弹窗 -->
<div id="modal-mask" class="modal-mask" style="display:none;">
<div class="modal">
<h3>修改密码</h3>
<div class="form-group">
<label for="old-pwd">原密码</label>
<input type="password" id="old-pwd" />
</div>
<div class="form-group">
<label for="new-pwd-1">新密码</label>
<input type="password" id="new-pwd-1" placeholder="6-10位含大小写字母和数字" />
</div>
<div class="form-group">
<label for="new-pwd-2">重复新密码</label>
<input type="password" id="new-pwd-2" />
<div class="hint" id="change-pwd-hint"></div>
</div>
<div class="modal-actions">
<button id="btn-confirm-change" class="primary">确认修改</button>
<button id="btn-cancel-change" class="secondary">取消</button>
</div>
</div>
</div>
<!-- 修改用户名弹窗 -->
<div id="modal-mask-username" class="modal-mask" style="display:none;">
<div class="modal">
<h3>修改用户名</h3>
<div class="form-group">
<label for="new-username">新用户名</label>
<input type="text" id="new-username" placeholder="请输入新用户名" />
<div class="hint" id="change-username-hint"></div>
</div>
<div class="modal-actions">
<button id="btn-confirm-username" class="primary">确认修改</button>
<button id="btn-cancel-username" class="secondary">取消</button>
</div>
</div>
</div>
</div>
<script src="./api/httpApi.js"></script>
<script src="./renderer.js"></script>
<noscript>需要启用 JavaScript</noscript>
</body>
</html>

4098
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,44 @@
{
"name": "math-learning-frontend",
"version": "1.0.0",
"private": true,
"description": "小初高数学学习软件 - 桌面前端Electron",
"main": "electron-main.js",
"scripts": {
"start": "electron .",
"dev": "electron .",
"build": "electron-builder --win --x64"
},
"author": "Pair Frontend",
"license": "MIT",
"devDependencies": {
"electron": "^31.2.1",
"electron-builder": "^24.13.3"
},
"build": {
"appId": "com.math.learning.app",
"productName": "数学学习软件",
"files": [
"index.html",
"styles.css",
"renderer.js",
"electron-main.js",
"preload.js",
"api/**/*"
],
"win": {
"target": ["nsis"],
"artifactName": "${productName}-${version}-Setup.${ext}",
"signAndEditExecutable": false
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"runAfterFinish": true,
"perMachine": false
}
}
}

@ -0,0 +1,6 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
closeApp: () => ipcRenderer.invoke('app:close')
});

@ -0,0 +1,346 @@
// 简单路由与状态管理
const state = {
currentEmail: '',
currentUsername: '',
grade: '',
questions: [],
currentIndex: 0,
answers: [] // {id, chosen}
};
function $(sel) { return document.querySelector(sel); }
function $all(sel) { return Array.from(document.querySelectorAll(sel)); }
function showView(id) {
$all('.view').forEach(v => v.classList.remove('active'));
$(id).classList.add('active');
}
function initLogin() {
const acc = $('#login-account');
const pwd = $('#login-password');
const btn = $('#btn-login');
const toReg = $('#btn-goto-register');
if (toReg) toReg.addEventListener('click', () => showView('#view-register'));
if (btn) btn.addEventListener('click', async () => {
const account = (acc?.value || '').trim();
const password = (pwd?.value || '').trim();
if (!account || !password) { showToast('#login-toast', '请输入账号与密码', false); return; }
btn.disabled = true;
const res = await window.API.login(account, password);
btn.disabled = false;
if (!res.ok) { showToast('#login-toast', res.message || '登录失败', false); return; }
// 登录成功,设置当前信息并显示用户栏
state.currentEmail = res.data.email;
state.currentUsername = res.data.username;
showUserInfo();
showView('#view-grade');
});
}
function showToast(id, text, ok = true) {
const el = $(id);
el.style.color = ok ? 'var(--success)' : 'var(--danger)';
el.textContent = text;
setTimeout(() => { el.textContent = ''; }, 2000);
}
// 表单校验
function validateEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function validatePassword(pwd) {
if (!pwd || pwd.length < 6 || pwd.length > 10) return false;
const hasUpper = /[A-Z]/.test(pwd);
const hasLower = /[a-z]/.test(pwd);
const hasDigit = /\d/.test(pwd);
return hasUpper && hasLower && hasDigit;
}
function initRegister() {
const emailInput = $('#reg-email');
const usernameInput = $('#reg-username');
const codeInput = $('#reg-code');
const emailHint = $('#reg-email-hint');
const usernameHint = $('#reg-username-hint');
$('#btn-send-code').addEventListener('click', async () => {
const email = emailInput.value.trim();
if (!validateEmail(email)) {
emailHint.textContent = '邮箱格式不正确';
return;
}
emailHint.textContent = '';
const username = (usernameInput?.value || '').trim();
if (!username) { if (usernameHint) usernameHint.textContent = '请先填写用户名'; return; }
const res = await window.API.sendRegisterCode(email, username);
if (res.ok) {
showToast('#register-toast', res.message || '验证码已发送', true);
} else {
showToast('#register-toast', res.message || '发送失败', false);
}
});
$('#btn-register').addEventListener('click', async () => {
const email = emailInput.value.trim();
const username = (usernameInput?.value || '').trim();
const code = codeInput.value.trim();
if (!validateEmail(email)) {
emailHint.textContent = '邮箱格式不正确';
return;
}
if (!username) { if (usernameHint) usernameHint.textContent = '用户名不能为空'; return; }
if (!code) {
showToast('#register-toast', '请输入验证码', false);
return;
}
console.log('[UI] 注册提交:', { email, username, code });
const res = await window.API.register(email, username, code);
if (!res.ok) {
console.warn('[UI] 注册失败:', res);
showToast('#register-toast', res.message || '注册失败', false);
return;
}
console.log('[UI] 注册成功, 进入设置密码');
state.currentEmail = email;
state.currentUsername = username;
showView('#view-set-password');
});
}
function initSetPassword() {
const p1 = $('#pwd-1');
const p2 = $('#pwd-2');
const hint = $('#pwd-hint');
$('#btn-set-password').addEventListener('click', async () => {
const a = p1.value;
const b = p2.value;
if (!validatePassword(a)) {
hint.textContent = '密码需6-10位且含大小写字母与数字';
return;
}
if (a !== b) {
hint.textContent = '两次输入不一致';
return;
}
hint.textContent = '';
const res = await window.API.setPassword(state.currentEmail, a);
if (res.ok) {
showToast('#setpwd-toast', '设置成功');
// 密码设置成功后,显示用户信息和操作按钮
showUserInfo();
showView('#view-grade');
} else {
showToast('#setpwd-toast', res.message || '设置失败', false);
}
});
}
function initGradeSelect() {
$all('.grade-card').forEach(btn => {
btn.addEventListener('click', () => {
state.grade = btn.getAttribute('data-grade');
$('#count-title').textContent = `准备生成${state.grade}数学题目请输入题目数量10-30`;
showView('#view-count');
});
});
}
function initCount() {
const input = $('#question-count');
const hint = $('#count-hint');
$('#btn-generate').addEventListener('click', async () => {
const n = Number(input.value);
if (!Number.isInteger(n) || n < 10 || n > 30) {
hint.textContent = '题目数量需在10-30之间';
return;
}
hint.textContent = '';
const res = await window.API.getQuestions(state.grade, n);
if (!res.ok) {
showToast('#count-hint', '生成题目失败', false);
return;
}
state.questions = res.data;
state.currentIndex = 0;
state.answers = [];
startQuiz();
});
}
function renderCurrentQuestion() {
const q = state.questions[state.currentIndex];
$('#quiz-progress').textContent = `${state.currentIndex + 1}/${state.questions.length}`;
$('#quiz-grade').textContent = state.grade;
$('#quiz-question').textContent = q.stem;
const list = $('#quiz-options');
list.innerHTML = '';
q.options.forEach(opt => {
const li = document.createElement('li');
li.innerHTML = `<label><input type="radio" name="opt" value="${opt.key}" /> <strong>${opt.key}.</strong> ${opt.text}</label>`;
list.appendChild(li);
});
}
function startQuiz() {
showView('#view-quiz');
renderCurrentQuestion();
}
function initQuiz() {
$('#btn-submit-next').addEventListener('click', () => {
const q = state.questions[state.currentIndex];
const chosen = document.querySelector('input[name="opt"]:checked');
if (!chosen) {
alert('请选择一个选项');
return;
}
state.answers.push({ id: q.id, chosen: chosen.value, correct: q.answer });
if (state.currentIndex < state.questions.length - 1) {
state.currentIndex++;
renderCurrentQuestion();
} else {
finishQuiz();
}
});
}
function finishQuiz() {
const total = state.questions.length;
const right = state.answers.filter(a => a.chosen === a.correct).length;
const score = Math.round((right / total) * 100);
$('#score-text').textContent = `${score}`;
showView('#view-result');
}
function initResult() {
$('#btn-continue').addEventListener('click', () => {
showView('#view-grade');
});
$('#btn-exit').addEventListener('click', () => {
// 退出回登录页并清理状态
state.currentEmail = '';
state.currentUsername = '';
state.grade = '';
state.questions = [];
state.currentIndex = 0;
state.answers = [];
const userBox = document.getElementById('user-box');
const userActions = document.getElementById('user-actions');
if (userBox) userBox.style.display = 'none';
if (userActions) userActions.style.display = 'none';
showView('#view-login');
});
}
function initChangePasswordModal() {
const mask = $('#modal-mask');
$('#btn-change-password').addEventListener('click', () => {
$('#old-pwd').value = '';
$('#new-pwd-1').value = '';
$('#new-pwd-2').value = '';
$('#change-pwd-hint').textContent = '';
mask.style.display = 'flex';
});
$('#btn-cancel-change').addEventListener('click', () => {
mask.style.display = 'none';
});
$('#btn-confirm-change').addEventListener('click', async () => {
const oldPwd = $('#old-pwd').value;
const p1 = $('#new-pwd-1').value;
const p2 = $('#new-pwd-2').value;
const hint = $('#change-pwd-hint');
if (!validatePassword(p1)) { hint.textContent = '新密码不符合规则'; return; }
if (p1 !== p2) { hint.textContent = '两次新密码不一致'; return; }
const res = await window.API.changePassword(state.currentEmail, oldPwd, p1);
if (!res.ok) { hint.textContent = res.message || '修改失败'; return; }
mask.style.display = 'none';
alert('修改成功');
});
}
// 修改用户名
function showUserInfo() {
// 显示用户信息(头像+用户名)
const userBox = document.getElementById('user-box');
const userActions = document.getElementById('user-actions');
const userName = document.getElementById('user-name');
const userAvatar = document.getElementById('user-avatar');
if (userBox && userActions && userName && userAvatar) {
userBox.style.display = 'flex';
userActions.style.display = 'flex';
userName.textContent = state.currentUsername || '用户';
// 默认头像
userAvatar.src = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="%23222"/><circle cx="32" cy="24" r="14" fill="%23555"/><rect x="14" y="40" width="36" height="16" rx="8" fill="%23555"/></svg>';
}
}
function initChangeUsernameModal() {
const mask = $('#modal-mask-username');
const btn = $('#btn-change-username');
if (!btn) return;
btn.addEventListener('click', () => {
$('#new-username').value = '';
$('#change-username-hint').textContent = '';
mask.style.display = 'flex';
});
$('#btn-cancel-username').addEventListener('click', () => {
mask.style.display = 'none';
});
$('#btn-confirm-username').addEventListener('click', async () => {
const newName = ($('#new-username')?.value || '').trim();
const hint = $('#change-username-hint');
if (!newName) { hint.textContent = '新用户名不能为空'; return; }
const res = await window.API.changeUsername(state.currentEmail, newName);
if (!res.ok) { hint.textContent = res.message || '修改失败'; return; }
mask.style.display = 'none';
alert('用户名修改成功');
// 更新显示的用户名
state.currentUsername = newName;
const userName = document.getElementById('user-name');
if (userName) userName.textContent = newName;
});
}
// 头像上传功能
function initAvatarUpload() {
const avatarContainer = document.querySelector('.avatar-container');
const avatarInput = document.getElementById('avatar-input');
const userAvatar = document.getElementById('user-avatar');
if (avatarContainer && avatarInput && userAvatar) {
avatarContainer.addEventListener('click', () => {
avatarInput.click();
});
avatarInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
userAvatar.src = e.target.result;
// 这里可以调用后端接口上传头像
console.log('头像已选择,可上传到后端');
};
reader.readAsDataURL(file);
}
});
}
}
// 启动
window.addEventListener('DOMContentLoaded', () => {
// 默认进入登录页
initRegister();
initSetPassword();
initGradeSelect();
initCount();
initQuiz();
initResult();
initChangePasswordModal();
initChangeUsernameModal();
initAvatarUpload();
initLogin();
});

@ -0,0 +1,147 @@
:root {
--bg: #0e1116;
--panel: #151a21;
--text: #e8edf3;
--muted: #9aa4b2;
--primary: #3b82f6;
--primary-2: #2563eb;
--danger: #ef4444;
--success: #22c55e;
--border: #202734;
}
* { box-sizing: border-box; }
html, body, #app { height: 100%; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, 'Helvetica Neue', Arial, 'Noto Sans', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background: var(--bg);
color: var(--text);
}
.app-header {
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid var(--border);
}
.app-title { font-weight: 600; }
.app-actions { display: flex; align-items: center; gap: 12px; }
.app-actions .link {
background: transparent;
border: none;
color: var(--primary);
cursor: pointer;
font-size: 12px;
}
.user-box { display: flex; align-items: center; gap: 8px; }
.avatar-container { position: relative; cursor: pointer; }
.avatar { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; border: 1px solid var(--border); background: #233; }
.user-name { color: #cbd5e1; font-size: 13px; white-space: nowrap; }
.user-actions { display: flex; gap: 8px; }
.view-container {
height: calc(100% - 56px);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.view {
width: 860px;
max-width: 100%;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
display: none;
min-height: 440px; /* 固定高度避免跳动 */
}
.view.active { display: block; }
/* 注册页按钮与上方间距 */
#view-register .primary { margin-top: 20px; }
/* 设置密码页:调大首个分组间距,调小按钮上边距 */
#view-set-password .form-group:first-of-type { margin-bottom: 22px; }
#view-set-password .form-group:first-of-type label { margin-bottom: 10px; }
#view-set-password .primary { margin-top: 6px; }
/* 年级选择:标题与卡片拉开距离并居中卡片 */
#view-grade h2 { margin-bottom: 0; }
#view-grade .grade-grid {
margin-top: 28px; /* 与标题拉开更大距离 */
justify-content: center; /* 居中排列 */
grid-template-columns: repeat(3, 200px); /* 固定宽度列,利于视觉对齐 */
max-width: 680px; /* 容器最大宽度,便于居中 */
margin-left: auto;
margin-right: auto;
gap: 16px; /* 稍大空隙更美观 */
}
.grade-card { height: 128px; } /* 卡片略高,空间更舒展 */
/* 用户名与头像更大一些 */
.avatar { width: 56px; height: 56px; }
.user-name { font-size: 16px; }
/* 生成题目:标题与输入区域拉开距离 */
#view-count h2 { margin-bottom: 0; }
#view-count .form-group.small { margin-top: 24px; }
/* 答题页:对齐选项单选与文本;增加内部间距,防止与按钮重叠的视觉拥挤 */
.quiz-card { gap: 12px; }
.question { margin-bottom: 16px; }
.options { gap: 10px; }
.options li label { display: flex; align-items: center; gap: 8px; }
.options input[type="radio"] { vertical-align: middle; }
.quiz-actions { margin-top: 20px; }
/* 成绩页垂直居中仅在激活时显示为flex */
#view-result.view.active { display: flex; flex-direction: column; justify-content: center; align-items: center; }
h2 { margin: 0 0 16px; font-size: 20px; }
.form-group { margin-bottom: 16px; display: flex; flex-direction: column; }
.form-group.small { max-width: 320px; }
label { margin-bottom: 6px; color: var(--muted); font-size: 13px; }
input[type="email"], input[type="text"], input[type="password"], input[type="number"] {
background: #0c1016;
border: 1px solid var(--border);
color: var(--text);
padding: 10px 12px;
border-radius: 8px;
}
.hint { color: var(--muted); font-size: 12px; min-height: 16px; }
.form-row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: nowrap; }
.flex-1 { flex: 1; }
button { cursor: pointer; border: none; padding: 10px 14px; border-radius: 8px; }
button.primary { background: var(--primary); color: #fff; }
button.primary:hover { background: var(--primary-2); }
button.secondary { background: #2a3342; color: #cbd5e1; }
button.danger { background: var(--danger); color: #fff; }
.toast { margin-top: 8px; min-height: 18px; color: var(--success); }
.grade-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.grade-card { background: #0c1016; border: 1px solid var(--border); color: #e5e7eb; height: 120px; border-radius: 12px; font-size: 18px; }
.grade-card:hover { border-color: var(--primary); }
.quiz-header { display: flex; justify-content: space-between; margin-bottom: 12px; color: var(--muted); }
.quiz-card { background: #0c1016; border: 1px solid var(--border); border-radius: 12px; padding: 16px; min-height: 300px; display: flex; flex-direction: column; }
.question { font-size: 18px; margin-bottom: 12px; }
.options { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
.options li { background: #0f1420; border: 1px solid var(--border); border-radius: 8px; padding: 10px; display: flex; align-items: center; gap: 8px; }
.quiz-actions { display: flex; justify-content: flex-end; margin-top: auto; }
.score { font-size: 48px; margin: 24px 0; text-align: center; }
.result-actions { display: flex; gap: 12px; justify-content: center; }
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; }
.modal { width: 420px; background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 16px; }
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; }
Loading…
Cancel
Save