前端页面部分功能完成测试,需继续完善UI布局和功能
This commit is contained in:
1
.artifacts/playwright/.gitkeep
Normal file
1
.artifacts/playwright/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
@@ -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.
|
||||
BIN
data/survey.db
BIN
data/survey.db
Binary file not shown.
15
openspec/changes/update-user-exam-mobile-ux/proposal.md
Normal file
15
openspec/changes/update-user-exam-mobile-ux/proposal.md
Normal 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
|
||||
|
||||
@@ -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 的最低要求
|
||||
|
||||
11
openspec/changes/update-user-exam-mobile-ux/tasks.md
Normal file
11
openspec/changes/update-user-exam-mobile-ux/tasks.md
Normal file
@@ -0,0 +1,11 @@
|
||||
## 1. Implementation
|
||||
- [ ] 调整 QuizPage 页面结构为移动优先布局并保证 48×48 点击目标
|
||||
- [ ] 增加答题卡/题号导航的单手可操作入口
|
||||
- [ ] 增加左右滑动切换题目与键盘可达性
|
||||
- [ ] 优化加载状态与提交/弃考交互反馈
|
||||
- [ ] 增加题目预加载与切换动画性能优化
|
||||
|
||||
## 2. Verification
|
||||
- [ ] 通过 npm run check 与 npm run build
|
||||
- [ ] 覆盖移动端交互相关的自动化测试
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
35
src/utils/swipe.ts
Normal 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
37
test/swipe-detect.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user