引言:前台题库提交系统的核心挑战

在现代在线教育和考试平台中,前台题库选项提交系统是用户交互的核心环节。这个系统直接关系到用户的学习成果和考试成绩,因此必须确保其稳定性和可靠性。然而,在实际开发中,我们常常面临以下挑战:

  1. 用户误操作:用户可能在答题过程中误触提交按钮,或者在未完成所有题目时意外离开页面。
  2. 数据丢失:网络不稳定、浏览器崩溃或用户误操作都可能导致已填写的答案丢失。
  3. 系统稳定性:高并发情况下,系统需要处理大量提交请求,同时保持响应速度。

本文将详细探讨如何通过技术手段和用户体验设计来避免这些问题,确保用户答题体验流畅稳定。

一、前端防误操作设计

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);

总结

通过以上技术手段的综合应用,我们可以构建一个稳定、可靠、用户体验良好的前台题库提交系统。关键要点包括:

  1. 前端防误操作:二次确认、按钮禁用、实时保存
  2. 后端数据保护:事务处理、幂等性、数据备份
  3. 网络异常处理:重试机制、离线存储、后台同步
  4. 用户体验优化:实时反馈、进度指示、网络状态提示
  5. 高并发保障:请求队列、限流、WebSocket

这些措施共同构成了一个完整的解决方案,能够有效避免误操作和数据丢失,确保用户答题体验流畅稳定。在实际部署中,还需要根据具体业务场景进行调整和优化。