diff --git a/src/domains/opic/pages/OPIcPage.jsx b/src/domains/opic/pages/OPIcPage.jsx index 365d912..af50e00 100644 --- a/src/domains/opic/pages/OPIcPage.jsx +++ b/src/domains/opic/pages/OPIcPage.jsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' import { Alert, Box, @@ -18,6 +19,8 @@ import { Chip, Divider, IconButton, + Grid, + Paper } from '@mui/material' import { RecordVoiceOver as VoiceIcon, @@ -29,10 +32,15 @@ import { Send as SendIcon, UploadFile as UploadIcon, VolumeUp as SpeakerIcon, + Home as HomeIcon, + TrendingUp as TrendingUpIcon, + Warning as WarningIcon, + Lightbulb as LightbulbIcon, + Email as EmailIcon } from '@mui/icons-material' import { useSettings } from '../../../contexts/SettingsContext' import { useAuth } from '../../../contexts/AuthContext' -import { sessionService, uploadAudioToS3 } from '../services/opicService' +import { sessionService, uploadAudioToS3, pollForAnswerResult } from '../services/opicService' import { OPIC_TOPICS, OPIC_TOPIC_LABELS, @@ -43,6 +51,7 @@ export default function OPIcPage() { const { t, isKorean } = useSettings() const { user } = useAuth() const theme = useTheme() + const navigate = useNavigate() const isMobile = useMediaQuery(theme.breakpoints.down('md')) // Session state @@ -64,6 +73,7 @@ export default function OPIcPage() { const [mediaRecorder, setMediaRecorder] = useState(null) const [recordingTime, setRecordingTime] = useState(0) const audioChunksRef = useRef([]) + const [processingStatus, setProcessingStatus] = useState(null) // Upload state const [uploadUrl, setUploadUrl] = useState(null) @@ -72,6 +82,10 @@ export default function OPIcPage() { // Feedback state const [feedback, setFeedback] = useState(null) + // Report state + const [sessionReport, setSessionReport] = useState(null) + const [showReport, setShowReport] = useState(false) + // UI state const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -150,6 +164,7 @@ export default function OPIcPage() { setRecordedUrl(null) setUploadUrl(null) setS3Key(null) + setProcessingStatus(null) } // Get next question @@ -162,14 +177,19 @@ export default function OPIcPage() { // 모든 질문 완료 확인 if (data.completed) { // 세션 완료 처리 - handleCompleteSession() + await handleCompleteSession() return } - // 질문 데이터로 화면 업데이트 - displayQuestion(data) // data.question → data로 수정 - setQuestionNumber(data.questionNumber || questionNumber + 1) - setTotalQuestions(data.totalQuestions || totalQuestions) + if (data.question) { + displayQuestion(data.question) + + // 질문 번호 업데이트 (백엔드에서 오는 questionNumber가 있으면 사용, 없으면 수동 계산) + setQuestionNumber(data.question.questionNumber || questionNumber + 1) + } else { + console.error("❌ 질문 데이터를 찾을 수 없습니다:", data) + setError(isKorean ? '질문 데이터를 불러오지 못했습니다' : 'Failed to load question data') + } } catch (err) { console.error('Failed to get next question:', err) setError(isKorean ? '다음 질문을 불러오는데 실패했습니다' : 'Failed to get next question') @@ -243,18 +263,34 @@ export default function OPIcPage() { setLoading(true) setError(null) setUploadProgress(0) + setProcessingStatus(isKorean ? 'S3에 업로드 중...' : 'Uploading to S3...') - // Upload to S3 + // 1. S3 업로드 await uploadAudioToS3(uploadUrl, recordedBlob) - setUploadProgress(50) + setUploadProgress(20) + setProcessingStatus(isKorean ? '답변 제출 중...' : 'Submitting...') + + // 2. 답변 제출 (비동기 처리 시작 요청) + const submitResult = await sessionService.submitAnswer(sessionId, { audioS3Key: s3Key }) + setUploadProgress(40) + setProcessingStatus(isKorean ? 'AI가 분석 중...' : 'AI is analyzing...') + + // 3. 폴링으로 결과 대기 (백엔드 완료될 때까지 반복 확인) + const result = await pollForAnswerResult(sessionId, submitResult.questionIndex, { + onProgress: ({ attempt }) => { + // 진행 상황에 따라 프로그레스 바를 조금씩 채움 (40% ~ 90%) + setUploadProgress(prev => Math.min(prev + 1, 90)) + } + }) - // Submit answer - const data = await sessionService.submitAnswer(sessionId, { audioS3Key: s3Key }) - setFeedback(data) + // 4. 최종 결과 세팅 + setFeedback(result) setUploadProgress(100) + setProcessingStatus(null) + } catch (err) { console.error('Failed to submit answer:', err) - setError(isKorean ? '답변 제출에 실패했습니다' : 'Failed to submit answer') + setError(isKorean ? '분석 중 오류가 발생했습니다. 다시 시도해주세요.' : 'Analysis failed. Please try again.') } finally { setLoading(false) setTimeout(() => setUploadProgress(0), 2000) @@ -269,18 +305,12 @@ export default function OPIcPage() { setLoading(true) setError(null) const report = await sessionService.complete(sessionId) - // TODO: Navigate to report page or display report - console.log('Session completed:', report) - alert( - isKorean - ? '세션이 완료되었습니다! 리포트를 확인하세요.' - : 'Session completed! Check your report.' - ) - // Reset for new session - setSessionId(null) - setCurrentQuestion(null) - setQuestionNumber(0) - setFeedback(null) + + const reportData = report.data || report; + + // 리포트 데이터 저장 및 화면 전환 + setSessionReport(reportData) + setShowReport(true) } catch (err) { console.error('Failed to complete session:', err) setError(isKorean ? '세션 완료에 실패했습니다' : 'Failed to complete session') @@ -289,6 +319,24 @@ export default function OPIcPage() { } } + const handleStartNewSession = () => { + setSessionId(null) + setCurrentQuestion(null) + setQuestionNumber(0) + setFeedback(null) + setShowReport(false) + setSessionReport(null) + setRecordedBlob(null) + setRecordedUrl(null) + setUploadUrl(null) + setS3Key(null) + } + + // Navigate to reports page + const handleGoToReports = () => { + navigate('/reports') + } + // Play question audio const handlePlayQuestionAudio = () => { if (questionAudioRef.current) { @@ -315,491 +363,332 @@ export default function OPIcPage() { } } + const getLevelColor = (level) => { + const colors = { + 'IM1': '#22c55e', 'IM2': '#3b82f6', 'IM3': '#8b5cf6', + 'IH': '#f97316', 'AL': '#ef4444' + }; + return colors[level] || '#3b82f6'; + } + + // 결과 리포트 화면 + if (showReport && sessionReport) { + return ( + + + + {/* Header: Score & Level */} + + + {isKorean ? '테스트 결과 리포트' : 'TEST REPORT'} + + + + {/* Level */} + + + {sessionReport.estimatedLevel} + + LEVEL + + + + + {/* Score */} + + + {sessionReport.overallScore} + + SCORE + + + + {/* Overall Feedback */} + + + {sessionReport.feedback} + + + + + + + {/* Details Grid */} + + {[ + { title: isKorean ? "잘한 점" : "Strengths", items: sessionReport.strengths, color: "#16a34a", icon: TrendingUpIcon }, + { title: isKorean ? "아쉬운 점" : "Weaknesses", items: sessionReport.weaknesses, color: "#ea580c", icon: WarningIcon }, + { title: isKorean ? "학습 추천" : "Tips", items: sessionReport.recommendations, color: "#7c3aed", icon: LightbulbIcon } + ].map((section, idx) => ( + + + + + + {section.title} + + + + {section.items && section.items.length > 0 ? ( + section.items.map((item, i) => ( + + {item} + + )) + ) : ( + - + )} + + + + ))} + + + {/* Email Notification */} + + } + label={isKorean ? "결과가 이메일로 발송되었습니다." : "Report sent to your email."} + variant="outlined" + size="small" + sx={{ borderColor: '#e2e8f0', color: '#94a3b8', fontSize: '0.8rem' }} + /> + + + + {/* Bottom Actions */} + + + + + + ); + } + return ( - + + {/* Header */} - - + + - - - {isKorean ? 'OPIc 스피킹 테스트' : 'OPIc Speaking Test'} - - - {isKorean - ? 'AI 기반 OPIc 연습 및 피드백' - : 'AI-powered English speaking practice & feedback'} - + {isKorean ? 'OPIc 스피킹 테스트' : 'OPIc Speaking Test'} + {isKorean ? 'AI 기반 OPIc 연습 및 피드백' : 'AI-powered English speaking practice'} {/* Main Content */} - - {error && ( - - {error} - - )} + + {error && {error}} - {/* Session Setup */} + {/* --- [1] Session Setup --- */} {!sessionId && ( - - {isKorean ? 'OPIc 질문 생성' : 'Session Setup'} - + {isKorean ? 'OPIc 질문 생성' : 'Session Setup'} - {/* 주제 선택 */} {isKorean ? '질문 유형' : 'Question Type'} - setSessionSettings((prev) => ({ ...prev, topic: e.target.value }))}> {Object.values(OPIC_TOPICS).map((topic) => ( - - {isKorean - ? OPIC_TOPIC_LABELS[topic].ko - : OPIC_TOPIC_LABELS[topic].en} - + {isKorean ? OPIC_TOPIC_LABELS[topic].ko : OPIC_TOPIC_LABELS[topic].en} ))} - {/* 세부 주제 (소재) 선택 - 업데이트된 목록 사용 */} {isKorean ? '주제' : 'Topic'} - setSessionSettings((prev) => ({ ...prev, subTopic: e.target.value }))} MenuProps={{ PaperProps: { sx: { maxHeight: 300 } } }}> {OPIC_SUBTOPICS.map((item) => ( - - {isKorean ? item.labelKo : item.labelEn} - + {isKorean ? item.labelKo : item.labelEn} ))} - - * {isKorean - ? `프로필에 설정된 나의 레벨(${user?.level})에 맞춰 AI 피드백이 제공됩니다.` - : `AI feedback will be tailored to your current level (${user?.level || 'IM2'}).`} + * {isKorean ? `프로필에 설정된 나의 레벨(${user?.level || 'IM2'})에 맞춰 AI 피드백이 제공됩니다.` : `AI feedback tailored to level (${user?.level || 'IM2'}).`} - )} - {/* Question Area */} + {/* --- [2] Question Area --- */} {sessionId && currentQuestion && ( <> - - + + - + {/* 질문 카드 */} + - - {currentQuestion.questionText} - - + {currentQuestion.questionText} {currentQuestion.audioUrl && ( - - {isPlayingQuestion ? ( - - ) : ( - - )} + + {isPlayingQuestion ? : } - - {isKorean ? '질문 듣기' : 'Listen to question'} - - )} - {/* Recording Area */} - + {/* 녹음 카드 */} + - - {isKorean ? '답변 녹음' : 'Record Answer'} - - - - - - {isRecording && ( - - {formatTime(recordingTime)} - - )} - + {isRecording && {formatTime(recordingTime)}} {recordedUrl && !isRecording && ( - - + + {isPlayingRecorded ? : } - - {isKorean ? '녹음 완료' : 'Recording Ready'} - - - {isKorean - ? '재생하여 확인하세요' - : 'Play to review'} - + {isKorean ? '녹음 완료' : 'Recorded'} - )} - {/* Submit Area */} + {/* 제출 버튼 영역 */} {recordedBlob && !feedback && ( - + {!uploadUrl ? ( - ) : ( - )} {uploadProgress > 0 && ( - + + + {processingStatus && ( + + {processingStatus} + + )} + )} )} - {/* Feedback Area */} + {/* 피드백 및 다음 질문 버튼 */} {feedback && ( - + - - {isKorean ? 'AI 피드백' : 'AI Feedback'} - + AI Feedback - {/* My Answer (STT) */} {feedback.transcript && ( - - {isKorean ? '내 답변' : 'Your Answer'} - + {isKorean ? '내 답변' : 'Your Answer'} {feedback.transcript} )} - {/* Corrected Answer */} {feedback.feedback?.correctedAnswer && ( - - {isKorean ? '교정된 답변' : 'Corrected Answer'} - + {isKorean ? '교정된 답변' : 'Corrected'} {feedback.feedback.correctedAnswer} )} - - {/* Model Answer */} - {feedback.feedback?.sampleAnswer && ( - - - {isKorean ? '모범 답변' : 'Model Answer'} - - {feedback.feedback.sampleAnswer} - - )} - - {/* Grammar Errors */} {feedback.feedback?.errors && feedback.feedback.errors.length > 0 && ( - - - {isKorean ? '문법 오류' : 'Grammar Errors'} + + + {isKorean ? ' 문법 오류 체크' : 'Grammar Errors'} {feedback.feedback.errors.map((error, index) => ( - - • {error.type}: {error.original} → {error.corrected} -
- - {error.explanation} + + + • {error.original}{error.corrected} - + {error.explanation && ( + + └ {error.explanation} + + )} +
))}
)} + {(feedback.feedback?.grammarCorrection || feedback.feedback?.feedback) && ( + + + + + {isKorean ? ' AI 학습 팁 & 문법 교정' : 'Tips & Grammar Correction'} + + + + {/* grammarCorrection이 있으면 우선 보여주고, 없으면 전체 feedback을 보여줌 */} + {feedback.feedback.grammarCorrection || feedback.feedback.feedback} + + + )} - {/* Action Buttons */} - {feedback.hasNextQuestion ? ( - ) : ( - )} @@ -810,5 +699,6 @@ export default function OPIcPage() { )}
- ) + ); } + diff --git a/src/domains/opic/services/opicService.js b/src/domains/opic/services/opicService.js index e98ac9f..3bff7b5 100644 --- a/src/domains/opic/services/opicService.js +++ b/src/domains/opic/services/opicService.js @@ -64,7 +64,7 @@ export const sessionService = { }, /** - * 답변 제출 (STT + AI 피드백) + * 답변 제출 * @param {string} sessionId * @param {Object} data - { audioS3Key } */ @@ -76,6 +76,17 @@ export const sessionService = { return response.data }, + /** + * 답변 상태 조회 (폴링용 함수 추가) + */ + async getAnswerStatus(sessionId, questionIndex) { + const response = await opicApi.get(`/opic/sessions/${sessionId}/answers/${questionIndex}/status`) + if (!response.isSuccess) { + throw new Error(response.message || 'Failed to get answer status') + } + return response.data; // { status, transcript, feedback 등 } + }, + /** * 세션 완료 * @param {string} sessionId @@ -106,7 +117,43 @@ export const uploadAudioToS3 = async (uploadUrl, audioBlob) => { return true } +/** + * 답변 처리 결과 폴링 헬퍼 함수 + */ +export const pollForAnswerResult = async (sessionId, questionIndex, options = {}) => { + const { + maxAttempts = 90, + intervalMs = 2000, // 2초 간격 + onProgress = null // 진행 상태 보고용 콜백 + } = options + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const result = await sessionService.getAnswerStatus(sessionId, questionIndex) + + // 1. 완료 시 결과 반환 + if (result.status === 'COMPLETED') { + return result + } + + // 2. 실패 시 에러 발생 + if (result.status === 'FAILED') { + throw new Error(result.message || 'AI 분석 중 오류가 발생했습니다.') + } + + // 3. 처리 중일 때 (PROCESSING) + if (onProgress) { + onProgress({ attempt: attempt + 1, status: result.status }) + } + + // 대기 후 재시도 + await new Promise(resolve => setTimeout(resolve, intervalMs)) + } + + throw new Error('분석 시간이 초과되었습니다. 잠시 후 다시 확인해주세요.') +} + export default { sessionService, uploadAudioToS3, + pollForAnswerResult } \ No newline at end of file