diff --git a/src/App.jsx b/src/App.jsx index 483d766..0a69750 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -34,6 +34,7 @@ import ChatRoomPage from './domains/freetalk/pages/ChatRoomPage' import ChatRoomModal from './domains/freetalk/components/ChatRoomModal' import VocabDashboard from './domains/vocab/pages/VocabDashboard' import DailyLearning from './domains/vocab/pages/DailyLearning' +import TestPage from './domains/vocab/pages/TestPage' import { useChat } from './contexts/ChatContext' import { useSettings } from './contexts/SettingsContext' @@ -364,6 +365,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/src/domains/vocab/components/TestQuestion.jsx b/src/domains/vocab/components/TestQuestion.jsx new file mode 100644 index 0000000..880d42f --- /dev/null +++ b/src/domains/vocab/components/TestQuestion.jsx @@ -0,0 +1,124 @@ +import { Box, Typography, Paper, Radio, RadioGroup, FormControlLabel } from '@mui/material' + +export default function TestQuestion({ + question, + selectedAnswer, + onSelect, + showResult = false, + disabled = false, +}) { + if (!question) return null + + const getOptionStyle = (option) => { + if (!showResult) { + return { + border: selectedAnswer === option ? '2px solid' : '1px solid', + borderColor: selectedAnswer === option ? 'primary.main' : 'divider', + backgroundColor: selectedAnswer === option ? 'primary.50' : 'background.paper', + } + } + + // 결과 표시 모드 + const isCorrect = option === question.correctAnswer + const isSelected = option === selectedAnswer + + if (isCorrect) { + return { + border: '2px solid', + borderColor: 'success.main', + backgroundColor: 'success.50', + } + } + if (isSelected && !isCorrect) { + return { + border: '2px solid', + borderColor: 'error.main', + backgroundColor: 'error.50', + } + } + return { + border: '1px solid', + borderColor: 'divider', + backgroundColor: 'background.paper', + opacity: 0.6, + } + } + + return ( + + {/* 문제 */} + + + {question.question} + + + {question.type === 'KOREAN_TO_ENGLISH' + ? '다음 중 올바른 영어 단어는?' + : '다음 중 올바른 한국어 뜻은?'} + + + + {/* 선택지 */} + !disabled && onSelect(e.target.value)} + > + + {question.options.map((option, index) => ( + !disabled && onSelect(option)} + sx={{ + p: 2, + cursor: disabled ? 'default' : 'pointer', + transition: 'all 0.2s', + ...getOptionStyle(option), + '&:hover': disabled + ? {} + : { + borderColor: 'primary.main', + transform: 'translateX(4px)', + }, + }} + > + + } + label={ + + {`${['①', '②', '③', '④'][index]} ${option}`} + + } + sx={{ m: 0, width: '100%' }} + /> + + ))} + + + + ) +} diff --git a/src/domains/vocab/pages/TestPage.jsx b/src/domains/vocab/pages/TestPage.jsx new file mode 100644 index 0000000..2ff9f6a --- /dev/null +++ b/src/domains/vocab/pages/TestPage.jsx @@ -0,0 +1,488 @@ +import { useState, useEffect, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Container, + Box, + Typography, + Button, + Paper, + ToggleButton, + ToggleButtonGroup, + RadioGroup, + Radio, + FormControlLabel, + FormControl, + FormLabel, + LinearProgress, + IconButton, + Chip, + Card, + CardContent, + CardActionArea, + CircularProgress, + Alert, +} from '@mui/material' +import { + ArrowBack as BackIcon, + PlayArrow as PlayIcon, + Timer as TimerIcon, + NavigateNext as NextIcon, + NavigateBefore as PrevIcon, +} from '@mui/icons-material' +import TestQuestion from '../components/TestQuestion' +import { testService } from '../services/vocabService' +import { LEVELS, LEVEL_LABELS, TEST_TYPES, TEST_TYPE_LABELS } from '../constants/vocabConstants' + +const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1' + +// 시험 설정 화면 +function TestSetup({ onStart, recentResults, loading }) { + const [wordCount, setWordCount] = useState(20) + const [level, setLevel] = useState(null) + const [type, setType] = useState(TEST_TYPES.ENGLISH_TO_KOREAN) + + const handleStart = () => { + onStart({ wordCount, level, type }) + } + + return ( + + + + 시험 설정 + + + {/* 문제 수 */} + + + 문제 수 + + val && setWordCount(val)} + fullWidth + > + 10개 + 20개 + 30개 + + + + {/* 레벨 */} + + + 레벨 + + setLevel(val)} + fullWidth + > + 전체 + {Object.entries(LEVEL_LABELS).map(([key, label]) => ( + + {label} + + ))} + + + + {/* 출제 유형 */} + + + + 출제 유형 + + setType(e.target.value)} + > + {Object.entries(TEST_TYPE_LABELS).map(([key, label]) => ( + } + label={label} + /> + ))} + + + + + + + + {/* 최근 시험 기록 */} + {recentResults.length > 0 && ( + + + 최근 시험 기록 + + {recentResults.map((result, index) => ( + + + + + + {new Date(result.completedAt).toLocaleDateString()} + + + {result.totalQuestions}문제 + + + = 80 ? 'success' : result.successRate >= 60 ? 'warning' : 'error'} + /> + + + + ))} + + )} + + ) +} + +// 시험 진행 화면 +function TestInProgress({ + questions, + currentIndex, + answers, + timeRemaining, + onAnswer, + onNext, + onPrev, + onSubmit, +}) { + const currentQuestion = questions[currentIndex] + const progress = ((currentIndex + 1) / questions.length) * 100 + const minutes = Math.floor(timeRemaining / 60) + const seconds = timeRemaining % 60 + + return ( + + {/* 헤더 */} + + + 문제 {currentIndex + 1} / {questions.length} + + } + label={`${minutes}:${seconds.toString().padStart(2, '0')}`} + color={timeRemaining < 60 ? 'error' : 'default'} + /> + + + {/* 진행률 */} + + + {/* 문제 */} + onAnswer(currentQuestion.questionId, answer)} + /> + + {/* 네비게이션 */} + + + + {currentIndex === questions.length - 1 ? ( + + ) : ( + + )} + + + {/* 문제 번호 표시 */} + + {questions.map((q, idx) => ( + onNext(idx - currentIndex)} + > + {idx + 1} + + ))} + + + ) +} + +// 결과 화면 +function TestResult({ result, onRetry, onHome }) { + const navigate = useNavigate() + + return ( + + + {result.successRate?.toFixed(0)}점 + + + {result.correctCount} / {result.totalQuestions} 정답 + + + + + + + {result.correctCount} + + 정답 + + + + {result.incorrectCount} + + 오답 + + + + + {/* 틀린 문제 */} + {result.results?.filter(r => !r.isCorrect).length > 0 && ( + + + 틀린 문제 + + {result.results.filter(r => !r.isCorrect).map((r, idx) => ( + + + 내 답: {r.userAnswer} + + + 정답: {r.correctAnswer} + + + ))} + + )} + + + + + + + ) +} + +export default function TestPage() { + const navigate = useNavigate() + const [phase, setPhase] = useState('setup') // setup, testing, result + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [recentResults, setRecentResults] = useState([]) + + // 시험 상태 + const [testId, setTestId] = useState(null) + const [questions, setQuestions] = useState([]) + const [currentIndex, setCurrentIndex] = useState(0) + const [answers, setAnswers] = useState({}) + const [timeRemaining, setTimeRemaining] = useState(0) + const [result, setResult] = useState(null) + + useEffect(() => { + fetchRecentResults() + }, []) + + // 타이머 + useEffect(() => { + if (phase !== 'testing' || timeRemaining <= 0) return + + const timer = setInterval(() => { + setTimeRemaining(prev => { + if (prev <= 1) { + handleSubmit() + return 0 + } + return prev - 1 + }) + }, 1000) + + return () => clearInterval(timer) + }, [phase, timeRemaining]) + + const fetchRecentResults = async () => { + try { + const response = await testService.getResults(TEMP_USER_ID, { limit: 5 }) + setRecentResults(response?.data?.testResults || []) + } catch (err) { + console.error('Fetch results error:', err) + } + } + + const handleStart = async (options) => { + try { + setLoading(true) + setError(null) + const response = await testService.start(TEMP_USER_ID, options) + + if (response?.data) { + setTestId(response.data.testId) + setQuestions(response.data.questions || []) + setTimeRemaining(options.wordCount * 30) // 문제당 30초 + setAnswers({}) + setCurrentIndex(0) + setPhase('testing') + } + } catch (err) { + console.error('Start test error:', err) + setError('시험을 시작할 수 없습니다.') + } finally { + setLoading(false) + } + } + + const handleAnswer = (questionId, answer) => { + setAnswers(prev => ({ ...prev, [questionId]: answer })) + } + + const handleNext = (offset = 1) => { + const newIndex = currentIndex + offset + if (newIndex >= 0 && newIndex < questions.length) { + setCurrentIndex(newIndex) + } + } + + const handlePrev = () => { + if (currentIndex > 0) { + setCurrentIndex(currentIndex - 1) + } + } + + const handleSubmit = async () => { + try { + setLoading(true) + const answersArray = questions.map(q => ({ + questionId: q.questionId, + wordId: q.wordId, + answer: answers[q.questionId] || '', + })) + + const response = await testService.submit(TEMP_USER_ID, testId, answersArray) + + if (response?.data) { + setResult(response.data) + setPhase('result') + } + } catch (err) { + console.error('Submit error:', err) + setError('제출에 실패했습니다.') + } finally { + setLoading(false) + } + } + + const handleRetry = () => { + setPhase('setup') + setTestId(null) + setQuestions([]) + setAnswers({}) + setResult(null) + fetchRecentResults() + } + + return ( + + {/* 헤더 */} + + navigate('/vocab')}> + + + + 단어 시험 + + + + {error && ( + setError(null)}> + {error} + + )} + + {phase === 'setup' && ( + + )} + + {phase === 'testing' && ( + handleNext(1)} + onPrev={handlePrev} + onSubmit={handleSubmit} + /> + )} + + {phase === 'result' && result && ( + navigate('/vocab')} + /> + )} + + ) +}