前端页面部分功能完成测试,需继续完善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

@@ -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>
);