引言:前台题库提交系统的核心挑战
在现代在线教育和考试平台中,前台题库选项提交系统是用户交互的核心环节。这个系统直接关系到用户的学习成果和考试成绩,因此必须确保其稳定性和可靠性。然而,在实际开发中,我们常常面临以下挑战:
- 用户误操作:用户可能在答题过程中误触提交按钮,或者在未完成所有题目时意外离开页面。
- 数据丢失:网络不稳定、浏览器崩溃或用户误操作都可能导致已填写的答案丢失。
- 系统稳定性:高并发情况下,系统需要处理大量提交请求,同时保持响应速度。
本文将详细探讨如何通过技术手段和用户体验设计来避免这些问题,确保用户答题体验流畅稳定。
一、前端防误操作设计
1.1 提交按钮的二次确认机制
核心思想:在用户点击提交按钮时,通过二次确认弹窗来防止误操作。
实现方式:
// 提交按钮点击事件处理
function handleSubmit() {
// 检查是否所有必答题都已完成
if (!checkAllQuestionsAnswered()) {
showNotification('请完成所有必答题后再提交');
return;
}
// 弹出二次确认对话框
if (confirm('确定要提交答案吗?提交后将无法修改!')) {
// 执行提交操作
submitAnswers();
}
}
优化建议:
- 对于重要考试,可以增加”输入确认码”的步骤
- 使用更美观的自定义模态框替代原生confirm
- 在弹窗中显示已完成的题目数量和未完成的题目数量
1.2 防止重复提交
核心思想:在提交过程中禁用提交按钮,防止用户重复点击。
实现方式:
// 提交函数
async function submitAnswers() {
const submitBtn = document.getElementById('submitBtn');
const originalText = submitBtn.textContent;
try {
// 禁用按钮并显示加载状态
submitBtn.disabled = true;
submitBtn.textContent = '提交中...';
// 执行提交操作
const result = await fetch('/api/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(answers)
});
if (result.ok) {
showSuccess('提交成功!');
// 跳转到结果页面
setTimeout(() => {
window.location.href = '/result';
}, 1500);
} else {
throw new Error('提交失败');
}
} catch (error) {
showError('提交失败,请重试');
// 恢复按钮状态
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
}
1.3 答题过程中的实时保存
核心思想:通过自动保存机制,防止因意外情况导致的数据丢失。
实现方式:
// 自动保存函数
function autoSave() {
const answers = collectAnswers();
localStorage.setItem('exam_answers', JSON.stringify(answers));
localStorage.setItem('last_save_time', new Date().toISOString());
}
// 页面加载时恢复数据
function restoreAnswers() {
const saved = localStorage.getItem('exam_answers');
if (saved) {
const answers = JSON.parse(saved);
// 填充到表单中
Object.keys(answers).forEach(questionId => {
const input = document.querySelector(`[name="${questionId}"]`);
if (input) {
input.value = answers[questionId];
}
});
// 显示恢复提示
const lastSaveTime = localStorage.getItem('last_save_time');
if (lastSaveTime) {
showNotification(`已恢复 ${new Date(lastSaveTime).toLocaleString()} 的答题进度`);
}
}
}
// 监听答题变化,触发自动保存
document.addEventListener('change', (e) => {
if (e.target.name && e.target.name.startsWith('question_')) {
// 防抖处理,避免频繁保存
clearTimeout(window.autoSaveTimer);
window.autoSaveTimer = setTimeout(autoSave, 1000);
}
});
// 页面加载时恢复数据
window.addEventListener('DOMContentLoaded', restoreAnswers);
二、后端数据保护机制
2.1 数据库事务处理
核心思想:使用数据库事务确保数据一致性,防止部分提交导致的数据不完整。
实现方式(以Node.js + PostgreSQL为例):
const { Pool } = require('pg');
const pool = new Pool();
async function submitExamAnswers(userId, examId, answers) {
const client = await pool.connect();
try {
// 开始事务
await client.query('BEGIN');
// 1. 检查是否已提交
const checkResult = await client.query(
'SELECT id FROM submissions WHERE user_id = $1 AND exam_id = $2',
[userId, examId]
);
if (checkResult.rows.length > 0) {
throw new Error('该考试已提交,不能重复提交');
}
// 2. 插入提交记录
const submissionResult = await client.query(
`INSERT INTO submissions (user_id, exam_id, submitted_at, status)
VALUES ($1, $2, NOW(), 'submitted')
RETURNING id`,
[userId, examId]
);
const submissionId = submissionResult.rows[0].id;
// 3. 批量插入答案
const answerValues = answers.map((answer, index) => [
submissionId,
answer.questionId,
answer.selectedOption,
answer.answerText,
index + 1
]);
// 使用参数化查询防止SQL注入
const insertQuery = `
INSERT INTO answers (submission_id, question_id, selected_option, answer_text, question_order)
VALUES ${answerValues.map((_, i) =>
`($${i * 5 + 1}, $${i * 5 + 2}, $${i * 5 + 3}, $${i * 5 + 4}, $${i * 5 + 5})`
).join(',')}
`;
// 展平参数数组
const flatValues = answerValues.flat();
await client.query(insertQuery, flatValues);
// 4. 提交事务
await client.query('COMMIT');
return { success: true, submissionId };
} catch (error) {
// 回滚事务
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
2.2 接口幂等性设计
核心思想:防止因网络重试导致的重复提交。
实现方式:
// 生成幂等键
function generateIdempotencyKey(userId, examId) {
return `exam:${examId}:user:${userId}:${Date.now()}`;
}
// 提交接口(带幂等性检查)
app.post('/api/submit', async (req, res) => {
const { userId, examId, answers, idempotencyKey } = req.body;
// 检查幂等键是否已存在
const existing = await redis.get(`idempotency:${idempotencyKey}`);
if (existing) {
return res.status(409).json({ error: '重复提交' });
}
// 设置幂等键(有效期24小时)
await redis.setex(`idempotency:${idempotencyKey}`, 86400, 'processing');
try {
const result = await submitExamAnswers(userId, examId, answers);
// 标记为已完成
await redis.setex(`idempotency:${idempotencyKey}`, 86400, 'completed');
res.json(result);
} catch (error) {
// 出错时删除幂等键,允许重试
await redis.del(`idempotency:${idempotencyKey}`);
res.status(500).json({ error: error.message });
}
});
2.3 数据备份与恢复机制
核心思想:定期备份答题数据,防止数据库故障导致的数据丢失。
实现方式:
// 定时备份任务(使用Node.js + Cron)
const cron = require('node-cron');
const fs = require('fs');
const path = require('path');
// 每小时备份一次答题数据
cron.schedule('0 * * * *', async () => {
try {
const backupDir = path.join(__dirname, 'backups');
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir);
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupFile = path.join(backupDir, `answers-backup-${timestamp}.json`);
// 查询最近一小时的答题数据
const result = await pool.query(`
SELECT s.user_id, s.exam_id, a.question_id, a.selected_option, a.answer_text, s.submitted_at
FROM submissions s
JOIN answers a ON s.id = a.submission_id
WHERE s.submitted_at >= NOW() - INTERVAL '1 hour'
`);
// 写入备份文件
fs.writeFileSync(backupFile, JSON.stringify(result.rows, null, 2));
console.log(`Backup created: ${backupFile}`);
// 清理7天前的备份
cleanOldBackups(backupDir, 7);
} catch (error) {
console.error('Backup failed:', error);
// 发送告警通知
sendAlert('备份失败: ' + error.message);
}
});
function cleanOldBackups(backupDir, days) {
const files = fs.readdirSync(backupDir);
const now = Date.now();
const cutoff = days * 24 * 60 * 60 * 1000;
files.forEach(file => {
const filePath = path.join(backupDir, file);
const stats = fs.statSync(filePath);
if (now - stats.mtimeMs > cutoff) {
fs.unlinkSync(filePath);
console.log(`Deleted old backup: ${file}`);
}
});
}
三、网络与浏览器异常处理
3.1 网络异常检测与重试机制
核心思想:检测网络异常并自动重试,确保数据最终能成功提交。
实现方式:
// 带重试机制的提交函数
async function submitWithRetry(data, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
const response = await fetch('/api/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
signal: controller.signal
});
clearTimeout(timeoutId);
if (response.ok) {
return await response.json();
}
// 409表示重复提交,不需要重试
if (response.status === 409) {
throw new Error('重复提交,请勿重复操作');
}
// 其他错误状态码
const errorData = await response.json();
throw new Error(errorData.error || `服务器错误 (${response.status})`);
} catch (error) {
if (attempt === maxRetries) {
throw error;
}
// 指数退避策略
const delay = Math.pow(2, attempt) * 1000;
console.log(`第${attempt}次尝试失败,${delay}ms后重试...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// 使用示例
async function handleExamSubmit() {
const submitBtn = document.getElementById('submitBtn');
submitBtn.disabled = true;
submitBtn.textContent = '提交中...';
try {
const answers = collectAnswers();
const idempotencyKey = generateIdempotencyKey(userId, examId);
const result = await submitWithRetry({
userId,
examId,
answers,
idempotencyKey
});
showSuccess('提交成功!');
// 清除本地存储
localStorage.removeItem('exam_answers');
localStorage.removeItem('last_save_time');
// 跳转到结果页
setTimeout(() => {
window.location.href = `/result/${result.submissionId}`;
}, 1500);
} catch (error) {
showError(`提交失败: ${error.message}`);
// 保留本地数据,允许用户稍后重试
showNotification('您的答案已保存在本地,可以稍后重试');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = '提交答案';
}
}
3.2 页面关闭/刷新保护
核心思想:防止用户在未提交的情况下意外关闭页面或刷新。
实现方式:
// 页面关闭/刷新前的确认
window.addEventListener('beforeunload', (e) => {
// 检查是否有未保存的更改
if (hasUnsavedChanges()) {
// 标准方式
e.preventDefault();
e.returnValue = '您有未提交的答案,确定要离开吗?';
return e.returnValue;
}
});
// 检查是否有未保存的更改
function hasUnsavedChanges() {
const saved = localStorage.getItem('exam_answers');
if (!saved) return false;
const savedAnswers = JSON.parse(saved);
const currentAnswers = collectAnswers();
// 比较当前答案和已保存的答案
return JSON.stringify(savedAnswers) !== JSON.stringify(currentAnswers);
}
// React/Vue等框架中的实现(以React为例)
function ExamPage() {
const [hasChanges, setHasChanges] = useState(false);
useEffect(() => {
const handleBeforeUnload = (e) => {
if (hasChanges) {
e.preventDefault();
e.returnValue = '您有未提交的答案,确定要离开吗?';
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [hasChanges]);
// 在答题变化时更新hasChanges状态
const handleAnswerChange = () => {
setHasChanges(true);
};
return (
<div>
{/* 考试内容 */}
<button
onClick={handleSubmit}
disabled={!hasChanges}
>
提交答案
</button>
</div>
);
}
3.3 浏览器崩溃恢复
核心思想:通过Service Worker和IndexedDB实现更可靠的数据存储。
实现方式:
// Service Worker实现后台同步
// sw.js
self.addEventListener('sync', (event) => {
if (event.tag === 'submit-exam') {
event.waitUntil(submitExamWhenOnline());
}
});
async function submitExamWhenOnline() {
const db = await openIndexedDB();
const pendingSubmissions = await getPendingSubmissions(db);
for (const submission of pendingSubmissions) {
try {
const response = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(submission.data)
});
if (response.ok) {
await deletePendingSubmission(db, submission.id);
// 发送通知给用户
self.registration.showNotification('考试答案已成功提交');
}
} catch (error) {
console.error('后台提交失败:', error);
}
}
}
// IndexedDB操作
function openIndexedDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('ExamDB', 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('pendingSubmissions')) {
db.createObjectStore('pendingSubmissions', { keyPath: 'id', autoIncrement: true });
}
};
});
}
// 页面中使用
async function savePendingSubmission(data) {
const db = await openIndexedDB();
const tx = db.transaction('pendingSubmissions', 'readwrite');
const store = tx.objectStore('pendingSubmissions');
await store.add({
data,
timestamp: Date.now(),
userId: data.userId,
examId: data.examId
});
// 注册后台同步
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('submit-exam');
}
}
四、用户体验优化
4.1 实时反馈与状态指示
核心思想:让用户清楚地知道当前系统的状态,减少焦虑。
实现方式:
// 状态管理器
class ExamStatusManager {
constructor() {
this.status = 'ready'; // ready, saving, saved, submitting, submitted, error
this.lastSaveTime = null;
this.retryCount = 0;
}
updateStatus(newStatus, message = '') {
this.status = newStatus;
this.renderStatusIndicator(newStatus, message);
}
renderStatusIndicator(status, message) {
const indicator = document.getElementById('statusIndicator');
if (!indicator) return;
const statusConfig = {
'ready': { text: '就绪', color: '#6c757d', icon: '○' },
'saving': { text: '保存中...', color: '#ffc107', icon: '⋯' },
'saved': { text: `已保存 ${this.formatTime(this.lastSaveTime)}`, color: '#28a745', icon: '✓' },
'submitting': { text: '提交中...', color: '#17a2b8', icon: '↑' },
'submitted': { text: '已提交', color: '#28a745', icon: '✓' },
'error': { text: message || '出错', color: '#dc3545', icon: '!' }
};
const config = statusConfig[status] || statusConfig['ready'];
indicator.innerHTML = `
<span style="color: ${config.color}">${config.icon}</span>
${config.text}
`;
}
formatTime(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp);
return date.toLocaleTimeString();
}
// 自动保存状态更新
async autoSave() {
this.updateStatus('saving');
try {
const answers = collectAnswers();
localStorage.setItem('exam_answers', JSON.stringify(answers));
this.lastSaveTime = Date.now();
this.updateStatus('saved');
this.retryCount = 0;
} catch (error) {
this.updateStatus('error', '保存失败');
this.retryCount++;
if (this.retryCount < 3) {
setTimeout(() => this.autoSave(), 2000);
}
}
}
}
// 使用示例
const statusManager = new ExamStatusManager();
// 监听答题变化
document.addEventListener('change', (e) => {
if (e.target.name && e.target.name.startsWith('question_')) {
// 防抖自动保存
clearTimeout(window.saveTimer);
window.saveTimer = setTimeout(() => {
statusManager.autoSave();
}, 1000);
}
});
4.2 网络状态检测
核心思想:实时检测网络状态,给用户明确的反馈。
实现方式:
// 网络状态检测器
class NetworkStatusDetector {
constructor() {
this.isOnline = navigator.onLine;
this.setupEventListeners();
this.showStatus();
}
setupEventListeners() {
window.addEventListener('online', () => {
this.isOnline = true;
this.showStatus();
this.tryPendingOperations();
});
window.addEventListener('offline', () => {
this.isOnline = false;
this.showStatus();
});
}
showStatus() {
const statusElement = document.getElementById('networkStatus');
if (!statusElement) return;
if (this.isOnline) {
statusElement.innerHTML = '<span style="color: #28a745">●</span> 在线';
statusElement.style.display = 'none'; // 在线时可以隐藏
} else {
statusElement.innerHTML = '<span style="color: #dc3545">●</span> 离线 - 答案将保存在本地';
statusElement.style.display = 'block';
}
}
// 尝试执行待处理的操作
async tryPendingOperations() {
const pending = localStorage.getItem('pending_submission');
if (pending) {
try {
const data = JSON.parse(pending);
const result = await submitWithRetry(data);
localStorage.removeItem('pending_submission');
showNotification('已自动提交离线期间的答案');
} catch (error) {
console.error('自动提交失败:', error);
}
}
}
}
// 初始化网络状态检测
const networkDetector = new NetworkStatusDetector();
4.3 进度指示与预估时间
核心思想:让用户了解当前进度和预估完成时间。
实现方式:
// 进度指示器
class ProgressIndicator {
constructor(totalQuestions) {
this.total = totalQuestions;
this.answered = 0;
this.startTime = Date.now();
}
updateProgress(answeredCount) {
this.answered = answeredCount;
const percentage = Math.round((this.answered / this.total) * 100);
// 更新进度条
const progressBar = document.getElementById('progressBar');
if (progressBar) {
progressBar.style.width = `${percentage}%`;
progressBar.textContent = `${percentage}%`;
}
// 更新计数器
const counter = document.getElementById('progressCounter');
if (counter) {
counter.textContent = `${this.answered}/${this.total}`;
}
// 预估剩余时间
const elapsed = (Date.now() - this.startTime) / 1000; // 秒
const avgTimePerQuestion = elapsed / this.answered;
const remainingQuestions = this.total - this.answered;
const estimatedRemaining = Math.round(avgTimePerQuestion * remainingQuestions);
const timeEstimate = document.getElementById('timeEstimate');
if (timeEstimate && this.answered > 0) {
const minutes = Math.floor(estimatedRemaining / 60);
const seconds = estimatedRemaining % 60;
timeEstimate.textContent = `预估剩余: ${minutes}分${seconds}秒`;
}
}
}
// 使用示例
const progressIndicator = new ProgressIndicator(50); // 假设50道题
// 监听答题变化
document.addEventListener('change', (e) => {
if (e.target.name && e.target.name.startsWith('question_')) {
const answeredCount = document.querySelectorAll('input[name^="question_"]:checked').length;
progressIndicator.updateProgress(answeredCount);
}
});
五、高并发场景下的稳定性保障
5.1 请求队列与限流
核心思想:防止用户快速连续点击导致系统过载。
实现方式:
// 请求队列管理器
class RequestQueue {
constructor(maxConcurrent = 3) {
this.queue = [];
this.running = 0;
this.maxConcurrent = maxConcurrent;
}
async add(requestFn) {
return new Promise((resolve, reject) => {
this.queue.push({ requestFn, resolve, reject });
this.processQueue();
});
}
async processQueue() {
if (this.running >= this.maxConcurrent || this.queue.length === 0) {
return;
}
const { requestFn, resolve, reject } = this.queue.shift();
this.running++;
try {
const result = await requestFn();
resolve(result);
} catch (error) {
reject(error);
} finally {
this.running--;
this.processQueue();
}
}
}
// 使用示例
const requestQueue = new RequestQueue(2); // 最多同时处理2个请求
async function submitAnswerWithQueue(answerData) {
return requestQueue.add(async () => {
const response = await fetch('/api/answer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(answerData)
});
if (!response.ok) {
throw new Error('提交失败');
}
return response.json();
});
}
5.2 WebSocket实时通信
核心思想:使用WebSocket实现服务器推送,减少轮询开销。
实现方式:
// WebSocket管理器
class WebSocketManager {
constructor(url) {
this.url = url;
this.ws = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.messageQueue = [];
this.isConnected = false;
}
connect() {
try {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('WebSocket connected');
this.isConnected = true;
this.reconnectAttempts = 0;
this.flushMessageQueue();
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleMessage(data);
};
this.ws.onclose = () => {
console.log('WebSocket disconnected');
this.isConnected = false;
this.attemptReconnect();
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
} catch (error) {
console.error('Failed to connect WebSocket:', error);
}
}
send(data) {
if (this.isConnected && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
} else {
this.messageQueue.push(data);
}
}
flushMessageQueue() {
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift();
this.send(message);
}
}
attemptReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnection attempts reached');
return;
}
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
console.log(`Reconnecting in ${delay}ms...`);
setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, delay);
}
handleMessage(data) {
switch (data.type) {
case 'answer_saved':
// 更新UI显示答案已保存
updateAnswerStatus(data.questionId, 'saved');
break;
case 'submission_confirmed':
// 显示提交确认
showSubmissionConfirmation(data.submissionId);
break;
case 'error':
showError(data.message);
break;
}
}
}
// 使用示例
const wsManager = new WebSocketManager('wss://your-api.com/exam-ws');
wsManager.connect();
// 发送答案
function sendAnswerViaWebSocket(answer) {
wsManager.send({
type: 'submit_answer',
data: answer
});
}
六、完整系统架构示例
6.1 前端完整实现
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>在线考试系统</title>
<style>
.status-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
background: #f8f9fa;
padding: 10px 20px;
border-bottom: 1px solid #dee2e6;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 1000;
}
.progress-container {
flex: 1;
margin: 0 20px;
}
.progress-bar {
height: 20px;
background: #e9ecef;
border-radius: 10px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #28a745;
transition: width 0.3s;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 12px;
}
.status-indicator {
font-size: 14px;
white-space: nowrap;
}
.network-status {
font-size: 12px;
margin-left: 10px;
}
.question-container {
margin-top: 80px;
padding: 20px;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.question {
margin-bottom: 30px;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.question-title {
font-weight: bold;
margin-bottom: 15px;
font-size: 16px;
}
.options {
display: flex;
flex-direction: column;
gap: 10px;
}
.option {
padding: 10px;
border: 1px solid #dee2e6;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.option:hover {
background: #f8f9fa;
border-color: #007bff;
}
.option.selected {
background: #e7f3ff;
border-color: #007bff;
font-weight: bold;
}
.submit-section {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
padding: 15px 20px;
border-top: 1px solid #dee2e6;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 1000;
}
.submit-btn {
padding: 12px 30px;
background: #28a745;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
transition: background 0.2s;
}
.submit-btn:hover:not(:disabled) {
background: #218838;
}
.submit-btn:disabled {
background: #6c757d;
cursor: not-allowed;
}
.notification {
position: fixed;
top: 70px;
right: 20px;
padding: 15px 20px;
background: white;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 2000;
animation: slideIn 0.3s;
max-width: 300px;
}
.notification.success {
border-left: 4px solid #28a745;
}
.notification.error {
border-left: 4px solid #dc3545;
}
.notification.warning {
border-left: 4px solid #ffc107;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.time-estimate {
font-size: 12px;
color: #6c757d;
margin-top: 5px;
}
</style>
</head>
<body>
<!-- 状态栏 -->
<div class="status-bar">
<div id="networkStatus" class="network-status"></div>
<div class="progress-container">
<div class="progress-bar">
<div id="progressBar" class="progress-fill" style="width: 0%">0%</div>
</div>
<div id="timeEstimate" class="time-estimate"></div>
</div>
<div id="statusIndicator" class="status-indicator">○ 就绪</div>
</div>
<!-- 题目容器 -->
<div id="questionContainer" class="question-container">
<!-- 题目将通过JS动态生成 -->
</div>
<!-- 提交区域 -->
<div class="submit-section">
<div id="progressCounter">0/50</div>
<button id="submitBtn" class="submit-btn" onclick="handleExamSubmit()">提交答案</button>
</div>
<script>
// 全局状态管理
const ExamState = {
userId: 'user_123',
examId: 'exam_456',
totalQuestions: 50,
answers: {},
isSubmitting: false
};
// 初始化
document.addEventListener('DOMContentLoaded', () => {
initializeExam();
setupEventListeners();
restoreAnswers();
new NetworkStatusDetector();
});
function initializeExam() {
const container = document.getElementById('questionContainer');
// 生成示例题目
for (let i = 1; i <= 50; i++) {
const questionDiv = document.createElement('div');
questionDiv.className = 'question';
questionDiv.innerHTML = `
<div class="question-title">${i}. 这是第${i}道题目?</div>
<div class="options">
<label class="option">
<input type="radio" name="question_${i}" value="A"> A. 选项A
</label>
<label class="option">
<input type="radio" name="question_${i}" value="B"> B. 选项B
</label>
<label class="option">
<input type="radio" name="question_${i}" value="C"> C. 选项C
</label>
<label class="option">
<input type="radio" name="question_${i}" value="D"> D. 选项D
</label>
</div>
`;
container.appendChild(questionDiv);
}
}
function setupEventListeners() {
// 答题变化监听
document.addEventListener('change', (e) => {
if (e.target.name && e.target.name.startsWith('question_')) {
// 更新选中状态样式
updateOptionStyles(e.target);
// 更新进度
updateProgress();
// 自动保存
clearTimeout(window.autoSaveTimer);
window.autoSaveTimer = setTimeout(() => {
autoSave();
}, 1000);
}
});
// 页面关闭保护
window.addEventListener('beforeunload', (e) => {
if (hasUnsavedChanges()) {
e.preventDefault();
e.returnValue = '您有未提交的答案,确定要离开吗?';
return e.returnValue;
}
});
}
function updateOptionStyles(radio) {
// 清除同题目的其他选项样式
const groupName = radio.name;
document.querySelectorAll(`input[name="${groupName}"]`).forEach(input => {
input.closest('.option').classList.remove('selected');
});
// 添加当前选项样式
radio.closest('.option').classList.add('selected');
}
function updateProgress() {
const answeredCount = document.querySelectorAll('input[name^="question_"]:checked').length;
const percentage = Math.round((answeredCount / ExamState.totalQuestions) * 100);
// 更新进度条
const progressBar = document.getElementById('progressBar');
progressBar.style.width = `${percentage}%`;
progressBar.textContent = `${percentage}%`;
// 更新计数器
const counter = document.getElementById('progressCounter');
counter.textContent = `${answeredCount}/${ExamState.totalQuestions}`;
// 更新预估时间
updateTimeEstimate(answeredCount);
}
function updateTimeEstimate(answeredCount) {
if (answeredCount === 0) return;
const timeEstimate = document.getElementById('timeEstimate');
const elapsed = (Date.now() - window.examStartTime) / 1000;
const avgTime = elapsed / answeredCount;
const remaining = ExamState.totalQuestions - answeredCount;
const estimatedSeconds = Math.round(avgTime * remaining);
const minutes = Math.floor(estimatedSeconds / 60);
const seconds = estimatedSeconds % 60;
timeEstimate.textContent = `预估剩余: ${minutes}分${seconds}秒`;
}
function collectAnswers() {
const answers = {};
for (let i = 1; i <= ExamState.totalQuestions; i++) {
const selected = document.querySelector(`input[name="question_${i}"]:checked`);
if (selected) {
answers[`question_${i}`] = selected.value;
}
}
return answers;
}
function autoSave() {
const statusIndicator = document.getElementById('statusIndicator');
statusIndicator.innerHTML = '<span style="color: #ffc107">⋯</span> 保存中...';
try {
const answers = collectAnswers();
localStorage.setItem('exam_answers', JSON.stringify(answers));
localStorage.setItem('last_save_time', new Date().toISOString());
localStorage.setItem('exam_state', JSON.stringify({
userId: ExamState.userId,
examId: ExamState.examId,
totalQuestions: ExamState.totalQuestions
}));
statusIndicator.innerHTML = '<span style="color: #28a745">✓</span> 已保存 ' + new Date().toLocaleTimeString();
} catch (error) {
statusIndicator.innerHTML = '<span style="color: #dc3545">!</span> 保存失败';
}
}
function restoreAnswers() {
const saved = localStorage.getItem('exam_answers');
if (saved) {
const answers = JSON.parse(saved);
Object.keys(answers).forEach(questionId => {
const input = document.querySelector(`input[name="${questionId}"][value="${answers[questionId]}"]`);
if (input) {
input.checked = true;
updateOptionStyles(input);
}
});
updateProgress();
const lastSaveTime = localStorage.getItem('last_save_time');
if (lastSaveTime) {
showNotification(`已恢复 ${new Date(lastSaveTime).toLocaleString()} 的答题进度`, 'success');
}
}
// 初始化开始时间
window.examStartTime = Date.now();
}
function hasUnsavedChanges() {
const saved = localStorage.getItem('exam_answers');
if (!saved) return false;
const savedAnswers = JSON.parse(saved);
const currentAnswers = collectAnswers();
return JSON.stringify(savedAnswers) !== JSON.stringify(currentAnswers);
}
async function handleExamSubmit() {
if (ExamState.isSubmitting) return;
// 检查是否所有题目都已完成
const answeredCount = Object.keys(collectAnswers()).length;
if (answeredCount < ExamState.totalQuestions) {
const unanswered = ExamState.totalQuestions - answeredCount;
if (!confirm(`您还有${unanswered}道题目未完成,确定要提交吗?`)) {
return;
}
}
// 二次确认
if (!confirm('确定要提交答案吗?提交后将无法修改!')) {
return;
}
ExamState.isSubmitting = true;
const submitBtn = document.getElementById('submitBtn');
const originalText = submitBtn.textContent;
submitBtn.disabled = true;
submitBtn.textContent = '提交中...';
try {
const answers = collectAnswers();
const idempotencyKey = generateIdempotencyKey(ExamState.userId, ExamState.examId);
const result = await submitWithRetry({
userId: ExamState.userId,
examId: ExamState.examId,
answers,
idempotencyKey
});
showNotification('提交成功!', 'success');
// 清除本地存储
localStorage.removeItem('exam_answers');
localStorage.removeItem('last_save_time');
localStorage.removeItem('exam_state');
// 跳转到结果页
setTimeout(() => {
window.location.href = `/result/${result.submissionId}`;
}, 1500);
} catch (error) {
showNotification(`提交失败: ${error.message},答案已保存在本地`, 'error');
// 保存到待处理队列
localStorage.setItem('pending_submission', JSON.stringify({
userId: ExamState.userId,
examId: ExamState.examId,
answers: collectAnswers(),
timestamp: Date.now()
}));
} finally {
ExamState.isSubmitting = false;
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
}
// 工具函数
function generateIdempotencyKey(userId, examId) {
return `exam:${examId}:user:${userId}:${Date.now()}`;
}
async function submitWithRetry(data, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
signal: controller.signal
});
clearTimeout(timeoutId);
if (response.ok) {
return await response.json();
}
if (response.status === 409) {
throw new Error('重复提交,请勿重复操作');
}
const errorData = await response.json();
throw new Error(errorData.error || `服务器错误 (${response.status})`);
} catch (error) {
if (attempt === maxRetries) {
throw error;
}
const delay = Math.pow(2, attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
function showNotification(message, type = 'success') {
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.remove();
}, 5000);
}
// 网络状态检测
class NetworkStatusDetector {
constructor() {
this.isOnline = navigator.onLine;
this.setupEventListeners();
this.showStatus();
}
setupEventListeners() {
window.addEventListener('online', () => {
this.isOnline = true;
this.showStatus();
this.tryPendingSubmission();
});
window.addEventListener('offline', () => {
this.isOnline = false;
this.showStatus();
});
}
showStatus() {
const statusElement = document.getElementById('networkStatus');
if (!statusElement) return;
if (this.isOnline) {
statusElement.innerHTML = '<span style="color: #28a745">●</span> 在线';
statusElement.style.display = 'none';
} else {
statusElement.innerHTML = '<span style="color: #dc3545">●</span> 离线 - 答案保存在本地';
statusElement.style.display = 'block';
}
}
async tryPendingSubmission() {
const pending = localStorage.getItem('pending_submission');
if (pending) {
try {
const data = JSON.parse(pending);
const result = await submitWithRetry(data);
localStorage.removeItem('pending_submission');
showNotification('已自动提交离线期间的答案', 'success');
} catch (error) {
console.error('自动提交失败:', error);
}
}
}
}
</script>
</body>
</html>
6.2 后端完整实现(Node.js + Express + PostgreSQL)
const express = require('express');
const { Pool } = require('pg');
const Redis = require('ioredis');
const cors = require('cors');
const app = express();
app.use(cors());
app.use(express.json());
// 数据库连接
const pool = new Pool({
user: 'postgres',
host: 'localhost',
database: 'examdb',
password: 'your_password',
port: 5432,
});
// Redis连接(用于幂等性和限流)
const redis = new Redis({
host: 'localhost',
port: 6379
});
// 数据库初始化
async function initializeDatabase() {
const client = await pool.connect();
try {
await client.query(`
CREATE TABLE IF NOT EXISTS submissions (
id SERIAL PRIMARY KEY,
user_id VARCHAR(50) NOT NULL,
exam_id VARCHAR(50) NOT NULL,
submitted_at TIMESTAMP NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, exam_id)
);
CREATE TABLE IF NOT EXISTS answers (
id SERIAL PRIMARY KEY,
submission_id INTEGER REFERENCES submissions(id) ON DELETE CASCADE,
question_id VARCHAR(50) NOT NULL,
selected_option VARCHAR(10),
answer_text TEXT,
question_order INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_submissions_user_exam ON submissions(user_id, exam_id);
CREATE INDEX IF NOT EXISTS idx_answers_submission ON answers(submission_id);
`);
console.log('Database initialized successfully');
} catch (error) {
console.error('Database initialization failed:', error);
} finally {
client.release();
}
}
// 中间件:限流
async function rateLimit(req, res, next) {
const key = `rate_limit:${req.body.userId}`;
const limit = 10; // 10次请求
const window = 60; // 60秒
const current = await redis.incr(key);
if (current === 1) {
await redis.expire(key, window);
}
if (current > limit) {
return res.status(429).json({ error: '请求过于频繁,请稍后再试' });
}
next();
}
// 中间件:幂等性检查
async function checkIdempotency(req, res, next) {
const { idempotencyKey } = req.body;
if (!idempotencyKey) {
return res.status(400).json({ error: '缺少幂等键' });
}
const existing = await redis.get(`idempotency:${idempotencyKey}`);
if (existing) {
if (existing === 'completed') {
return res.status(409).json({ error: '重复提交' });
}
// 正在处理中,返回之前的处理结果
return res.status(200).json({ message: '正在处理中', status: 'processing' });
}
// 设置处理中状态
await redis.setex(`idempotency:${idempotencyKey}`, 3600, 'processing');
next();
}
// 提交答案接口
app.post('/api/submit', rateLimit, checkIdempotency, async (req, res) => {
const { userId, examId, answers, idempotencyKey } = req.body;
// 数据验证
if (!userId || !examId || !answers || typeof answers !== 'object') {
return res.status(400).json({ error: '参数错误' });
}
const client = await pool.connect();
try {
// 开始事务
await client.query('BEGIN');
// 检查是否已提交
const checkResult = await client.query(
'SELECT id FROM submissions WHERE user_id = $1 AND exam_id = $2',
[userId, examId]
);
if (checkResult.rows.length > 0) {
throw new Error('该考试已提交,不能重复提交');
}
// 插入提交记录
const submissionResult = await client.query(
`INSERT INTO submissions (user_id, exam_id, submitted_at, status)
VALUES ($1, $2, NOW(), 'submitted')
RETURNING id`,
[userId, examId]
);
const submissionId = submissionResult.rows[0].id;
// 批量插入答案
const answerEntries = Object.entries(answers);
if (answerEntries.length === 0) {
throw new Error('没有答案数据');
}
const answerValues = answerEntries.map(([questionId, selectedOption], index) => [
submissionId,
questionId,
selectedOption,
null, // answer_text
index + 1
]);
const insertQuery = `
INSERT INTO answers (submission_id, question_id, selected_option, answer_text, question_order)
VALUES ${answerValues.map((_, i) =>
`($${i * 5 + 1}, $${i * 5 + 2}, $${i * 5 + 3}, $${i * 5 + 4}, $${i * 5 + 5})`
).join(',')}
`;
const flatValues = answerValues.flat();
await client.query(insertQuery, flatValues);
// 提交事务
await client.query('COMMIT');
// 标记幂等键为已完成
await redis.setex(`idempotency:${idempotencyKey}`, 86400, 'completed');
// 发送WebSocket通知(如果有)
// notifyWebSocket(userId, { type: 'submission_confirmed', submissionId });
res.json({ success: true, submissionId });
} catch (error) {
// 回滚事务
await client.query('ROLLBACK');
// 删除幂等键,允许重试
if (idempotencyKey) {
await redis.del(`idempotency:${idempotencyKey}`);
}
console.error('Submission error:', error);
res.status(500).json({ error: error.message });
} finally {
client.release();
}
});
// 获取考试结果
app.get('/api/result/:submissionId', async (req, res) => {
const { submissionId } = req.params;
try {
const result = await pool.query(`
SELECT s.*,
json_agg(json_build_object(
'question_id', a.question_id,
'selected_option', a.selected_option,
'question_order', a.question_order
) ORDER BY a.question_order) as answers
FROM submissions s
JOIN answers a ON s.id = a.submission_id
WHERE s.id = $1
GROUP BY s.id
`, [submissionId]);
if (result.rows.length === 0) {
return res.status(404).json({ error: '未找到提交记录' });
}
res.json(result.rows[0]);
} catch (error) {
console.error('Error fetching result:', error);
res.status(500).json({ error: '获取结果失败' });
}
});
// 健康检查
app.get('/health', async (req, res) => {
try {
await pool.query('SELECT 1');
res.json({ status: 'ok', database: 'connected' });
} catch (error) {
res.status(500).json({ status: 'error', database: 'disconnected' });
}
});
// 启动服务器
const PORT = process.env.PORT || 3000;
app.listen(PORT, async () => {
await initializeDatabase();
console.log(`Server running on port ${PORT}`);
});
七、测试与监控
7.1 自动化测试
// 使用Jest进行测试
const request = require('supertest');
const app = require('./app');
describe('Exam Submission API', () => {
test('should submit answers successfully', async () => {
const response = await request(app)
.post('/api/submit')
.send({
userId: 'test_user',
examId: 'test_exam',
answers: {
'question_1': 'A',
'question_2': 'B'
},
idempotencyKey: 'test_key_123'
});
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('success', true);
expect(response.body).toHaveProperty('submissionId');
});
test('should reject duplicate submission', async () => {
const response = await request(app)
.post('/api/submit')
.send({
userId: 'test_user',
examId: 'test_exam',
answers: { 'question_1': 'A' },
idempotencyKey: 'test_key_123'
});
expect(response.status).toBe(409);
});
});
7.2 监控与告警
// 监控中间件
function monitorRequests(req, res, next) {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
const log = {
timestamp: new Date().toISOString(),
method: req.method,
path: req.path,
status: res.statusCode,
duration: duration,
userId: req.body.userId,
examId: req.body.examId
};
// 记录到日志文件或发送到监控系统
console.log(JSON.stringify(log));
// 如果响应时间过长,发送告警
if (duration > 5000) {
sendAlert(`慢请求警告: ${req.path} 耗时 ${duration}ms`);
}
});
next();
}
app.use(monitorRequests);
总结
通过以上技术手段的综合应用,我们可以构建一个稳定、可靠、用户体验良好的前台题库提交系统。关键要点包括:
- 前端防误操作:二次确认、按钮禁用、实时保存
- 后端数据保护:事务处理、幂等性、数据备份
- 网络异常处理:重试机制、离线存储、后台同步
- 用户体验优化:实时反馈、进度指示、网络状态提示
- 高并发保障:请求队列、限流、WebSocket
这些措施共同构成了一个完整的解决方案,能够有效避免误操作和数据丢失,确保用户答题体验流畅稳定。在实际部署中,还需要根据具体业务场景进行调整和优化。
