diff --git a/.artifacts/playwright/.gitkeep b/.artifacts/playwright/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/.artifacts/playwright/.gitkeep @@ -0,0 +1 @@ + diff --git a/.artifacts/playwright/workspace-admin-login-2025-12-28T15-24-54-026Z.png b/.artifacts/playwright/workspace-admin-login-2025-12-28T15-24-54-026Z.png new file mode 100644 index 0000000..fd67665 Binary files /dev/null and b/.artifacts/playwright/workspace-admin-login-2025-12-28T15-24-54-026Z.png differ diff --git a/.artifacts/playwright/workspace-home-2025-12-28T15-24-41-001Z.png b/.artifacts/playwright/workspace-home-2025-12-28T15-24-41-001Z.png new file mode 100644 index 0000000..cc18d68 Binary files /dev/null and b/.artifacts/playwright/workspace-home-2025-12-28T15-24-41-001Z.png differ diff --git a/.trae/rules/openspec-file-spec.md b/.trae/rules/openspec-file-spec.md index 08bcfdb..26f9a1d 100644 --- a/.trae/rules/openspec-file-spec.md +++ b/.trae/rules/openspec-file-spec.md @@ -6,4 +6,5 @@ - 任何新功能、修复或重构都必须: - 先检查是否符合上述规范 - 如果需求与规范冲突,先提出修改规范的建议 -- 不得擅自绕过约束(如直接 res.json 裸返回) \ No newline at end of file +- 不得擅自绕过约束(如直接 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. \ No newline at end of file diff --git a/data/survey.db b/data/survey.db index d93c6d1..862b58d 100644 Binary files a/data/survey.db and b/data/survey.db differ diff --git a/openspec/changes/update-user-exam-mobile-ux/proposal.md b/openspec/changes/update-user-exam-mobile-ux/proposal.md new file mode 100644 index 0000000..ae4f6e7 --- /dev/null +++ b/openspec/changes/update-user-exam-mobile-ux/proposal.md @@ -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 + diff --git a/openspec/changes/update-user-exam-mobile-ux/specs/user-exam-portal/spec.md b/openspec/changes/update-user-exam-mobile-ux/specs/user-exam-portal/spec.md new file mode 100644 index 0000000..bc852aa --- /dev/null +++ b/openspec/changes/update-user-exam-mobile-ux/specs/user-exam-portal/spec.md @@ -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 的最低要求 + diff --git a/openspec/changes/update-user-exam-mobile-ux/tasks.md b/openspec/changes/update-user-exam-mobile-ux/tasks.md new file mode 100644 index 0000000..9ad2dc8 --- /dev/null +++ b/openspec/changes/update-user-exam-mobile-ux/tasks.md @@ -0,0 +1,11 @@ +## 1. Implementation +- [ ] 调整 QuizPage 页面结构为移动优先布局并保证 48×48 点击目标 +- [ ] 增加答题卡/题号导航的单手可操作入口 +- [ ] 增加左右滑动切换题目与键盘可达性 +- [ ] 优化加载状态与提交/弃考交互反馈 +- [ ] 增加题目预加载与切换动画性能优化 + +## 2. Verification +- [ ] 通过 npm run check 与 npm run build +- [ ] 覆盖移动端交互相关的自动化测试 + diff --git a/package.json b/package.json index a85ddda..a3e3854 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/pages/QuizPage.tsx b/src/pages/QuizPage.tsx index a4487fe..ea58535 100644 --- a/src/pages/QuizPage.tsx +++ b/src/pages/QuizPage.tsx @@ -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(null); const [timeLimit, setTimeLimit] = useState(null); const [subjectId, setSubjectId] = useState(''); const [taskId, setTaskId] = useState(''); const lastTickSavedAtMsRef = useRef(0); + const saveDebounceTimerRef = useRef(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(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) => ( - + {String.fromCharCode(65 + index)}. {option} ))} @@ -325,7 +414,7 @@ const QuizPage = () => { className="w-full" > {question.options?.map((option, index) => ( - + {String.fromCharCode(65 + index)}. {option} ))} @@ -339,10 +428,10 @@ const QuizPage = () => { onChange={(e) => handleAnswerChange(question.id, e.target.value)} className="w-full" > - + 正确 - + 错误 @@ -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 (
-

正在生成试卷...

+

正在生成试卷...

@@ -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 ( -
+
{/* 头部信息 */} -
-
+
+
-

在线答题

-

+

在线答题

+

第 {currentQuestionIndex + 1} 题 / 共 {questions.length} 题

-
- {timeLeft !== null && (
-
剩余时间
+
剩余时间
@@ -420,7 +553,11 @@ const QuizPage = () => { {/* 题目卡片 */} -
+
{questionTypeMap[currentQuestion.type]} @@ -428,55 +565,134 @@ const QuizPage = () => {
{currentQuestion.category && (
- + {currentQuestion.category}
)} -

+

{currentQuestion.content}

-
+
{renderQuestion(currentQuestion)}
+ - {/* 操作按钮 */} -
- +
+
+
+
+ -
- {currentQuestionIndex === questions.length - 1 ? ( - ) : ( - - )} + + {currentQuestionIndex === questions.length - 1 ? ( + + ) : ( + + )} +
- +
+ + {answerSheetOpen && ( + setAnswerSheetOpen(false)} + footer={null} + centered + width={560} + destroyOnClose + > +
+
+ 已答 {answeredCount} / 共 {questions.length} +
+ +
+ +
+ {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 ( + + ); + })} +
+
+ )}
); diff --git a/src/utils/swipe.ts b/src/utils/swipe.ts new file mode 100644 index 0000000..ded7635 --- /dev/null +++ b/src/utils/swipe.ts @@ -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'; +}; + diff --git a/test/swipe-detect.test.ts b/test/swipe-detect.test.ts new file mode 100644 index 0000000..bf41e70 --- /dev/null +++ b/test/swipe-detect.test.ts @@ -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, + ); +}); +