前端页面部分功能完成测试,需继续完善UI布局和功能

This commit is contained in:
2025-12-28 23:33:14 +08:00
parent 42fcb71bae
commit 27fea6f647
12 changed files with 400 additions and 61 deletions

View File

@@ -0,0 +1 @@

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View File

@@ -6,4 +6,5 @@
- 任何新功能、修复或重构都必须:
- 先检查是否符合上述规范
- 如果需求与规范冲突,先提出修改规范的建议
- 不得擅自绕过约束(如直接 res.json 裸返回)
- 不得擅自绕过约束(如直接 res.json 裸返回)
# Always use context7 when I need code generation, setup or configuration steps, or library/API documentation. This means you should automatically use the Context7 MCP tools to resolve library id and get library docs without me having to explicitly ask.

Binary file not shown.

View File

@@ -0,0 +1,15 @@
# Change: update-user-exam-mobile-ux
## Why
用户端考试页目前以桌面布局为主,移动端点击目标、视觉对比度、切换交互与加载反馈不足,影响可用性与无障碍合规。
## What Changes
- 重构用户端考试页为移动优先响应式布局320px-768px
- 统一交互目标最小可点击区域为 48×48px并优化控件间距以降低误触。
- 增加题目左右滑动手势切换与更轻量的切换过渡≤300ms
- 增强加载状态反馈与题目预加载策略以降低首屏与切换等待。
## Impact
- Affected specs: user-exam-portal
- Affected code: src/pages/QuizPage.tsx

View File

@@ -0,0 +1,23 @@
## MODIFIED Requirements
### Requirement: 考试交互与自动保存
系统 MUST 支持题目导航与按题型清晰呈现(单选/多选/判断/文字题);系统 MUST 自动保存答题进度并在页面刷新后可恢复;系统 MUST 在考试过程中隐藏评分信息(包括题目分值与实时得分),仅在交卷后展示评分详情;系统 MUST 在移动端提供易触达的题号导航与题目切换交互。
#### Scenario: 移动端触控与导航可用
- **GIVEN** 用户在 320px-768px 的移动端屏幕进行考试
- **WHEN** 用户进行题目切换、打开题号导航并跳转到指定题目
- **THEN** 系统 MUST 保证主要交互控件的可点击区域不小于 48×48px
- **AND** 系统 MUST 支持左右滑动手势切换上一题/下一题(边界处不切换)
- **AND** 系统 MUST 提供可见的加载与切换反馈,且过渡动画持续时间 MUST 不超过 300ms
## ADDED Requirements
### Requirement: 考试页面可访问性WCAG 2.1 AA
系统 MUST 满足 WCAG 2.1 AA 的基本可读性与可操作性要求:文本与背景对比度满足最低阈值,交互组件具有可见焦点状态,且支持键盘操作完成主要流程。
#### Scenario: 视觉对比度与焦点可见
- **GIVEN** 用户在考试页浏览题干与选项
- **WHEN** 用户使用键盘在控件间切换焦点
- **THEN** 系统 MUST 提供清晰可见的焦点样式
- **AND** 系统 MUST 确保主要文本与背景对比度满足 WCAG 2.1 AA 的最低要求

View File

@@ -0,0 +1,11 @@
## 1. Implementation
- [ ] 调整 QuizPage 页面结构为移动优先布局并保证 48×48 点击目标
- [ ] 增加答题卡/题号导航的单手可操作入口
- [ ] 增加左右滑动切换题目与键盘可达性
- [ ] 优化加载状态与提交/弃考交互反馈
- [ ] 增加题目预加载与切换动画性能优化
## 2. Verification
- [ ] 通过 npm run check 与 npm run build
- [ ] 覆盖移动端交互相关的自动化测试

View File

@@ -10,7 +10,7 @@
"build": "tsc && vite build",
"preview": "vite preview",
"start": "node dist/api/server.js",
"test": "node --import tsx --test test/admin-task-stats.test.ts test/question-text-import.test.ts",
"test": "node --import tsx --test test/admin-task-stats.test.ts test/question-text-import.test.ts test/swipe-detect.test.ts",
"check": "tsc --noEmit"
},
"dependencies": {

View File

@@ -1,9 +1,10 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useMemo, useRef, type TouchEventHandler } from 'react';
import { Card, Button, Radio, Checkbox, Input, message, Progress, Modal } from 'antd';
import { useNavigate, useLocation } from 'react-router-dom';
import { useUser, useQuiz } from '../contexts';
import { quizAPI } from '../services/api';
import { questionTypeMap } from '../utils/validation';
import { detectHorizontalSwipe } from '../utils/swipe';
import { UserLayout } from '../layouts/UserLayout';
const { TextArea } = Input;
@@ -35,11 +36,77 @@ const QuizPage = () => {
const { questions, setQuestions, currentQuestionIndex, setCurrentQuestionIndex, answers, setAnswer, setAnswers, clearQuiz } = useQuiz();
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [answerSheetOpen, setAnswerSheetOpen] = useState(false);
const [timeLeft, setTimeLeft] = useState<number | null>(null);
const [timeLimit, setTimeLimit] = useState<number | null>(null);
const [subjectId, setSubjectId] = useState<string>('');
const [taskId, setTaskId] = useState<string>('');
const lastTickSavedAtMsRef = useRef<number>(0);
const saveDebounceTimerRef = useRef<number | null>(null);
const [navDirection, setNavDirection] = useState<'next' | 'prev'>('next');
const [enterAnimationOn, setEnterAnimationOn] = useState(false);
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
const questionHeadingRef = useRef<HTMLHeadingElement | null>(null);
useEffect(() => {
const el = questionHeadingRef.current;
if (!el) return;
el.focus();
}, [currentQuestionIndex]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) return;
const active = document.activeElement as HTMLElement | null;
const activeTag = (active?.tagName || '').toLowerCase();
const inputType =
activeTag === 'input' ? ((active as HTMLInputElement | null)?.type || '').toLowerCase() : '';
const isTextualInputType =
activeTag === 'input'
? inputType === '' ||
inputType === 'text' ||
inputType === 'search' ||
inputType === 'tel' ||
inputType === 'url' ||
inputType === 'email' ||
inputType === 'password' ||
inputType === 'number'
: false;
const inTextInput =
activeTag === 'textarea' ||
active?.getAttribute('role') === 'textbox' ||
active?.classList.contains('ant-input') ||
active?.closest?.('.ant-input') ||
isTextualInputType ||
active?.isContentEditable;
if (inTextInput) return;
if (event.key === 'ArrowLeft') {
event.preventDefault();
handlePrevious();
return;
}
if (event.key === 'ArrowRight') {
event.preventDefault();
handleNext();
return;
}
if (event.key === 'Escape') {
if (answerSheetOpen) {
event.preventDefault();
setAnswerSheetOpen(false);
}
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [answerSheetOpen, currentQuestionIndex, questions.length]);
useEffect(() => {
if (!user) {
@@ -179,19 +246,32 @@ const QuizPage = () => {
if (!questions.length) return;
if (timeLeft === null) return;
const progressKey = getActiveProgressKey(user.id);
if (!progressKey) return;
if (saveDebounceTimerRef.current !== null) {
window.clearTimeout(saveDebounceTimerRef.current);
}
saveProgress(progressKey, {
questions,
answers,
currentQuestionIndex,
timeLeftSeconds: timeLeft,
timeLimitMinutes: timeLimit || 60,
subjectId,
taskId,
});
}, [user, questions, answers, currentQuestionIndex, subjectId, taskId]);
saveDebounceTimerRef.current = window.setTimeout(() => {
const progressKey = getActiveProgressKey(user.id);
if (!progressKey) return;
saveProgress(progressKey, {
questions,
answers,
currentQuestionIndex,
timeLeftSeconds: timeLeft,
timeLimitMinutes: timeLimit || 60,
subjectId,
taskId,
});
saveDebounceTimerRef.current = null;
}, 300);
return () => {
if (saveDebounceTimerRef.current !== null) {
window.clearTimeout(saveDebounceTimerRef.current);
saveDebounceTimerRef.current = null;
}
};
}, [user, questions, answers, currentQuestionIndex, subjectId, taskId, timeLeft, timeLimit]);
useEffect(() => {
if (!user) return;
@@ -218,16 +298,25 @@ const QuizPage = () => {
const handleNext = () => {
if (currentQuestionIndex < questions.length - 1) {
setNavDirection('next');
setCurrentQuestionIndex(currentQuestionIndex + 1);
}
};
const handlePrevious = () => {
if (currentQuestionIndex > 0) {
setNavDirection('prev');
setCurrentQuestionIndex(currentQuestionIndex - 1);
}
};
const handleJumpTo = (index: number) => {
if (index < 0 || index >= questions.length) return;
if (index === currentQuestionIndex) return;
setNavDirection(index > currentQuestionIndex ? 'next' : 'prev');
setCurrentQuestionIndex(index);
};
const handleSubmit = async (forceSubmit = false) => {
try {
setSubmitting(true);
@@ -310,7 +399,7 @@ const QuizPage = () => {
className="w-full"
>
{question.options?.map((option, index) => (
<Radio key={index} value={option} className="block mb-3 p-4 rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-mars-200 transition-colors">
<Radio key={index} value={option} className="block mb-3 p-4 min-h-12 rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-mars-200 transition-colors">
{String.fromCharCode(65 + index)}. {option}
</Radio>
))}
@@ -325,7 +414,7 @@ const QuizPage = () => {
className="w-full"
>
{question.options?.map((option, index) => (
<Checkbox key={index} value={option} className="block mb-3 p-4 rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-mars-200 transition-colors w-full">
<Checkbox key={index} value={option} className="block mb-3 p-4 min-h-12 rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-mars-200 transition-colors w-full">
{String.fromCharCode(65 + index)}. {option}
</Checkbox>
))}
@@ -339,10 +428,10 @@ const QuizPage = () => {
onChange={(e) => handleAnswerChange(question.id, e.target.value)}
className="w-full"
>
<Radio value="正确" className="block mb-3 p-4 rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-mars-200 transition-colors">
<Radio value="正确" className="block mb-3 p-4 min-h-12 rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-mars-200 transition-colors">
</Radio>
<Radio value="错误" className="block mb-3 p-4 rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-mars-200 transition-colors">
<Radio value="错误" className="block mb-3 p-4 min-h-12 rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-mars-200 transition-colors">
</Radio>
</Radio.Group>
@@ -364,13 +453,24 @@ const QuizPage = () => {
}
};
const answeredCount = useMemo(() => {
return questions.reduce((count, q) => (answers[q.id] ? count + 1 : count), 0);
}, [questions, answers]);
useEffect(() => {
if (!questions.length) return;
setEnterAnimationOn(false);
const rafId = requestAnimationFrame(() => setEnterAnimationOn(true));
return () => cancelAnimationFrame(rafId);
}, [currentQuestionIndex, questions.length]);
if (loading || !questions.length) {
return (
<UserLayout>
<div className="flex items-center justify-center h-full min-h-[500px]">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-mars-600 mx-auto mb-4"></div>
<p className="text-gray-600">...</p>
<p className="text-gray-700">...</p>
</div>
</div>
</UserLayout>
@@ -380,25 +480,58 @@ const QuizPage = () => {
const currentQuestion = questions[currentQuestionIndex];
const progress = ((currentQuestionIndex + 1) / questions.length) * 100;
const handleTouchStart: TouchEventHandler = (e) => {
if (e.touches.length !== 1) return;
const target = e.target as HTMLElement | null;
if (target?.closest('textarea,input,[role="textbox"],.ant-input,.ant-checkbox-wrapper,.ant-radio-wrapper')) return;
const touch = e.touches[0];
touchStartRef.current = { x: touch.clientX, y: touch.clientY, time: Date.now() };
};
const handleTouchEnd: TouchEventHandler = (e) => {
const start = touchStartRef.current;
touchStartRef.current = null;
if (!start) return;
const touch = e.changedTouches[0];
if (!touch) return;
const direction = detectHorizontalSwipe({
startX: start.x,
startY: start.y,
endX: touch.clientX,
endY: touch.clientY,
elapsedMs: Date.now() - start.time,
});
if (direction === 'left') {
handleNext();
return;
}
if (direction === 'right') {
handlePrevious();
}
};
return (
<UserLayout>
<div className="max-w-4xl mx-auto">
<div className="max-w-4xl mx-auto pb-24">
{/* 头部信息 */}
<div className="bg-white rounded-xl shadow-sm p-6 mb-6 border border-gray-100">
<div className="flex justify-between items-center mb-4">
<div className="bg-white rounded-xl shadow-sm p-4 md:p-6 mb-4 md:mb-6 border border-gray-100">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-3 mb-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">线</h1>
<p className="text-gray-500 mt-1">
<h1 className="text-xl md:text-2xl font-bold text-gray-900">线</h1>
<p className="text-gray-700 mt-1">
{currentQuestionIndex + 1} / {questions.length}
</p>
</div>
<div className="flex items-center space-x-3">
<Button danger onClick={handleGiveUp}>
<div className="flex items-center justify-between md:justify-end gap-3">
<Button danger onClick={handleGiveUp} className="h-12 px-4 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-red-500">
</Button>
{timeLeft !== null && (
<div className="text-right">
<div className="text-sm text-gray-500"></div>
<div className="text-sm text-gray-700"></div>
<div className={`text-2xl font-bold tabular-nums ${
timeLeft < 300 ? 'text-red-600' : 'text-mars-600'
}`}>
@@ -420,7 +553,11 @@ const QuizPage = () => {
{/* 题目卡片 */}
<Card className="shadow-sm border border-gray-100 rounded-xl">
<div className="mb-8">
<div
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
className="mb-6 md:mb-8"
>
<div className="flex items-center justify-between mb-4">
<span className={`inline-block px-3 py-1 rounded-full text-sm font-medium ${getTagColor(currentQuestion.type)}`}>
{questionTypeMap[currentQuestion.type]}
@@ -428,55 +565,134 @@ const QuizPage = () => {
</div>
{currentQuestion.category && (
<div className="mb-3">
<span className="inline-block px-2 py-1 bg-gray-50 text-gray-500 text-xs rounded border border-gray-100">
<span className="inline-block px-2 py-1 bg-gray-50 text-gray-700 text-xs rounded border border-gray-100">
{currentQuestion.category}
</span>
</div>
)}
<h2 className="text-xl font-medium text-gray-800 leading-relaxed">
<h2
ref={questionHeadingRef}
tabIndex={-1}
className={`text-lg md:text-xl font-medium text-gray-900 leading-relaxed transition-all duration-200 ease-out ${
enterAnimationOn
? 'opacity-100 translate-x-0'
: navDirection === 'next'
? 'opacity-0 translate-x-2'
: 'opacity-0 -translate-x-2'
}`}
>
{currentQuestion.content}
</h2>
</div>
<div className="mb-8">
<div
className={`mb-6 md:mb-8 transition-all duration-200 ease-out ${
enterAnimationOn
? 'opacity-100 translate-x-0'
: navDirection === 'next'
? 'opacity-0 translate-x-2'
: 'opacity-0 -translate-x-2'
}`}
>
{renderQuestion(currentQuestion)}
</div>
</Card>
{/* 操作按钮 */}
<div className="flex justify-between items-center pt-6 border-t border-gray-100">
<Button
onClick={handlePrevious}
disabled={currentQuestionIndex === 0}
size="large"
className="px-6 h-10 hover:border-mars-500 hover:text-mars-500"
>
</Button>
<div className="fixed left-0 right-0 bottom-0 z-20">
<div className="bg-white/95 backdrop-blur border-t border-gray-200">
<div className="max-w-4xl mx-auto px-4 py-3">
<div className="flex items-center gap-3">
<Button
onClick={handlePrevious}
disabled={currentQuestionIndex === 0}
className="h-12 min-w-12 px-4 rounded-lg hover:border-mars-500 hover:text-mars-600 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-mars-500"
>
</Button>
<div className="flex space-x-3">
{currentQuestionIndex === questions.length - 1 ? (
<Button
type="primary"
onClick={() => handleSubmit()}
loading={submitting}
size="large"
className="px-8 h-10 bg-mars-600 hover:bg-mars-700 border-none shadow-md"
onClick={() => setAnswerSheetOpen(true)}
className="h-12 flex-1 rounded-lg border-gray-200 text-gray-900 hover:border-mars-400 hover:text-mars-600 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-mars-500"
>
{answeredCount}/{questions.length}
</Button>
) : (
<Button
type="primary"
onClick={handleNext}
size="large"
className="px-8 h-10 bg-mars-500 hover:bg-mars-600 border-none shadow-md"
>
</Button>
)}
{currentQuestionIndex === questions.length - 1 ? (
<Button
type="primary"
onClick={() => handleSubmit()}
loading={submitting}
className="h-12 min-w-12 px-4 rounded-lg bg-mars-600 hover:bg-mars-700 border-none shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-mars-500"
>
</Button>
) : (
<Button
type="primary"
onClick={handleNext}
className="h-12 min-w-12 px-4 rounded-lg bg-mars-500 hover:bg-mars-600 border-none shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-mars-500"
>
</Button>
)}
</div>
</div>
</div>
</Card>
</div>
{answerSheetOpen && (
<Modal
title="答题卡"
open={answerSheetOpen}
onCancel={() => setAnswerSheetOpen(false)}
footer={null}
centered
width={560}
destroyOnClose
>
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-gray-700">
{answeredCount} / {questions.length}
</div>
<Button
type="primary"
onClick={() => setAnswerSheetOpen(false)}
className="h-10 px-4 bg-mars-600 hover:bg-mars-700 border-none"
>
</Button>
</div>
<div className="grid grid-cols-5 sm:grid-cols-6 gap-2">
{questions.map((q, idx) => {
const isCurrent = idx === currentQuestionIndex;
const isAnswered = !!answers[q.id];
const baseClassName = 'h-12 w-12 rounded-lg border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-mars-500';
const className = isCurrent
? `${baseClassName} border-mars-500 text-mars-700 bg-mars-50`
: isAnswered
? `${baseClassName} border-green-200 text-green-700 bg-green-50 hover:border-green-300`
: `${baseClassName} border-gray-200 text-gray-800 bg-white hover:border-mars-200`;
return (
<button
key={q.id}
type="button"
className={className}
onClick={() => {
setAnswerSheetOpen(false);
handleJumpTo(idx);
}}
aria-label={`跳转到第 ${idx + 1}`}
>
{idx + 1}
</button>
);
})}
</div>
</Modal>
)}
</div>
</UserLayout>
);

35
src/utils/swipe.ts Normal file
View File

@@ -0,0 +1,35 @@
export type SwipeDirection = 'left' | 'right';
export type DetectHorizontalSwipeInput = {
startX: number;
startY: number;
endX: number;
endY: number;
elapsedMs: number;
minDistancePx?: number;
maxDurationMs?: number;
minHorizontalDominanceRatio?: number;
};
export const detectHorizontalSwipe = (input: DetectHorizontalSwipeInput): SwipeDirection | null => {
const minDistancePx = input.minDistancePx ?? 60;
const maxDurationMs = input.maxDurationMs ?? 600;
const minHorizontalDominanceRatio = input.minHorizontalDominanceRatio ?? 1.2;
if (!Number.isFinite(input.startX) || !Number.isFinite(input.startY)) return null;
if (!Number.isFinite(input.endX) || !Number.isFinite(input.endY)) return null;
if (!Number.isFinite(input.elapsedMs)) return null;
if (input.elapsedMs < 0) return null;
if (input.elapsedMs > maxDurationMs) return null;
const dx = input.endX - input.startX;
const dy = input.endY - input.startY;
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
if (absDx < minDistancePx) return null;
if (absDx < absDy * minHorizontalDominanceRatio) return null;
return dx < 0 ? 'left' : 'right';
};

37
test/swipe-detect.test.ts Normal file
View File

@@ -0,0 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { detectHorizontalSwipe } from '../src/utils/swipe';
test('detectHorizontalSwipe: 左滑/右滑识别', () => {
assert.equal(
detectHorizontalSwipe({ startX: 200, startY: 100, endX: 50, endY: 110, elapsedMs: 200 }),
'left',
);
assert.equal(
detectHorizontalSwipe({ startX: 50, startY: 100, endX: 200, endY: 90, elapsedMs: 200 }),
'right',
);
});
test('detectHorizontalSwipe: 垂直滚动不触发', () => {
assert.equal(
detectHorizontalSwipe({ startX: 100, startY: 100, endX: 150, endY: 260, elapsedMs: 200 }),
null,
);
});
test('detectHorizontalSwipe: 距离不足不触发', () => {
assert.equal(
detectHorizontalSwipe({ startX: 100, startY: 100, endX: 140, endY: 105, elapsedMs: 200 }),
null,
);
});
test('detectHorizontalSwipe: 时间过长不触发', () => {
assert.equal(
detectHorizontalSwipe({ startX: 200, startY: 100, endX: 50, endY: 110, elapsedMs: 900 }),
null,
);
});