待测试

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

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

View File

@@ -0,0 +1,93 @@
import { Input } from 'antd';
const { TextArea } = Input;
interface OptionListProps {
type: 'single' | 'multiple' | 'judgment' | 'text';
options?: string[];
value: string | string[];
onChange: (val: string | string[]) => void;
disabled?: boolean;
}
export const OptionList = ({ type, options, value, onChange, disabled }: OptionListProps) => {
const getOptionLabel = (index: number) => String.fromCharCode(65 + index);
const isSelected = (val: string) => {
if (Array.isArray(value)) {
return value.includes(val);
}
return value === val;
};
const handleSelect = (val: string) => {
if (disabled) return;
if (type === 'multiple') {
const current = Array.isArray(value) ? value : [];
const next = current.includes(val)
? current.filter(v => v !== val)
: [...current, val];
onChange(next);
} else {
onChange(val);
}
};
if (type === 'text') {
return (
<div className="mt-4">
<TextArea
rows={6}
value={value as string || ''}
onChange={(e) => onChange(e.target.value)}
placeholder="请输入您的答案..."
disabled={disabled}
className="rounded-lg border-gray-300 focus:border-[#00897B] focus:ring-[#00897B] text-base p-4"
/>
</div>
);
}
const renderOptions = () => {
if (type === 'judgment') {
return ['正确', '错误'].map((opt, idx) => ({ label: opt, value: opt, key: idx }));
}
return (options || []).map((opt, idx) => ({ label: opt, value: opt, key: idx }));
};
return (
<div className="space-y-3 mt-2">
{renderOptions().map((opt, index) => {
const selected = isSelected(opt.value);
return (
<div
key={opt.key}
onClick={() => handleSelect(opt.value)}
className={`
relative flex items-start p-4 rounded-xl border-2 transition-all duration-200 active:scale-[0.99] cursor-pointer
${selected
? 'border-[#00897B] bg-[#E0F2F1]'
: 'border-transparent bg-white shadow-sm hover:border-gray-200'}
`}
>
{/* 选项圆圈 */}
<div className={`
flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border mr-3 mt-0.5 transition-colors
${selected
? 'bg-[#00897B] border-[#00897B] text-white'
: 'bg-white border-gray-300 text-gray-500'}
`}>
{type === 'judgment' ? (index === 0 ? 'T' : 'F') : getOptionLabel(index)}
</div>
{/* 选项内容 */}
<div className={`text-base leading-snug select-none ${selected ? 'text-[#00695C] font-medium' : 'text-gray-700'}`}>
{opt.label}
</div>
</div>
);
})}
</div>
);
};

View File

@@ -0,0 +1,69 @@
import { Button, Modal } from 'antd';
import { AppstoreOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
interface QuizFooterProps {
current: number;
total: number;
onPrev: () => void;
onNext: () => void;
onSubmit: () => void;
onOpenSheet: () => void;
answeredCount: number;
}
export const QuizFooter = ({
current,
total,
onPrev,
onNext,
onSubmit,
onOpenSheet,
answeredCount
}: QuizFooterProps) => {
const isFirst = current === 0;
const isLast = current === total - 1;
return (
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 px-4 py-3 safe-area-bottom z-30 shadow-[0_-2px_10px_rgba(0,0,0,0.05)]">
<div className="max-w-4xl mx-auto flex items-center justify-between">
<Button
type="text"
icon={<LeftOutlined />}
onClick={onPrev}
disabled={isFirst}
className={`flex items-center text-gray-600 hover:text-[#00897B] ${isFirst ? 'opacity-30' : ''}`}
>
</Button>
<div
onClick={onOpenSheet}
className="flex flex-col items-center justify-center -mt-8 bg-white rounded-full h-16 w-16 shadow-lg border border-gray-100 cursor-pointer active:scale-95 transition-transform"
>
<AppstoreOutlined className="text-xl text-[#00897B] mb-1" />
<span className="text-[10px] text-gray-500 scale-90">
{answeredCount}/{total}
</span>
</div>
{isLast ? (
<Button
type="text"
onClick={onSubmit}
className="flex items-center text-[#00897B] font-medium hover:bg-teal-50"
>
<RightOutlined />
</Button>
) : (
<Button
type="text"
onClick={onNext}
className="flex items-center text-gray-600 hover:text-[#00897B]"
>
<RightOutlined />
</Button>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,39 @@
import { Button, Modal } from 'antd';
import { LeftOutlined, EyeOutlined, ClockCircleOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
interface QuizHeaderProps {
current: number;
total: number;
timeLeft: number | null;
onGiveUp: () => void;
}
export const QuizHeader = ({ current, total, timeLeft, onGiveUp }: QuizHeaderProps) => {
const navigate = useNavigate();
const formatTime = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
};
return (
<div className="bg-[#00897B] text-white px-4 h-14 flex items-center justify-between shadow-md sticky top-0 z-30">
<div className="flex items-center gap-1" onClick={onGiveUp}>
<LeftOutlined className="text-lg" />
<span className="text-base"></span>
</div>
<div className="flex items-center gap-2 bg-[#00796B] px-3 py-1 rounded-full text-sm">
<EyeOutlined />
<span></span>
</div>
<div className="flex items-center gap-2 text-base font-medium tabular-nums">
<ClockCircleOutlined />
<span>{timeLeft !== null ? formatTime(timeLeft) : '--:--'}</span>
</div>
</div>
);
};

View File

@@ -0,0 +1,28 @@
import { Progress } from 'antd';
interface QuizProgressProps {
current: number;
total: number;
}
export const QuizProgress = ({ current, total }: QuizProgressProps) => {
const percent = Math.round((current / total) * 100);
return (
<div className="bg-white px-4 py-3 border-b border-gray-100">
<div className="flex items-center gap-3 mb-2">
<span className="text-gray-500 text-sm">
<span className="text-gray-900 text-lg font-medium">{current}</span>/{total}
</span>
<Progress
percent={percent}
showInfo={false}
strokeColor="#00897B"
trailColor="#E0F2F1"
size="small"
className="m-0 flex-1"
/>
</div>
</div>
);
};