feat: 添加做题的保存功能

在答题界面(answer.html)添加本地保存功能:
- 使用 localStorage 保存答题状态(选择题、简答题、画图题)
- 保存学生姓名和画图笔画历史
- 有效期 24 小时,过期自动清除
- 页面加载时自动恢复进度
- 页面关闭/刷新前自动保存
- 提交成功后自动清除本地进度
- 添加保存进度按钮和状态提示

Closes #1
This commit is contained in:
2026-06-03 14:51:42 +08:00
parent e36c84b195
commit 8cd0130691

View File

@@ -166,6 +166,76 @@
outline: none; outline: none;
border-color: #f59e0b; border-color: #f59e0b;
} }
/* 画板样式 */
.drawing-board {
margin-left: 44px;
border: 2px solid #e0e0e0;
border-radius: 8px;
padding: 12px;
background: #f8f9fa;
}
.drawing-canvas {
width: 100%;
height: 300px;
border: 2px solid #ddd;
border-radius: 8px;
background: white;
cursor: crosshair;
touch-action: none;
}
.drawing-tools {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
align-items: center;
}
.drawing-tools button {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.btn-clear {
background: #ef4444;
color: white;
}
.btn-clear:hover {
background: #dc2626;
}
.btn-undo {
background: #9ca3af;
color: white;
}
.btn-undo:hover {
background: #6b7280;
}
.color-picker {
display: flex;
gap: 6px;
align-items: center;
}
.color-option {
width: 24px;
height: 24px;
border-radius: 50%;
cursor: pointer;
border: 3px solid transparent;
transition: border-color 0.2s;
}
.color-option.active {
border-color: #f59e0b;
}
.brush-size {
display: flex;
align-items: center;
gap: 8px;
}
.brush-size input[type="range"] {
width: 100px;
}
.submit-section { .submit-section {
text-align: center; text-align: center;
padding: 30px; padding: 30px;
@@ -186,6 +256,22 @@
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(245, 158, 11, 0.3); box-shadow: 0 5px 20px rgba(245, 158, 11, 0.3);
} }
.btn-save {
background: #10b981;
color: white;
border: none;
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-save:hover {
background: #059669;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
.btn-submit:disabled { .btn-submit:disabled {
background: #ccc; background: #ccc;
cursor: not-allowed; cursor: not-allowed;
@@ -261,6 +347,10 @@
<div class="stat-label">简答题</div> <div class="stat-label">简答题</div>
<div class="stat-value" th:text="${essayCount} + '道'">0道</div> <div class="stat-value" th:text="${essayCount} + '道'">0道</div>
</div> </div>
<div class="stat-item" th:if="${drawingCount != null && drawingCount > 0}">
<div class="stat-label">画图题</div>
<div class="stat-value" th:text="${drawingCount} + '道'">0道</div>
</div>
<div class="stat-item"> <div class="stat-item">
<div class="stat-label">总分</div> <div class="stat-label">总分</div>
<div class="stat-value" th:text="${totalScore} + '分'">100分</div> <div class="stat-value" th:text="${totalScore} + '分'">100分</div>
@@ -270,6 +360,10 @@
<label for="studentName">做题人姓名 *</label> <label for="studentName">做题人姓名 *</label>
<input type="text" id="studentName" placeholder="请输入您的姓名" required> <input type="text" id="studentName" placeholder="请输入您的姓名" required>
</div> </div>
<div style="margin-top: 16px; display: flex; justify-content: center; gap: 12px; align-items: center;">
<button type="button" class="btn-save" onclick="saveProgress()" id="saveBtn">保存进度</button>
<span id="saveStatus" style="color: #999; font-size: 13px;"></span>
</div>
</div> </div>
<!-- 答题表单 --> <!-- 答题表单 -->
@@ -317,6 +411,36 @@
</div> </div>
</div> </div>
<!-- 画图题 -->
<div th:if="${drawingQuestions != null}" th:each="q, iterStat : ${drawingQuestions}" class="question-card">
<div class="question-header">
<span class="question-number" th:text="${choiceCount + essayCount + iterStat.index + 1}">1</span>
<span class="question-title" th:text="${q.title}">题目内容</span>
<span class="question-score" th:text="${q.score} + '分'">10分</span>
<span class="question-type">画图题</span>
</div>
<div class="drawing-board">
<canvas class="drawing-canvas" th:id="'canvas_' + ${q.id}" th:data-qid="${q.id}"></canvas>
<div class="drawing-tools">
<div class="color-picker">
<span style="color: #666; font-size: 12px;">颜色:</span>
<div class="color-option active" style="background: #000000;" data-color="#000000"></div>
<div class="color-option" style="background: #ef4444;" data-color="#ef4444"></div>
<div class="color-option" style="background: #22c55e;" data-color="#22c55e"></div>
<div class="color-option" style="background: #3b82f6;" data-color="#3b82f6"></div>
</div>
<div class="brush-size">
<span style="color: #666; font-size: 12px;">粗细:</span>
<input type="range" th:id="'brushSize_' + ${q.id}" min="1" max="20" value="3">
<span th:id="'brushSizeValue_' + ${q.id}" style="color: #666; font-size: 12px;">3</span>
</div>
<button type="button" class="btn-undo" th:onclick="'undoStroke(' + ${q.id} + ')'">撤销</button>
<button type="button" class="btn-clear" th:onclick="'clearCanvas(' + ${q.id} + ')'">清空</button>
</div>
<input type="hidden" th:name="'q_' + ${q.id}" th:id="'input_' + ${q.id}" value="">
</div>
</div>
<!-- 提交按钮 --> <!-- 提交按钮 -->
<div class="submit-section"> <div class="submit-section">
<button type="submit" class="btn-submit">提交答案</button> <button type="submit" class="btn-submit">提交答案</button>
@@ -336,6 +460,334 @@
</div> </div>
<script th:inline="javascript"> <script th:inline="javascript">
// 画板相关变量
const canvases = {};
const contexts = {};
const strokes = {}; // 存储每个画板的笔画历史
const currentStrokes = {}; // 当前正在画的笔画
// 保存和恢复答题状态
const EXPIRE_TIME = 24 * 60 * 60 * 1000; // 24小时
function getStorageKey() {
const examUid = document.getElementById('examUid').value;
return 'exam_progress_' + examUid;
}
function saveProgress() {
const examUid = document.getElementById('examUid').value;
const studentName = document.getElementById('studentName').value;
const answers = {};
const canvasData = {};
// 保存选择题答案
document.querySelectorAll('input[type="radio"]:checked').forEach(radio => {
answers[radio.name] = radio.value;
});
// 保存简答题答案
document.querySelectorAll('.essay-input textarea').forEach(textarea => {
if (textarea.value.trim()) {
answers[textarea.name] = textarea.value;
}
});
// 保存画图题
Object.keys(strokes).forEach(qid => {
if (strokes[qid] && strokes[qid].length > 0) {
canvasData[qid] = strokes[qid];
}
});
const data = {
examUid: examUid,
studentName: studentName,
answers: answers,
canvasData: canvasData,
savedAt: Date.now()
};
try {
localStorage.setItem(getStorageKey(), JSON.stringify(data));
updateSaveStatus('保存成功', 'success');
} catch (e) {
console.error('保存失败:', e);
updateSaveStatus('保存失败,存储空间不足', 'error');
}
}
function restoreProgress() {
const key = getStorageKey();
const saved = localStorage.getItem(key);
if (!saved) return;
try {
const data = JSON.parse(saved);
const now = Date.now();
// 检查是否过期
if (now - data.savedAt > EXPIRE_TIME) {
localStorage.removeItem(key);
updateSaveStatus('之前的进度已过期超过24小时', 'warning');
return;
}
// 恢复姓名
if (data.studentName) {
document.getElementById('studentName').value = data.studentName;
}
// 恢复选择题
if (data.answers) {
Object.keys(data.answers).forEach(name => {
const radio = document.querySelector('input[type="radio"][name="' + name + '"][value="' + data.answers[name] + '"]');
if (radio) radio.checked = true;
const textarea = document.querySelector('textarea[name="' + name + '"]');
if (textarea) textarea.value = data.answers[name];
});
}
// 恢复画图题需要等canvas初始化完成后
if (data.canvasData) {
setTimeout(() => {
Object.keys(data.canvasData).forEach(qid => {
if (canvases[qid] && contexts[qid]) {
strokes[qid] = data.canvasData[qid];
redrawCanvas(qid);
updateCanvasInput(qid);
}
});
}, 100);
}
const saveTime = new Date(data.savedAt).toLocaleString('zh-CN');
updateSaveStatus('已恢复 ' + saveTime + ' 的进度', 'success');
} catch (e) {
console.error('恢复进度失败:', e);
}
}
function clearSavedProgress() {
localStorage.removeItem(getStorageKey());
}
function updateSaveStatus(message, type) {
const statusEl = document.getElementById('saveStatus');
if (!statusEl) return;
statusEl.textContent = message;
if (type === 'success') {
statusEl.style.color = '#10b981';
} else if (type === 'error') {
statusEl.style.color = '#ef4444';
} else if (type === 'warning') {
statusEl.style.color = '#f59e0b';
} else {
statusEl.style.color = '#999';
}
// 3秒后清除状态
setTimeout(() => {
statusEl.textContent = '';
}, 3000);
}
// 页面关闭前自动保存
window.addEventListener('beforeunload', function() {
saveProgress();
});
// 初始化所有画板
document.addEventListener('DOMContentLoaded', function() {
const canvasElements = document.querySelectorAll('.drawing-canvas');
canvasElements.forEach(canvas => {
const qid = canvas.dataset.qid;
initCanvas(canvas, qid);
});
// 恢复之前保存的进度
restoreProgress();
});
function initCanvas(canvas, qid) {
const ctx = canvas.getContext('2d');
canvases[qid] = canvas;
contexts[qid] = ctx;
strokes[qid] = [];
currentStrokes[qid] = [];
// 设置画布大小
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
// 设置画笔样式
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = '#000000';
ctx.lineWidth = 3;
let isDrawing = false;
let lastX = 0;
let lastY = 0;
// 鼠标/触摸事件
function getPos(e) {
const rect = canvas.getBoundingClientRect();
if (e.touches) {
return {
x: e.touches[0].clientX - rect.left,
y: e.touches[0].clientY - rect.top
};
}
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
}
function startDrawing(e) {
isDrawing = true;
const pos = getPos(e);
lastX = pos.x;
lastY = pos.y;
currentStrokes[qid] = [{
x: pos.x,
y: pos.y,
color: ctx.strokeStyle,
width: ctx.lineWidth
}];
e.preventDefault();
}
function draw(e) {
if (!isDrawing) return;
const pos = getPos(e);
ctx.beginPath();
ctx.moveTo(lastX, lastY);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
currentStrokes[qid].push({
x: pos.x,
y: pos.y,
color: ctx.strokeStyle,
width: ctx.lineWidth
});
lastX = pos.x;
lastY = pos.y;
e.preventDefault();
}
function stopDrawing() {
if (isDrawing && currentStrokes[qid].length > 1) {
strokes[qid].push([...currentStrokes[qid]]);
updateCanvasInput(qid);
}
isDrawing = false;
currentStrokes[qid] = [];
}
// 鼠标事件
canvas.addEventListener('mousedown', startDrawing);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', stopDrawing);
canvas.addEventListener('mouseout', stopDrawing);
// 触摸事件
canvas.addEventListener('touchstart', startDrawing, { passive: false });
canvas.addEventListener('touchmove', draw, { passive: false });
canvas.addEventListener('touchend', stopDrawing);
// 颜色选择
const colorOptions = canvas.parentElement.querySelectorAll('.color-option');
colorOptions.forEach(option => {
option.addEventListener('click', function() {
colorOptions.forEach(o => o.classList.remove('active'));
this.classList.add('active');
ctx.strokeStyle = this.dataset.color;
});
});
// 画笔大小
const brushSizeInput = document.getElementById('brushSize_' + qid);
const brushSizeValue = document.getElementById('brushSizeValue_' + qid);
if (brushSizeInput) {
brushSizeInput.addEventListener('input', function() {
ctx.lineWidth = this.value;
if (brushSizeValue) {
brushSizeValue.textContent = this.value;
}
});
}
// 窗口大小改变时重新设置画布大小
window.addEventListener('resize', function() {
const rect = canvas.getBoundingClientRect();
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
canvas.width = rect.width;
canvas.height = rect.height;
ctx.putImageData(imageData, 0, 0);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
});
}
function clearCanvas(qid) {
const canvas = canvases[qid];
const ctx = contexts[qid];
if (canvas && ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
strokes[qid] = [];
updateCanvasInput(qid);
}
}
function undoStroke(qid) {
const canvas = canvases[qid];
const ctx = contexts[qid];
if (canvas && ctx && strokes[qid] && strokes[qid].length > 0) {
strokes[qid].pop();
redrawCanvas(qid);
updateCanvasInput(qid);
}
}
function redrawCanvas(qid) {
const canvas = canvases[qid];
const ctx = contexts[qid];
if (!canvas || !ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
strokes[qid].forEach(stroke => {
if (stroke.length < 2) return;
ctx.beginPath();
ctx.moveTo(stroke[0].x, stroke[0].y);
ctx.strokeStyle = stroke[0].color;
ctx.lineWidth = stroke[0].width;
for (let i = 1; i < stroke.length; i++) {
ctx.lineTo(stroke[i].x, stroke[i].y);
}
ctx.stroke();
});
}
function updateCanvasInput(qid) {
const canvas = canvases[qid];
const input = document.getElementById('input_' + qid);
if (canvas && input) {
// 将画布内容转为base64
const base64 = canvas.toDataURL('image/png');
input.value = base64;
}
}
// 表单提交
document.getElementById('answerForm').addEventListener('submit', function(e) { document.getElementById('answerForm').addEventListener('submit', function(e) {
e.preventDefault(); e.preventDefault();
@@ -346,6 +798,11 @@
return; return;
} }
// 更新所有画板的输入值
Object.keys(canvases).forEach(qid => {
updateCanvasInput(qid);
});
const formData = new FormData(this); const formData = new FormData(this);
const answers = {}; const answers = {};
@@ -381,6 +838,9 @@
}) })
.then(data => { .then(data => {
if (data.success) { if (data.success) {
// 提交成功后清除本地保存的进度
clearSavedProgress();
// 填入返回的答案 UID 并显示弹窗 // 填入返回的答案 UID 并显示弹窗
const answerUidElement = document.getElementById('answerUid'); const answerUidElement = document.getElementById('answerUid');
if (answerUidElement) { if (answerUidElement) {
@@ -409,6 +869,12 @@
// 重置表单 // 重置表单
document.getElementById('answerForm').reset(); document.getElementById('answerForm').reset();
document.getElementById('studentName').value = ''; document.getElementById('studentName').value = '';
// 清空所有画板
Object.keys(canvases).forEach(qid => {
clearCanvas(qid);
});
// 清除本地保存的进度
clearSavedProgress();
document.querySelector('.btn-submit').disabled = false; document.querySelector('.btn-submit').disabled = false;
document.querySelector('.btn-submit').textContent = '提交答案'; document.querySelector('.btn-submit').textContent = '提交答案';
} }