待测试

This commit is contained in:
2025-12-29 00:17:39 +08:00
parent 27fea6f647
commit 91e15ec9b0
7 changed files with 350 additions and 257 deletions

View File

@@ -1,13 +1,15 @@
import { useState, useEffect, useMemo, useRef, type TouchEventHandler } from 'react';
import { Card, Button, Radio, Checkbox, Input, message, Progress, Modal } from 'antd';
import { Card, Button, Modal, message } 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;
import { QuizHeader } from './quiz/components/QuizHeader';
import { QuizProgress } from './quiz/components/QuizProgress';
import { OptionList } from './quiz/components/OptionList';
import { QuizFooter } from './quiz/components/QuizFooter';
interface Question {
id: string;
@@ -216,24 +218,13 @@ const QuizPage = () => {
}, 1000);
};
const formatTime = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
};
const getTagColor = (type: string) => {
switch (type) {
case 'single':
return 'bg-blue-100 text-blue-800';
case 'multiple':
return 'bg-purple-100 text-purple-800';
case 'judgment':
return 'bg-orange-100 text-orange-800';
case 'text':
return 'bg-green-100 text-green-800';
default:
return 'bg-gray-100 text-gray-800';
case 'single': return 'bg-orange-100 text-orange-800 border-orange-200';
case 'multiple': return 'bg-blue-100 text-blue-800 border-blue-200';
case 'judgment': return 'bg-purple-100 text-purple-800 border-purple-200';
case 'text': return 'bg-green-100 text-green-800 border-green-200';
default: return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
@@ -322,7 +313,6 @@ const QuizPage = () => {
setSubmitting(true);
if (!forceSubmit) {
// 检查是否所有题目都已回答
const unansweredQuestions = questions.filter(q => !answers[q.id]);
if (unansweredQuestions.length > 0) {
message.warning(`还有 ${unansweredQuestions.length} 道题未作答`);
@@ -330,7 +320,6 @@ const QuizPage = () => {
}
}
// 准备答案数据
const answersData = questions.map(question => {
const isCorrect = checkAnswer(question, answers[question.id]);
return {
@@ -387,72 +376,6 @@ const QuizPage = () => {
}
};
const renderQuestion = (question: Question) => {
const currentAnswer = answers[question.id];
switch (question.type) {
case 'single':
return (
<Radio.Group
value={currentAnswer as string}
onChange={(e) => handleAnswerChange(question.id, e.target.value)}
className="w-full"
>
{question.options?.map((option, index) => (
<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>
))}
</Radio.Group>
);
case 'multiple':
return (
<Checkbox.Group
value={currentAnswer as string[] || []}
onChange={(checkedValues) => handleAnswerChange(question.id, checkedValues)}
className="w-full"
>
{question.options?.map((option, index) => (
<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>
))}
</Checkbox.Group>
);
case 'judgment':
return (
<Radio.Group
value={currentAnswer as string}
onChange={(e) => handleAnswerChange(question.id, e.target.value)}
className="w-full"
>
<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 min-h-12 rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-mars-200 transition-colors">
</Radio>
</Radio.Group>
);
case 'text':
return (
<TextArea
rows={6}
value={currentAnswer as string || ''}
onChange={(e) => handleAnswerChange(question.id, e.target.value)}
placeholder="请输入您的答案..."
className="rounded-lg border-gray-300 focus:border-mars-500 focus:ring-mars-500"
/>
);
default:
return <div></div>;
}
};
const answeredCount = useMemo(() => {
return questions.reduce((count, q) => (answers[q.id] ? count + 1 : count), 0);
}, [questions, answers]);
@@ -478,7 +401,6 @@ const QuizPage = () => {
}
const currentQuestion = questions[currentQuestionIndex];
const progress = ((currentQuestionIndex + 1) / questions.length) * 100;
const handleTouchStart: TouchEventHandler = (e) => {
if (e.touches.length !== 1) return;
@@ -514,187 +436,129 @@ const QuizPage = () => {
};
return (
<UserLayout>
<div className="max-w-4xl mx-auto pb-24">
{/* 头部信息 */}
<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-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 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-700"></div>
<div className={`text-2xl font-bold tabular-nums ${
timeLeft < 300 ? 'text-red-600' : 'text-mars-600'
}`}>
{formatTime(timeLeft)}
</div>
</div>
)}
</div>
</div>
<Progress
percent={Math.round(progress)}
strokeColor="#008C8C"
trailColor="#f0fcfc"
showInfo={false}
className="mt-2"
/>
</div>
<div className="min-h-screen bg-gray-50 flex flex-col">
<QuizHeader
current={currentQuestionIndex + 1}
total={questions.length}
timeLeft={timeLeft}
onGiveUp={handleGiveUp}
/>
<QuizProgress
current={currentQuestionIndex + 1}
total={questions.length}
/>
{/* 题目卡片 */}
<Card className="shadow-sm border border-gray-100 rounded-xl">
<div className="flex-1 overflow-y-auto pb-24 safe-area-bottom">
<div className="max-w-4xl mx-auto p-4">
<div
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
className="mb-6 md:mb-8"
className={`
transition-all duration-300 ease-out
${enterAnimationOn
? 'opacity-100 translate-x-0'
: navDirection === 'next'
? 'opacity-0 translate-x-4'
: 'opacity-0 -translate-x-4'
}
`}
>
<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]}
</span>
</div>
{currentQuestion.category && (
<div className="mb-3">
<span className="inline-block px-2 py-1 bg-gray-50 text-gray-700 text-xs rounded border border-gray-100">
{currentQuestion.category}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5 md:p-8 min-h-[400px]">
<div className="mb-6">
<span className={`inline-block px-3 py-1 rounded-md text-sm font-medium border ${getTagColor(currentQuestion.type)}`}>
{questionTypeMap[currentQuestion.type]}
</span>
</div>
)}
<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-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="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>
<Button
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>
{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>
{currentQuestion.category && (
<span className="ml-2 inline-block px-2 py-1 bg-gray-50 text-gray-600 text-xs rounded border border-gray-100">
{currentQuestion.category}
</span>
)}
</div>
<h2
ref={questionHeadingRef}
tabIndex={-1}
className="text-lg md:text-xl font-medium text-gray-900 leading-relaxed mb-8 outline-none"
>
{currentQuestion.content}
</h2>
<OptionList
type={currentQuestion.type}
options={currentQuestion.options}
value={answers[currentQuestion.id]}
onChange={(val) => handleAnswerChange(currentQuestion.id, val)}
/>
</div>
</div>
</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>
<QuizFooter
current={currentQuestionIndex}
total={questions.length}
onPrev={handlePrevious}
onNext={handleNext}
onSubmit={() => handleSubmit()}
onOpenSheet={() => setAnswerSheetOpen(true)}
answeredCount={answeredCount}
/>
<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">
<span className="text-[#00897B] font-medium">{answeredCount}</span> / {questions.length}
</div>
<Button
type="primary"
onClick={() => setAnswerSheetOpen(false)}
className="bg-[#00897B] hover:bg-[#00796B]"
>
</Button>
</div>
<div className="grid grid-cols-5 sm:grid-cols-6 gap-3">
{questions.map((q, idx) => {
const isCurrent = idx === currentQuestionIndex;
const isAnswered = !!answers[q.id];
let className = 'h-10 w-10 rounded-full flex items-center justify-center text-sm font-medium border transition-colors ';
if (isCurrent) {
className += 'border-[#00897B] bg-[#E0F2F1] text-[#00695C]';
} else if (isAnswered) {
className += 'border-[#00897B] bg-[#00897B] text-white';
} else {
className += 'border-gray-200 text-gray-600 bg-white hover:border-[#00897B]';
}
return (
<button
key={q.id}
type="button"
className={className}
onClick={() => {
setAnswerSheetOpen(false);
handleJumpTo(idx);
}}
aria-label={`跳转到第 ${idx + 1}`}
>
{idx + 1}
</button>
);
})}
</div>
</Modal>
</div>
);
};