前端页面部分功能完成测试,需继续完善UI布局和功能
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user